ACMX 2.139.0
Dual-Backend Real-Time GPU Video Synthesis
Loading...
Searching...
No Matches
ACMX2/acmx.cpp
Go to the documentation of this file.
1/**
2 * @file acmx.cpp
3 * @brief ACMX2 — Real-time GPU-accelerated video glitch engine.
4 *
5 * This file implements the complete ACMX2 command-line application, the core
6 * engine of the acidcam-gpu project. It combines CUDA GPU filters, GLSL shader
7 * processing, OpenGL rendering, optional 3D model support, audio reactivity,
8 * MIDI controller input, and video recording into a single real-time pipeline.
9 *
10 * @section arch Architecture Overview
11 * - **TextureUploader** — Host-zero-copy CUDA↔OpenGL image interop (direct cudaArray binding) for GPU frames.
12 * - **ShaderCache / ShaderLibrary** — Compile, cache, and manage GLSL shader programs.
13 * - **FrameCache** — Ring-buffer of recent frames for temporal ("cache") shaders.
14 * - **SnapshotThreadPool** — Async PNG snapshot writer.
15 * - **ACView** — Main GL object: capture → filter → shade → record pipeline.
16 * - **MainWindow** — SDL2/OpenGL window host.
17 *
18 * @copyright (C) 2026 LostSideDead Software — BSD 2-Clause License
19 * @see https://lostsidedead.biz
20 */
21
22#include "mxwrite.hpp"
24#include "version_info.hpp"
25#include <algorithm>
26#include <argz.hpp>
27#include <atomic>
28#include <cctype>
29#include <chrono>
30#include <cmath>
31#include <condition_variable>
32#include <csignal>
33#include <cstdlib>
34#include <cstring>
35#include <ctime>
36#include <deque>
37#include <filesystem>
38#include <fstream>
39#include <functional>
40#include <gl.hpp>
41#include <iomanip>
42#include <limits>
43#include <map>
44#include <mutex>
45#include <mx.hpp>
46#include <opencv2/opencv.hpp>
47#include <optional>
48#include <queue>
49#include <random>
50#include <regex>
51#include <sstream>
52#include <string>
53#include <thread>
54#include <tuple>
55#include <unordered_map>
56#include <vector>
57#ifdef AUDIO_ENABLED
58#include "audio.hpp"
59#include "file_audio.hpp"
60
62#endif
63#ifdef MIDI_ENABLED
64#include <rtmidi/RtMidi.h>
65#endif
66#include "program.hpp"
67#ifdef ACMX2_WITH_DNN
68#include "dnn.hpp"
69#include <memory>
70#endif
71#ifdef ACMX2_WITH_WEBP
72#include <webp/encode.h>
73#endif
74#ifdef ACMX2_WITH_TIFF
75#include <tiffio.h>
76#endif
77#ifdef ACMX2_WITH_CUDA
78#include <ac-gpu/ac-gpu.hpp>
79#include <cuda_gl_interop.h>
80#else
81// Stubs so code compiled without CUDA still has the symbols it references.
82#ifndef CHECK_CUDA
83#define CHECK_CUDA(call) \
84 do { \
85 static_cast<void>(call); \
86 } while (0)
87#endif
88namespace ac_gpu {
89 inline constexpr int AC_FILTER_MAX = 0;
90 struct Filter {
91 int index;
92 std::string name;
93 };
94 struct GPUFilter {
95 int index;
96 };
98 int arraySize = 0;
99 };
100 // Empty filter table so code referencing ac_gpu::filters still compiles.
101 // (Never indexed in no-CUDA builds because AC_FILTER_MAX == 0 guards all uses.)
102 inline Filter filters[1] = {{0, ""}};
103} // namespace ac_gpu
104#endif
105#include <libavcodec/avcodec.h>
106#include <libavformat/avformat.h>
107#include <libavutil/hwcontext.h>
108#include <libavutil/imgutils.h>
109#include <libswscale/swscale.h>
110#ifdef __linux__
111#include <fcntl.h>
112#include <linux/videodev2.h>
113#include <semaphore.h>
114#include <sys/ioctl.h>
115#include <sys/mman.h>
116#include <sys/stat.h>
117#include <unistd.h>
118#endif
119#if defined(__APPLE__)
120#include <fcntl.h>
121#include <mach-o/dyld.h>
122#include <semaphore.h>
123#include <sys/mman.h>
124#include <sys/stat.h>
125#include <unistd.h>
126#endif
127#include <deque>
128#include <glm/gtc/matrix_transform.hpp>
129#include <glm/gtc/type_ptr.hpp>
130#include <model.hpp>
131#ifdef ACMX2_WITH_CUDA
132#include <opencv2/core/cuda.hpp>
133#include <opencv2/cudaimgproc.hpp>
134#include <opencv2/cudawarping.hpp>
135#endif
136#include <opencv2/opencv.hpp>
137#include <string_view>
138
139#ifdef _WIN32
140#ifndef NOMINMAX
141#define NOMINMAX
142#endif
143#include <windows.h>
144#endif
145
146/**
147 * @brief Return the directory containing the running ACMX2 executable.
148 *
149 * Installed ACMX2 layouts place the program in @c PREFIX/bin and its runtime
150 * data in @c PREFIX/share/acmx2. Resolving this from the executable keeps a
151 * relocated installation self-contained instead of assuming @c /usr/local.
152 */
153static std::filesystem::path executable_directory() {
154#if defined(__linux__)
155 std::vector<char> executable_path(1024, '\0');
156 while (true) {
157 const ssize_t length = readlink("/proc/self/exe", executable_path.data(), executable_path.size() - 1);
158 if (length < 0) {
159 return {};
160 }
161 if (static_cast<size_t>(length) < executable_path.size() - 1) {
162 executable_path[static_cast<size_t>(length)] = '\0';
163 return std::filesystem::path(executable_path.data()).parent_path();
164 }
165 executable_path.resize(executable_path.size() * 2, '\0');
166 }
167#elif defined(__APPLE__)
168 uint32_t required_size = 0;
169 if (_NSGetExecutablePath(nullptr, &required_size) != -1 || required_size == 0) {
170 return {};
171 }
172
173 std::vector<char> executable_path(required_size, '\0');
174 if (_NSGetExecutablePath(executable_path.data(), &required_size) != 0) {
175 return {};
176 }
177
178 std::error_code error;
179 const std::filesystem::path resolved_path = std::filesystem::weakly_canonical(executable_path.data(), error);
180 return error ? std::filesystem::path(executable_path.data()).parent_path() : resolved_path.parent_path();
181#elif defined(_WIN32)
182 std::vector<wchar_t> executable_path(MAX_PATH);
183 while (true) {
184 const DWORD length = GetModuleFileNameW(nullptr, executable_path.data(), static_cast<DWORD>(executable_path.size()));
185 if (length == 0U) {
186 return {};
187 }
188 if (length < executable_path.size() - 1U) {
189 return std::filesystem::path(std::wstring(executable_path.data(), length)).parent_path();
190 }
191 if (executable_path.size() >= 32768U) {
192 return {};
193 }
194 executable_path.resize(executable_path.size() * 2U);
195 }
196#else
197 return {};
198#endif
199}
200
201static bool set_environment_if_missing(const char *name, const char *value) {
202 if (std::getenv(name) != nullptr) {
203 return true;
204 }
205#ifdef _WIN32
206 return _putenv_s(name, value) == 0;
207#else
208 return setenv(name, value, 0) == 0;
209#endif
210}
211
212/** @brief Locate the installed ACMX2 asset root relative to the executable. */
213static std::filesystem::path installed_assets_directory() {
214 const std::filesystem::path executable_dir = executable_directory();
215 if (executable_dir.empty()) {
216 return {};
217 }
218
219 const std::vector<std::filesystem::path> candidates = {
220 executable_dir.parent_path() / "share" / "acmx2",
221#if defined(__APPLE__)
222 executable_dir.parent_path() / "Resources" / "acmx2",
223#endif
224 executable_dir,
225 };
226 for (const std::filesystem::path &candidate : candidates) {
227 std::error_code error;
228 if (std::filesystem::is_regular_file(candidate / "data" / "win-icon.png", error)) {
229 return candidate;
230 }
231 }
232 return {};
233}
234
235/// @brief Copy the audio track from one media file to another via FFmpeg.
236void transfer_audio(std::string_view, std::string_view);
237
238static std::string safeGLString(GLenum name) {
239 const GLubyte *value = glGetString(name);
240 if (!value) {
241 return "unavailable";
242 }
243 return reinterpret_cast<const char *>(value);
244}
245
247 int major = 4;
248 int minor = 1;
249};
250
251/** True when the active ACMX2 context can run OpenGL compute shaders. */
252static bool compute_shader_supported = false;
253
254#if defined(__linux__) || defined(_WIN32)
255/**
256 * @brief Test whether SDL can create a core-profile context of a given version.
257 *
258 * OpenGL capabilities cannot be queried without first creating a context, so
259 * Linux and Windows startup use a small hidden probe window before constructing
260 * the real ACMX2 window. The probe uses the already-selected SDL video driver,
261 * including the offscreen driver selected by Linux silent mode.
262 */
263static bool probe_open_gl_context(int major, int minor, std::string &error_message) {
264 const bool video_was_initialized = (SDL_WasInit(SDL_INIT_VIDEO) & SDL_INIT_VIDEO) != 0;
265 if (!video_was_initialized && SDL_InitSubSystem(SDL_INIT_VIDEO) != 0) {
266 error_message = SDL_GetError();
267 return false;
268 }
269
270 SDL_GL_ResetAttributes();
271 SDL_GL_SetAttribute(SDL_GL_CONTEXT_MAJOR_VERSION, major);
272 SDL_GL_SetAttribute(SDL_GL_CONTEXT_MINOR_VERSION, minor);
273 SDL_GL_SetAttribute(SDL_GL_CONTEXT_PROFILE_MASK, SDL_GL_CONTEXT_PROFILE_CORE);
274 SDL_GL_SetAttribute(SDL_GL_STENCIL_SIZE, 8);
275 SDL_GL_SetAttribute(SDL_GL_DOUBLEBUFFER, 1);
276
277 SDL_Window *probe_window = SDL_CreateWindow("ACMX2 OpenGL Probe", SDL_WINDOWPOS_UNDEFINED, SDL_WINDOWPOS_UNDEFINED, 32, 32, SDL_WINDOW_OPENGL | SDL_WINDOW_HIDDEN);
278 if (!probe_window) {
279 error_message = SDL_GetError();
280 if (!video_was_initialized) {
281 SDL_QuitSubSystem(SDL_INIT_VIDEO);
282 }
283 return false;
284 }
285
286 SDL_GLContext probe_context = SDL_GL_CreateContext(probe_window);
287 bool version_supported = false;
288 if (probe_context) {
289 int actual_major = 0;
290 int actual_minor = 0;
291 SDL_GL_GetAttribute(SDL_GL_CONTEXT_MAJOR_VERSION, &actual_major);
292 SDL_GL_GetAttribute(SDL_GL_CONTEXT_MINOR_VERSION, &actual_minor);
293 version_supported = actual_major > major || (actual_major == major && actual_minor >= minor);
294 if (!version_supported) {
295 error_message = "driver returned OpenGL " + std::to_string(actual_major) + "." + std::to_string(actual_minor);
296 }
297 SDL_GL_DeleteContext(probe_context);
298 } else {
299 error_message = SDL_GetError();
300 }
301
302 SDL_DestroyWindow(probe_window);
303 if (!video_was_initialized) {
304 SDL_QuitSubSystem(SDL_INIT_VIDEO);
305 }
306 return version_supported;
307}
308
309/** Select OpenGL 4.3 for compute shaders, falling back to ACMX2's 4.1 baseline. */
311 std::string error_message;
312 if (probe_open_gl_context(4, 3, error_message)) {
313 mx::system_out << "acmx2: OpenGL 4.3 context available; compute shaders enabled\n";
314 return {4, 3};
315 }
316
317 mx::system_out << "acmx2: OpenGL 4.3 unavailable (" << error_message << "); falling back to OpenGL 4.1 with compute shaders disabled\n";
318 error_message.clear();
319 if (!probe_open_gl_context(4, 1, error_message)) {
320 throw std::runtime_error("OpenGL 4.1 or newer is required: " + error_message);
321 }
322 return {4, 1};
323}
324#else
326#endif
327
328/** Update the feature flag from the real context rather than only the probe. */
330#if defined(__linux__) || defined(_WIN32)
331 GLint major = 0;
332 GLint minor = 0;
333 glGetIntegerv(GL_MAJOR_VERSION, &major);
334 glGetIntegerv(GL_MINOR_VERSION, &minor);
335 compute_shader_supported = major > 4 || (major == 4 && minor >= 3);
336#else
338#endif
339 mx::system_out << "acmx2: Compute shader support: " << (compute_shader_supported ? "enabled" : "disabled") << "\n";
340}
341
342/**
343 * @brief Print the active driver's shader-uniform capacity in one line.
344 *
345 * GL_MAX_UNIFORM_LOCATIONS became core in OpenGL 4.3. Older contexts still
346 * expose the per-stage component limits, so report locations as unsupported
347 * instead of issuing an invalid glGetIntegerv query.
348 */
350 GLint max_vertex_components = 0;
351 GLint max_fragment_components = 0;
352 GLint max_uniform_locations = 0;
353
354 glGetIntegerv(GL_MAX_VERTEX_UNIFORM_COMPONENTS, &max_vertex_components);
355 glGetIntegerv(GL_MAX_FRAGMENT_UNIFORM_COMPONENTS, &max_fragment_components);
356
357 GLint major_version = 0;
358 GLint minor_version = 0;
359 glGetIntegerv(GL_MAJOR_VERSION, &major_version);
360 glGetIntegerv(GL_MINOR_VERSION, &minor_version);
361 bool uniform_locations_supported = major_version > 4 || (major_version == 4 && minor_version >= 3);
362#ifdef GL_ARB_explicit_uniform_location
363 uniform_locations_supported = uniform_locations_supported || SDL_GL_ExtensionSupported("GL_ARB_explicit_uniform_location") == SDL_TRUE;
364#endif
365
366#ifdef GL_MAX_UNIFORM_LOCATIONS
367 if (uniform_locations_supported) {
368 glGetIntegerv(GL_MAX_UNIFORM_LOCATIONS, &max_uniform_locations);
369 }
370#else
371 uniform_locations_supported = false;
372#endif
373
374 mx::system_out << "acmx2: OpenGL uniform limits: vertex components=" << max_vertex_components << " (GL_MAX_VERTEX_UNIFORM_COMPONENTS), "
375 << "fragment components=" << max_fragment_components << " (GL_MAX_FRAGMENT_UNIFORM_COMPONENTS), uniform locations=";
376 if (uniform_locations_supported) {
377 mx::system_out << max_uniform_locations;
378 } else {
379 mx::system_out << "unsupported by this OpenGL context";
380 }
381 mx::system_out << " (GL_MAX_UNIFORM_LOCATIONS)\n";
382}
383
384#ifdef __linux__
385/**
386 * @brief Return whether a numbered V4L2 device is provided by v4l2loopback.
387 *
388 * Loopback devices expose only their current frame interval through
389 * VIDIOC_ENUM_FRAMEINTERVALS, even though consumers may select another
390 * time-per-frame value with VIDIOC_S_PARM. Detecting them lets camera
391 * enumeration expose useful high-speed choices to the Qt interface.
392 */
393static bool isV4l2LoopbackDevice(int device_index) {
394 const std::string device_path = "/dev/video" + std::to_string(device_index);
395 const int fd = open(device_path.c_str(), O_RDWR | O_NONBLOCK);
396 if (fd < 0) {
397 return false;
398 }
399
400 v4l2_capability capability{};
401 const bool queried = ioctl(fd, VIDIOC_QUERYCAP, &capability) == 0;
402 close(fd);
403 if (!queried) {
404 return false;
405 }
406
407 const std::string driver(reinterpret_cast<const char *>(capability.driver));
408 return driver.find("v4l2loopback") != std::string::npos || driver.find("v4l2 loopback") != std::string::npos;
409}
410#endif
411
412static std::optional<std::string> normalizeShaderIndexEntry(const std::string &raw) {
413 const auto first = raw.find_first_not_of(" \t\r\n");
414 if (first == std::string::npos) {
415 return std::nullopt;
416 }
417 const auto last = raw.find_last_not_of(" \t\r\n");
418 std::string entry = raw.substr(first, last - first + 1);
419
420 if (entry.empty()) {
421 return std::nullopt;
422 }
423
424 std::replace(entry.begin(), entry.end(), '\\', '/');
425 std::filesystem::path rel(entry);
426 if (rel.is_absolute()) {
427 return std::nullopt;
428 }
429
430 std::filesystem::path normalized = rel.lexically_normal();
431 std::string normalized_str = normalized.generic_string();
432 while (normalized_str.rfind("./", 0) == 0) {
433 normalized_str.erase(0, 2);
434 }
435
436 if (normalized_str.empty() || normalized_str == "." || normalized_str == "..") {
437 return std::nullopt;
438 }
439 if (normalized_str.rfind("../", 0) == 0 || normalized_str.find("/../") != std::string::npos || (normalized_str.size() >= 3 && normalized_str.compare(normalized_str.size() - 3, 3, "/..") == 0)) {
440 return std::nullopt;
441 }
442
443 return normalized_str;
444}
445
446static bool resolveShaderPathInLibrary(const std::string &library_path, const std::string &relative_path, std::string &resolved_full_path) {
447 std::error_code ec;
448 std::filesystem::path base = std::filesystem::weakly_canonical(std::filesystem::path(library_path), ec);
449 if (ec) {
450 ec.clear();
451 base = std::filesystem::absolute(std::filesystem::path(library_path), ec);
452 if (ec) {
453 return false;
454 }
455 }
456
457 std::filesystem::path target = std::filesystem::weakly_canonical(base / std::filesystem::path(relative_path), ec);
458 if (ec) {
459 return false;
460 }
461
462 const std::filesystem::path relative = target.lexically_relative(base);
463 const std::string relative_str = relative.generic_string();
464 if (relative.empty() || relative_str == ".." || relative_str.rfind("../", 0) == 0) {
465 return false;
466 }
467
468 if (!std::filesystem::exists(target) || !std::filesystem::is_regular_file(target)) {
469 return false;
470 }
471
472 resolved_full_path = target.string();
473 return true;
474}
475
476static bool isEditorPreviewPath(const std::filesystem::path &library_path, const std::filesystem::path &requested_path) {
477 std::error_code error;
478 const std::filesystem::path preview_root = std::filesystem::weakly_canonical(library_path / ".acmx2-editor-preview", error);
479 if (error)
480 return false;
481 const std::filesystem::path relative = requested_path.lexically_relative(preview_root);
482 const std::string relative_text = relative.generic_string();
483 return !relative.empty() && relative_text != ".." && relative_text.rfind("../", 0) != 0;
484}
485
486static bool isEditorPreviewForShader(const std::filesystem::path &loaded_shader, const std::filesystem::path &requested_path) {
487 if (requested_path.parent_path().filename() != ".acmx2-editor-preview")
488 return false;
489 const std::filesystem::path source_root = requested_path.parent_path().parent_path();
490 const std::filesystem::path relative = loaded_shader.lexically_relative(source_root);
491 const std::string relative_text = relative.generic_string();
492 return !relative.empty() && relative_text != ".." && relative_text.rfind("../", 0) != 0;
493}
494
496
498
499static bool isComputeShaderFile(const std::string &path) {
500 std::string extension = std::filesystem::path(path).extension().string();
501 std::transform(extension.begin(), extension.end(), extension.begin(), [](unsigned char ch) { return static_cast<char>(std::tolower(ch)); });
502 return extension == ".comp";
503}
504
507 std::string path;
508 std::vector<std::string> entries;
510 std::string name;
511 double minimum = 0.0;
512 double maximum = 1.0;
513 double step = 0.01;
514 double value = 0.0;
515 };
516 std::vector<CustomUniform> custom_uniforms;
517};
518
519static bool isValidCustomUniformName(const std::string &name) {
520 if (name.empty() || name.size() >= acmx2::ipc::kShaderSelectionMaxUniformName || name.rfind("gl_", 0) == 0 || !(std::isalpha(static_cast<unsigned char>(name.front())) || name.front() == '_')) {
521 return false;
522 }
523 return std::all_of(name.begin() + 1, name.end(), [](unsigned char ch) { return std::isalnum(ch) || ch == '_'; });
524}
525
526static std::string shaderManifestPath(const std::string &library_path) {
527 const std::filesystem::path root(library_path);
528 const std::filesystem::path json_path = root / "library.json";
529 if (std::filesystem::is_regular_file(json_path))
530 return json_path.string();
531 const std::filesystem::path text_path = root / "index.txt";
532 if (std::filesystem::is_regular_file(text_path))
533 return text_path.string();
534 return {};
535}
536
537static bool loadShaderManifest(const std::string &library_path, ShaderManifestData &manifest, std::string &error) {
538 manifest = {};
539 error.clear();
540 manifest.path = shaderManifestPath(library_path);
541 if (manifest.path.empty()) {
542 error = "No library.json or index.txt found at: " + library_path;
543 return false;
544 }
545
546 if (std::filesystem::path(manifest.path).extension() == ".json") {
548 try {
549 cv::FileStorage storage(manifest.path, cv::FileStorage::READ | cv::FileStorage::FORMAT_JSON);
550 if (!storage.isOpened()) {
551 error = "Could not open shader manifest: " + manifest.path;
552 return false;
553 }
554 const cv::FileNode shaders = storage["shaders"];
555 if (shaders.type() == cv::FileNode::NONE || !shaders.isSeq()) {
556 error = manifest.path + " must contain a 'shaders' array";
557 return false;
558 }
559 for (const cv::FileNode &entry : shaders) {
560 std::string file;
561 if (entry.isString()) {
562 entry >> file;
563 } else if (entry.isMap() && !entry["file"].empty()) {
564 entry["file"] >> file;
565 } else {
566 error = manifest.path + " contains a shader entry without a file name";
567 return false;
568 }
569 manifest.entries.push_back(std::move(file));
570 }
571 const cv::FileNode uniforms = storage["custom_uniforms"];
572 if (!uniforms.empty()) {
573 if (!uniforms.isMap()) {
574 error = manifest.path + " field 'custom_uniforms' must be an object";
575 return false;
576 }
577 for (auto it = uniforms.begin(); it != uniforms.end(); ++it) {
579 error = manifest.path + " contains too many custom uniforms";
580 return false;
581 }
582 const cv::FileNode entry = *it;
584 uniform.name = entry.name();
585 if (!entry.isMap() || !isValidCustomUniformName(uniform.name)) {
586 error = manifest.path + " contains an invalid custom uniform: " + uniform.name;
587 return false;
588 }
589 if (!entry["minimum"].empty())
590 entry["minimum"] >> uniform.minimum;
591 if (!entry["maximum"].empty())
592 entry["maximum"] >> uniform.maximum;
593 if (!entry["step"].empty())
594 entry["step"] >> uniform.step;
595 uniform.value = uniform.minimum;
596 if (!entry["value"].empty())
597 entry["value"] >> uniform.value;
598 if (!std::isfinite(uniform.minimum) || !std::isfinite(uniform.maximum) || !std::isfinite(uniform.step) || !std::isfinite(uniform.value) || uniform.maximum <= uniform.minimum || uniform.step <= 0.0) {
599 error = manifest.path + " contains an invalid range for custom uniform: " + uniform.name;
600 return false;
601 }
602 uniform.value = std::clamp(uniform.value, uniform.minimum, uniform.maximum);
603 manifest.custom_uniforms.push_back(std::move(uniform));
604 }
605 }
606 return true;
607 } catch (const cv::Exception &e) {
608 error = "Could not parse " + manifest.path + ": " + e.what();
609 return false;
610 }
611 }
612
614 std::ifstream file(manifest.path);
615 if (!file.is_open()) {
616 error = "Could not open shader manifest: " + manifest.path;
617 return false;
618 }
619 std::string line;
620 while (std::getline(file, line))
621 manifest.entries.push_back(std::move(line));
622 return true;
623}
624
625static bool writeJsonShaderManifest(const std::string &path, const std::vector<std::string> &entries, const std::vector<ShaderManifestData::CustomUniform> &custom_uniforms, std::string &error) {
626 try {
627 cv::FileStorage storage(path, cv::FileStorage::WRITE | cv::FileStorage::FORMAT_JSON);
628 if (!storage.isOpened()) {
629 error = "Could not write shader manifest: " + path;
630 return false;
631 }
632 storage << "version" << 1;
633 storage << "shaders"
634 << "[";
635 for (const std::string &entry : entries)
636 storage << entry;
637 storage << "]";
638 if (!custom_uniforms.empty()) {
639 storage << "custom_uniforms"
640 << "{";
641 for (const auto &uniform : custom_uniforms) {
642 storage << uniform.name << "{"
643 << "minimum" << uniform.minimum << "maximum" << uniform.maximum << "step" << uniform.step << "value" << uniform.value << "}";
644 }
645 storage << "}";
646 }
647 storage.release();
648 return true;
649 } catch (const cv::Exception &e) {
650 error = "Could not write " + path + ": " + e.what();
651 return false;
652 }
653}
654
655static bool collectShaderLibraryEntries(const std::string &library_path, std::vector<std::string> &shader_files, std::string &error) {
656 ShaderManifestData manifest;
657 if (!loadShaderManifest(library_path, manifest, error))
658 return false;
659 shader_files.clear();
660 for (const std::string &entry : manifest.entries) {
661 const auto shader_entry = normalizeShaderIndexEntry(entry);
662 if (!shader_entry)
663 continue;
664 std::string full_path;
665 if (resolveShaderPathInLibrary(library_path, *shader_entry, full_path))
666 shader_files.push_back(*shader_entry);
667 }
668 std::sort(shader_files.begin(), shader_files.end(), [](const std::string &a, const std::string &b) { return std::lexicographical_compare(a.begin(), a.end(), b.begin(), b.end(), [](unsigned char ca, unsigned char cb) { return std::tolower(ca) < std::tolower(cb); }); });
669 return true;
670}
671
672static std::vector<std::string> sortedShaderLibraryEntries(const std::string &library_path) {
673 std::vector<std::string> shader_files;
674 std::string error;
675 if (!collectShaderLibraryEntries(library_path, shader_files, error))
676 mx::system_err << "acmx2: " << error << "\n";
677 return shader_files;
678}
679
680static int shaderIndexForFile(const std::vector<std::string> &shader_files, const std::string &shader_file) {
681 const auto selected = std::find(shader_files.begin(), shader_files.end(), shader_file);
682 if (selected == shader_files.end())
683 return -1;
684 return static_cast<int>(std::distance(shader_files.begin(), selected));
685}
686
687// ---------------------------------------------------------------------------
688// Graceful shutdown for headless / --silent mode.
689//
690// When acmx2 is running without a visible window (offscreen / batch mode),
691// pressing Ctrl+C should stop encoding, flush the writer, and close the
692// output file cleanly so the partial video is still playable. We install a
693// POSIX signal handler that does nothing but set an atomic flag; the main
694// render loop polls this flag each frame and triggers ACView::requestStop()
695// which unwinds the normal EOF path (flush encoder, close file, trailer).
696// ---------------------------------------------------------------------------
697namespace {
698 std::atomic<bool> g_shutdown_requested{false};
699
700#if defined(__linux__) || defined(__APPLE__)
701 extern "C" void acmx2_signal_handler(int /*sig*/) {
702 // Async-signal-safe: only a relaxed atomic store.
703 g_shutdown_requested.store(true, std::memory_order_relaxed);
704 }
705
707 struct sigaction sa{};
708 sa.sa_handler = &acmx2_signal_handler;
709 sigemptyset(&sa.sa_mask);
710 // No SA_RESTART: let blocking syscalls (e.g. read) return EINTR so
711 // the decoder thread can observe shutdown quickly.
712 sa.sa_flags = 0;
713 sigaction(SIGINT, &sa, nullptr);
714 sigaction(SIGTERM, &sa, nullptr);
715 sigaction(SIGHUP, &sa, nullptr);
716 }
717#elif defined(_WIN32)
718 extern "C" void acmx2_signal_handler(int) { g_shutdown_requested.store(true, std::memory_order_relaxed); }
719
721 std::signal(SIGINT, &acmx2_signal_handler);
722 std::signal(SIGTERM, &acmx2_signal_handler);
723 }
724#else
726#endif
727} // namespace
728
729#if defined(__APPLE__)
730class ScopedStderrSilence {
731 public:
732 ScopedStderrSilence() {
733 saved_stderr_fd = dup(STDERR_FILENO);
734 if (saved_stderr_fd < 0) {
735 return;
736 }
737 null_fd = open("/dev/null", O_WRONLY);
738 if (null_fd < 0) {
739 close(saved_stderr_fd);
740 saved_stderr_fd = -1;
741 return;
742 }
743 if (dup2(null_fd, STDERR_FILENO) < 0) {
744 close(null_fd);
745 close(saved_stderr_fd);
746 null_fd = -1;
747 saved_stderr_fd = -1;
748 return;
749 }
750 active = true;
751 }
752
753 ~ScopedStderrSilence() {
754 if (saved_stderr_fd >= 0) {
755 dup2(saved_stderr_fd, STDERR_FILENO);
756 close(saved_stderr_fd);
757 }
758 if (null_fd >= 0) {
759 close(null_fd);
760 }
761 }
762
763 ScopedStderrSilence(const ScopedStderrSilence &) = delete;
764 ScopedStderrSilence &operator=(const ScopedStderrSilence &) = delete;
765
766 private:
767 int saved_stderr_fd = -1;
768 int null_fd = -1;
769 bool active = false;
770};
771#endif
772
773// ---------------------------------------------------------------------------
774// Manual 10-bit BT.2020 YUV -> 16-bit RGBA converter.
775//
776// swscale's @c sws_setColorspaceDetails is unreliable — on several internal
777// paths it silently falls back to BT.601 coefficients even when the caller
778// requests BT.2020, which tints HDR decoding pink/green. We sidestep the
779// problem entirely by doing the YUV->RGB matrix on the CPU with the exact
780// coefficients defined in ITU-R BT.2100 / BT.2020 non-constant-luminance.
781//
782// Input: AV_PIX_FMT_YUV420P10LE or AV_PIX_FMT_P010LE, limited-range tv levels
783// (Y in [64,940], UV in [64,960] at 10-bit). UV is upsampled with nearest-
784// neighbour (swscale's default). The output is packed RGBA16 (R,G,B,A, 16
785// bits per channel, little-endian host) with A = 0xFFFF — exactly the bytes
786// the GL @c uploadHdrFrame path expects.
787//
788// The transfer function (PQ or `) is *not* applied here: the bits stay in
789// the non-linear BT.2020 RGB' encoding that the GL @c kHdrDecodeFrag shader
790// converts to scene-linear. Bit precision is preserved by keeping the math
791// in float and scaling to uint16_t only at the end.
792// ---------------------------------------------------------------------------
793inline bool convertBt2020Yuv10LimitedToRgba16(const AVFrame *src, cv::Mat &out) {
794 if (!src || !src->data[0] || !src->data[1]) {
795 return false;
796 }
797 const int w = src->width;
798 const int h = src->height;
799 if (w <= 0 || h <= 0) {
800 return false;
801 }
802
803 const bool is_p010 = (src->format == AV_PIX_FMT_P010LE);
804 if (!is_p010 && !src->data[2]) {
805 return false; // planar formats need all three planes.
806 }
807
808 // Detect 10-bit sample position within a 16-bit container:
809 // yuv420p10le -> low 10 bits (shift = 0)
810 // p010le -> high 10 bits (shift = 6, so divide by 64)
811 int sample_shift = 0;
812 if (is_p010) {
813 sample_shift = 6;
814 }
815
816 const int y_stride_b = src->linesize[0];
817 const int uv_stride_b = src->linesize[1]; // UV interleaved (p010) or Cb (planar).
818 const int v_stride_b = is_p010 ? 0 : src->linesize[2];
819
820 // BT.2020 non-constant-luminance inverse matrix (per ITU-R BT.2020 §4).
821 constexpr float kCrR = 1.4746f;
822 constexpr float kCbG = -0.16455312684366f; // -2*(1-0.2627)*0.2627/0.6780
823 constexpr float kCrG = -0.57135313725490f; // -2*(1-0.0593)*0.0593/0.6780
824 constexpr float kCbB = 1.8814f;
825
826 out.create(h, w, CV_16UC4);
827
828 auto sample10 = [&](const uint8_t *plane, int stride_b, int x, int y) -> int {
829 const uint16_t raw = *reinterpret_cast<const uint16_t *>(plane + y * stride_b + x * 2);
830 return static_cast<int>(raw >> sample_shift);
831 };
832
833 const uint8_t *yp = src->data[0];
834 const uint8_t *up = src->data[1]; // planar: Cb plane | p010: interleaved Cb,Cr.
835 const uint8_t *vp = is_p010 ? nullptr : src->data[2];
836
837 // 10-bit limited-range BT.2020 scaling:
838 // Y' = (Y_sample - 64) / 876 (876 = 940-64)
839 // Cb = (Cb_sample - 512)/ 896 (896 = 2*448)
840 // Cr = (Cr_sample - 512)/ 896
841 constexpr float kInvY = 1.0f / 876.0f;
842 constexpr float kInvC = 1.0f / 896.0f;
843
844 for (int y = 0; y < h; ++y) {
845 const int cy = y >> 1; // 4:2:0 vertical subsampling, nearest.
846 uint16_t *dst = out.ptr<uint16_t>(y);
847 for (int x = 0; x < w; ++x) {
848 const int cx = x >> 1;
849 const int Ys = sample10(yp, y_stride_b, x, y);
850 int Cbs;
851 int Crs;
852 if (is_p010) {
853 // Interleaved Cb,Cr: each chroma pixel is 2 uint16 samples
854 // (Cb then Cr). Row stride is still linesize[1] bytes.
855 const uint16_t *row = reinterpret_cast<const uint16_t *>(up + cy * uv_stride_b);
856 Cbs = static_cast<int>(row[cx * 2 + 0]) >> sample_shift;
857 Crs = static_cast<int>(row[cx * 2 + 1]) >> sample_shift;
858 } else {
859 Cbs = sample10(up, uv_stride_b, cx, cy);
860 Crs = sample10(vp, v_stride_b, cx, cy);
861 }
862
863 const float Y = (Ys - 64) * kInvY;
864 const float Cb = (Cbs - 512) * kInvC;
865 const float Cr = (Crs - 512) * kInvC;
866
867 float R = Y + kCrR * Cr;
868 float G = Y + kCbG * Cb + kCrG * Cr;
869 float B = Y + kCbB * Cb;
870
871 // Clamp to [0,1] — HLG/PQ code values outside this range are
872 // not legal in the non-linear domain.
873 R = R < 0.0f ? 0.0f : (R > 1.0f ? 1.0f : R);
874 G = G < 0.0f ? 0.0f : (G > 1.0f ? 1.0f : G);
875 B = B < 0.0f ? 0.0f : (B > 1.0f ? 1.0f : B);
876
877 dst[x * 4 + 0] = static_cast<uint16_t>(R * 65535.0f + 0.5f);
878 dst[x * 4 + 1] = static_cast<uint16_t>(G * 65535.0f + 0.5f);
879 dst[x * 4 + 2] = static_cast<uint16_t>(B * 65535.0f + 0.5f);
880 dst[x * 4 + 3] = 0xFFFF;
881 }
882 }
883 return true;
884}
885
886inline float clamp01f(float v) { return (v < 0.0f) ? 0.0f : (v > 1.0f ? 1.0f : v); }
887
888inline float pqToLinearScalar(float e) {
889 // SMPTE ST.2084 inverse EOTF, output normalized so 1.0 == 10000 nits.
890 constexpr float m1 = 0.1593017578125f;
891 constexpr float m2 = 78.84375f;
892 constexpr float c1 = 0.8359375f;
893 constexpr float c2 = 18.8515625f;
894 constexpr float c3 = 18.6875f;
895 e = clamp01f(e);
896 const float p = std::pow(e, 1.0f / m2);
897 const float num = std::max(p - c1, 0.0f);
898 const float den = c2 - c3 * p;
899 if (den <= 0.0f) {
900 return 0.0f;
901 }
902 return std::pow(num / den, 1.0f / m1);
903}
904
905inline float hlgToLinearScalar(float e) {
906 // ARIB STD-B67 inverse OETF.
907 constexpr float a = 0.17883277f;
908 constexpr float b = 0.28466892f;
909 constexpr float c = 0.55991073f;
910 e = clamp01f(e);
911 if (e <= 0.5f) {
912 return (e * e) / 3.0f;
913 }
914 return (std::exp((e - c) / a) + b) / 12.0f;
915}
916
917inline unsigned char linearToSrgb8(float v) {
918 v = clamp01f(v);
919 float srgb = 0.0f;
920 if (v <= 0.0031308f) {
921 srgb = 12.92f * v;
922 } else {
923 srgb = 1.055f * std::pow(v, 1.0f / 2.4f) - 0.055f;
924 }
925 const int iv = static_cast<int>(srgb * 255.0f + 0.5f);
926 return static_cast<unsigned char>(std::clamp(iv, 0, 255));
927}
928
929inline uint16_t linearToSrgb16(float v) {
930 v = clamp01f(v);
931 float srgb = 0.0f;
932 if (v <= 0.0031308f) {
933 srgb = 12.92f * v;
934 } else {
935 srgb = 1.055f * std::pow(v, 1.0f / 2.4f) - 0.055f;
936 }
937 const int iv = static_cast<int>(srgb * 65535.0f + 0.5f);
938 return static_cast<uint16_t>(std::clamp(iv, 0, 65535));
939}
940
941inline std::vector<unsigned char> toneMapHdrRgba16ToSdrRgba8(const std::vector<unsigned char> &hdr_pixels, int w, int h, int hdr_trc) {
942 std::vector<unsigned char> sdr_pixels(static_cast<size_t>(w) * static_cast<size_t>(h) * 4, 0);
943 const bool is_hlg = (hdr_trc == AVCOL_TRC_ARIB_STD_B67);
944
945 // Linear BT.2020 -> Linear sRGB (D65).
946 constexpr float m00 = 1.6605f, m01 = -0.5876f, m02 = -0.0728f;
947 constexpr float m10 = -0.1246f, m11 = 1.1329f, m12 = -0.0083f;
948 constexpr float m20 = -0.0182f, m21 = -0.1006f, m22 = 1.1187f;
949
950 auto read_u16_le = [&](size_t byte_index) -> uint16_t { return static_cast<uint16_t>(static_cast<uint16_t>(hdr_pixels[byte_index]) | (static_cast<uint16_t>(hdr_pixels[byte_index + 1]) << 8)); };
951
952 for (int y = 0; y < h; ++y) {
953 for (int x = 0; x < w; ++x) {
954 const size_t px = static_cast<size_t>(y) * static_cast<size_t>(w) + static_cast<size_t>(x);
955 const size_t src_idx = px * 8;
956 const size_t dst_idx = px * 4;
957
958 const float enc_r = read_u16_le(src_idx + 0) / 65535.0f;
959 const float enc_g = read_u16_le(src_idx + 2) / 65535.0f;
960 const float enc_b = read_u16_le(src_idx + 4) / 65535.0f;
961
962 float lin2020_r = 0.0f;
963 float lin2020_g = 0.0f;
964 float lin2020_b = 0.0f;
965
966 if (is_hlg) {
967 lin2020_r = hlgToLinearScalar(enc_r) * 3.77358491f;
968 lin2020_g = hlgToLinearScalar(enc_g) * 3.77358491f;
969 lin2020_b = hlgToLinearScalar(enc_b) * 3.77358491f;
970 } else {
971 lin2020_r = pqToLinearScalar(enc_r) * 100.0f;
972 lin2020_g = pqToLinearScalar(enc_g) * 100.0f;
973 lin2020_b = pqToLinearScalar(enc_b) * 100.0f;
974 }
975
976 float sr = m00 * lin2020_r + m01 * lin2020_g + m02 * lin2020_b;
977 float sg = m10 * lin2020_r + m11 * lin2020_g + m12 * lin2020_b;
978 float sb = m20 * lin2020_r + m21 * lin2020_g + m22 * lin2020_b;
979
980 sr = std::max(sr, 0.0f);
981 sg = std::max(sg, 0.0f);
982 sb = std::max(sb, 0.0f);
983
984 // Simple global tone map for SDR preview/export.
985 sr = sr / (1.0f + sr);
986 sg = sg / (1.0f + sg);
987 sb = sb / (1.0f + sb);
988
989 sdr_pixels[dst_idx + 0] = linearToSrgb8(sr);
990 sdr_pixels[dst_idx + 1] = linearToSrgb8(sg);
991 sdr_pixels[dst_idx + 2] = linearToSrgb8(sb);
992 sdr_pixels[dst_idx + 3] = 255;
993 }
994 }
995
996 return sdr_pixels;
997}
998
999// 16-bit-per-channel variant: tone-maps PQ/HLG BT.2020 RGBA16 -> sRGB RGBA16
1000// (gamma-encoded sRGB, contiguous LE uint16 samples). Used by the HDR TIFF
1001// snapshot path so the resulting file is correctly viewable on any display.
1002inline std::vector<uint16_t> toneMapHdrRgba16ToSrgbRgba16(const unsigned char *hdr_pixels, int w, int h, int hdr_trc) {
1003 std::vector<uint16_t> sdr_pixels(static_cast<size_t>(w) * static_cast<size_t>(h) * 4, 0);
1004 const bool is_hlg = (hdr_trc == AVCOL_TRC_ARIB_STD_B67);
1005
1006 constexpr float m00 = 1.6605f, m01 = -0.5876f, m02 = -0.0728f;
1007 constexpr float m10 = -0.1246f, m11 = 1.1329f, m12 = -0.0083f;
1008 constexpr float m20 = -0.0182f, m21 = -0.1006f, m22 = 1.1187f;
1009
1010 auto read_u16_le = [&](size_t byte_index) -> uint16_t { return static_cast<uint16_t>(static_cast<uint16_t>(hdr_pixels[byte_index]) | (static_cast<uint16_t>(hdr_pixels[byte_index + 1]) << 8)); };
1011
1012 for (int y = 0; y < h; ++y) {
1013 for (int x = 0; x < w; ++x) {
1014 const size_t px = static_cast<size_t>(y) * static_cast<size_t>(w) + static_cast<size_t>(x);
1015 const size_t src_idx = px * 8;
1016 const size_t dst_idx = px * 4;
1017
1018 const float enc_r = read_u16_le(src_idx + 0) / 65535.0f;
1019 const float enc_g = read_u16_le(src_idx + 2) / 65535.0f;
1020 const float enc_b = read_u16_le(src_idx + 4) / 65535.0f;
1021
1022 float lin2020_r = 0.0f, lin2020_g = 0.0f, lin2020_b = 0.0f;
1023 if (is_hlg) {
1024 lin2020_r = hlgToLinearScalar(enc_r) * 3.77358491f;
1025 lin2020_g = hlgToLinearScalar(enc_g) * 3.77358491f;
1026 lin2020_b = hlgToLinearScalar(enc_b) * 3.77358491f;
1027 } else {
1028 lin2020_r = pqToLinearScalar(enc_r) * 100.0f;
1029 lin2020_g = pqToLinearScalar(enc_g) * 100.0f;
1030 lin2020_b = pqToLinearScalar(enc_b) * 100.0f;
1031 }
1032
1033 float sr = m00 * lin2020_r + m01 * lin2020_g + m02 * lin2020_b;
1034 float sg = m10 * lin2020_r + m11 * lin2020_g + m12 * lin2020_b;
1035 float sb = m20 * lin2020_r + m21 * lin2020_g + m22 * lin2020_b;
1036
1037 sr = std::max(sr, 0.0f);
1038 sg = std::max(sg, 0.0f);
1039 sb = std::max(sb, 0.0f);
1040
1041 sr = sr / (1.0f + sr);
1042 sg = sg / (1.0f + sg);
1043 sb = sb / (1.0f + sb);
1044
1045 sdr_pixels[dst_idx + 0] = linearToSrgb16(sr);
1046 sdr_pixels[dst_idx + 1] = linearToSrgb16(sg);
1047 sdr_pixels[dst_idx + 2] = linearToSrgb16(sb);
1048 sdr_pixels[dst_idx + 3] = 65535;
1049 }
1050 }
1051
1052 return sdr_pixels;
1053}
1054
1055#ifdef ACMX2_WITH_WEBP
1056inline bool saveSdrWebPFromRgba8(const char *filename, const unsigned char *rgba8, int width, int height) {
1057 if (filename == nullptr || rgba8 == nullptr || width <= 0 || height <= 0) {
1058 return false;
1059 }
1060
1061 uint8_t *output = nullptr;
1062 const int stride = width * 4;
1063 const size_t out_size = WebPEncodeLosslessRGBA(rgba8, width, height, stride, &output);
1064 if (out_size == 0 || output == nullptr) {
1065 if (output != nullptr) {
1066 WebPFree(output);
1067 }
1068 return false;
1069 }
1070
1071 std::ofstream ofs(filename, std::ios::binary);
1072 if (!ofs.is_open()) {
1073 WebPFree(output);
1074 return false;
1075 }
1076 ofs.write(reinterpret_cast<const char *>(output), static_cast<std::streamsize>(out_size));
1077 const bool ok = ofs.good();
1078 ofs.close();
1079 WebPFree(output);
1080 return ok;
1081}
1082
1083// Save an HDR snapshot as a WebP file.
1084//
1085// libwebp's bitstream is fundamentally 8-bit per channel and has no HDR
1086// metadata, so we tone-map the PQ/HLG BT.2020 input to sRGB before encoding.
1087// The resulting lossless RGBA WebP displays correctly on both SDR and HDR
1088// viewers (including phone HDR displays which would otherwise interpret
1089// PQ-encoded bytes as sRGB and produce washed-out colours).
1090inline bool saveHdrWebPFromRgba16(const char *filename, const unsigned char *rgba16, int width, int height, int hdr_trc) {
1091 if (filename == nullptr || rgba16 == nullptr || width <= 0 || height <= 0) {
1092 return false;
1093 }
1094 const size_t pixel_count = static_cast<size_t>(width) * static_cast<size_t>(height);
1095 // Reuse the existing tone-mapper. It expects a vector view of the input.
1096 const std::vector<unsigned char> hdr_view(rgba16, rgba16 + pixel_count * 8);
1097 const std::vector<unsigned char> rgba8 = toneMapHdrRgba16ToSdrRgba8(hdr_view, width, height, hdr_trc);
1098
1099 uint8_t *output = nullptr;
1100 const int stride = width * 4;
1101 const size_t out_size = WebPEncodeLosslessRGBA(rgba8.data(), width, height, stride, &output);
1102 if (out_size == 0 || output == nullptr) {
1103 if (output != nullptr) {
1104 WebPFree(output);
1105 }
1106 return false;
1107 }
1108
1109 std::ofstream ofs(filename, std::ios::binary);
1110 if (!ofs.is_open()) {
1111 WebPFree(output);
1112 return false;
1113 }
1114 ofs.write(reinterpret_cast<const char *>(output), static_cast<std::streamsize>(out_size));
1115 const bool ok = ofs.good();
1116 ofs.close();
1117 WebPFree(output);
1118 return ok;
1119}
1120#endif // ACMX2_WITH_WEBP
1121
1122#ifdef ACMX2_WITH_TIFF
1123inline bool saveSdrTiffFromRgba8(const char *filename, const unsigned char *rgba8, int width, int height) {
1124 if (filename == nullptr || rgba8 == nullptr || width <= 0 || height <= 0) {
1125 return false;
1126 }
1127
1128 TIFF *tif = TIFFOpen(filename, "w");
1129 if (tif == nullptr) {
1130 return false;
1131 }
1132
1133 TIFFSetField(tif, TIFFTAG_IMAGEWIDTH, static_cast<uint32_t>(width));
1134 TIFFSetField(tif, TIFFTAG_IMAGELENGTH, static_cast<uint32_t>(height));
1135 TIFFSetField(tif, TIFFTAG_SAMPLESPERPIXEL, 4);
1136 TIFFSetField(tif, TIFFTAG_BITSPERSAMPLE, 8);
1137 TIFFSetField(tif, TIFFTAG_ORIENTATION, ORIENTATION_TOPLEFT);
1138 TIFFSetField(tif, TIFFTAG_PLANARCONFIG, PLANARCONFIG_CONTIG);
1139 TIFFSetField(tif, TIFFTAG_PHOTOMETRIC, PHOTOMETRIC_RGB);
1140 TIFFSetField(tif, TIFFTAG_SAMPLEFORMAT, SAMPLEFORMAT_UINT);
1141 TIFFSetField(tif, TIFFTAG_COMPRESSION, COMPRESSION_LZW);
1142 TIFFSetField(tif, TIFFTAG_ROWSPERSTRIP, TIFFDefaultStripSize(tif, 0));
1143
1144 const uint16_t extra[1] = {EXTRASAMPLE_UNASSALPHA};
1145 TIFFSetField(tif, TIFFTAG_EXTRASAMPLES, 1, extra);
1146 TIFFSetField(tif, TIFFTAG_IMAGEDESCRIPTION, "ACMX2 SDR snapshot: 8-bit RGBA TIFF");
1147
1148 const tmsize_t row_bytes = static_cast<tmsize_t>(width) * 4;
1149 bool ok = true;
1150 for (int y = 0; y < height; ++y) {
1151 unsigned char *row = const_cast<unsigned char *>(rgba8 + static_cast<size_t>(y) * static_cast<size_t>(row_bytes));
1152 if (TIFFWriteScanline(tif, row, static_cast<uint32_t>(y), 0) < 0) {
1153 ok = false;
1154 break;
1155 }
1156 }
1157
1158 TIFFClose(tif);
1159 return ok;
1160}
1161
1162// Save an HDR snapshot as a 16-bit RGBA TIFF.
1163//
1164// We tone-map the PQ/HLG BT.2020 input to sRGB at full 16-bit precision
1165// before writing. This keeps highlight detail (no 8-bit quantisation) while
1166// producing a file that displays correctly on every viewer — without the
1167// PQ-as-sRGB washed-out appearance that bare PQ data would have.
1168inline bool saveHdrTiffFromRgba16(const char *filename, const unsigned char *rgba16, int width, int height, int hdr_trc) {
1169 if (filename == nullptr || rgba16 == nullptr || width <= 0 || height <= 0) {
1170 return false;
1171 }
1172
1173 const std::vector<uint16_t> srgb16 = toneMapHdrRgba16ToSrgbRgba16(rgba16, width, height, hdr_trc);
1174
1175 TIFF *tif = TIFFOpen(filename, "w");
1176 if (tif == nullptr) {
1177 return false;
1178 }
1179
1180 TIFFSetField(tif, TIFFTAG_IMAGEWIDTH, static_cast<uint32_t>(width));
1181 TIFFSetField(tif, TIFFTAG_IMAGELENGTH, static_cast<uint32_t>(height));
1182 TIFFSetField(tif, TIFFTAG_SAMPLESPERPIXEL, 4);
1183 TIFFSetField(tif, TIFFTAG_BITSPERSAMPLE, 16);
1184 TIFFSetField(tif, TIFFTAG_ORIENTATION, ORIENTATION_TOPLEFT);
1185 TIFFSetField(tif, TIFFTAG_PLANARCONFIG, PLANARCONFIG_CONTIG);
1186 TIFFSetField(tif, TIFFTAG_PHOTOMETRIC, PHOTOMETRIC_RGB);
1187 TIFFSetField(tif, TIFFTAG_SAMPLEFORMAT, SAMPLEFORMAT_UINT);
1188 TIFFSetField(tif, TIFFTAG_COMPRESSION, COMPRESSION_LZW);
1189 TIFFSetField(tif, TIFFTAG_ROWSPERSTRIP, TIFFDefaultStripSize(tif, 0));
1190
1191 const uint16_t extra[1] = {EXTRASAMPLE_UNASSALPHA};
1192 TIFFSetField(tif, TIFFTAG_EXTRASAMPLES, 1, extra);
1193 TIFFSetField(tif, TIFFTAG_IMAGEDESCRIPTION, "ACMX2 HDR snapshot: 16-bit RGBA, sRGB tone-mapped from BT.2020 PQ/HLG");
1194
1195 const tmsize_t row_samples = static_cast<tmsize_t>(width) * 4;
1196 bool ok = true;
1197 for (int y = 0; y < height; ++y) {
1198 // libtiff's WriteScanline takes a non-const buffer.
1199 uint16_t *row = const_cast<uint16_t *>(srgb16.data() + static_cast<size_t>(y) * static_cast<size_t>(row_samples));
1200 if (TIFFWriteScanline(tif, row, static_cast<uint32_t>(y), 0) < 0) {
1201 ok = false;
1202 break;
1203 }
1204 }
1205
1206 TIFFClose(tif);
1207 return ok;
1208}
1209#endif // ACMX2_WITH_TIFF
1210
1211// ---------------------------------------------------------------------------
1212// HDR pipeline shader sources.
1213//
1214// kHdrVertPassthrough is a minimal NDC-quad vertex shader that exposes a
1215// `tc` varying for the fragment shaders below. It matches the 2D sprite
1216// vertex attribute layout used by gl::GLSprite (position + texcoord).
1217//
1218// kHdrDecodeFrag runs once per video frame. It reads the freshly-uploaded
1219// 16-bit normalised PQ/HLG-encoded BT.2020 source texture and writes linear
1220// BT.2020 light (reference white = 1.0, so HDR highlights end up as values
1221// well above 1.0) into an RGBA16F target. Subsequent user shaders sample
1222// that linear texture via iChannel0/samp — they do not need any changes.
1223//
1224// kHdrEncodeFrag runs once after the user's last shader pass. It reads the
1225// final linear BT.2020 RGBA16F result and produces PQ-encoded values in
1226// [0,1], ready to be packed into a 16-bit normalised RGBA16 readback
1227// target (MXWrite converts that to a 10-bit BT.2020 YUV P010 HEVC stream).
1228//
1229// The HLG inverse/forward OETFs are gated by a `uniform int transfer` so
1230// the same two shaders handle both SMPTE ST.2084 (PQ) and ARIB STD-B67
1231// (HLG) inputs. transfer: 1 = PQ, 2 = HLG.
1232// ---------------------------------------------------------------------------
1233constexpr const char *kHdrVertPassthrough = "#version 330 core\n"
1234 "layout(location = 0) in vec3 aPos;\n"
1235 "layout(location = 1) in vec2 aTex;\n"
1236 "out vec2 tc;\n"
1237 "uniform mat4 mv_matrix;\n"
1238 "uniform mat4 proj_matrix;\n"
1239 "void main() {\n"
1240 " gl_Position = proj_matrix * mv_matrix * vec4(aPos, 1.0);\n"
1241 " tc = aTex;\n"
1242 "}\n";
1243
1244constexpr const char *kHdrDecodeFrag = "#version 330 core\n"
1245 "in vec2 tc;\n"
1246 "out vec4 color;\n"
1247 "uniform sampler2D samp;\n"
1248 "uniform int transfer; // 1 = PQ (SMPTE2084), 2 = HLG (ARIB STD-B67)\n"
1249 "// SMPTE ST.2084 inverse EOTF. Input: PQ code in [0,1]. Output: linear\n"
1250 "// fractional luminance where 1.0 = PQ peak (10000 nits).\n"
1251 "vec3 pqToLinear(vec3 e) {\n"
1252 " const float m1 = 2610.0 / 16384.0;\n"
1253 " const float m2 = (2523.0 / 4096.0) * 128.0;\n"
1254 " const float c1 = 3424.0 / 4096.0;\n"
1255 " const float c2 = (2413.0 / 4096.0) * 32.0;\n"
1256 " const float c3 = (2392.0 / 4096.0) * 32.0;\n"
1257 " vec3 ec = clamp(e, vec3(0.0), vec3(1.0));\n"
1258 " vec3 em2 = pow(ec, vec3(1.0 / m2));\n"
1259 " vec3 num = max(em2 - c1, vec3(0.0));\n"
1260 " // Clamp denominator floor to avoid div-by-zero / sign-flip near\n"
1261 " // peak code values which would otherwise propagate NaN through\n"
1262 " // pow() and read back as zero on some drivers.\n"
1263 " vec3 den = max(c2 - c3 * em2, vec3(1e-6));\n"
1264 " return pow(num / den, vec3(1.0 / m1));\n"
1265 "}\n"
1266 "// ARIB STD-B67 (HLG) inverse OETF. Input HLG code [0,1]. Output scene\n"
1267 "// linear [0,1] normalised so 0.5 HLG -> ~0.083 linear (reference white).\n"
1268 "vec3 hlgToLinear(vec3 e) {\n"
1269 " const float a = 0.17883277;\n"
1270 " const float b = 0.28466892;\n"
1271 " const float c = 0.55991073;\n"
1272 " vec3 ec = clamp(e, vec3(0.0), vec3(1.0));\n"
1273 " vec3 lo = (ec * ec) / 3.0;\n"
1274 " vec3 hi = (exp((ec - c) / a) + b) / 12.0;\n"
1275 " return mix(lo, hi, step(vec3(0.5), ec));\n"
1276 "}\n"
1277 "void main() {\n"
1278 " vec4 raw = texture(samp, tc);\n"
1279 " vec3 lin;\n"
1280 " if (transfer == 2) {\n"
1281 " // HLG: inverse-OETF then scale so reference white == 1.0.\n"
1282 " // Reference white sits at ~0.26 scene-linear; scale by 1/0.26\n"
1283 " // ~= 3.77 so shader colours match SDR at nominal exposure.\n"
1284 " lin = hlgToLinear(raw.rgb) * 3.77358491;\n"
1285 " } else {\n"
1286 " // PQ: inverse-EOTF, then rescale so 100 nits == 1.0 (reference\n"
1287 " // white). 100 / 10000 = 0.01, so we multiply by 100. Highlights\n"
1288 " // above reference end up as values > 1.0 (legal in RGBA16F).\n"
1289 " lin = pqToLinear(raw.rgb) * 100.0;\n"
1290 " }\n"
1291 " color = vec4(lin, raw.a);\n"
1292 "}\n";
1293
1294constexpr const char *kHdrEncodeFrag = "#version 330 core\n"
1295 "in vec2 tc;\n"
1296 "out vec4 color;\n"
1297 "uniform sampler2D samp;\n"
1298 "uniform int transfer;\n"
1299 "vec3 linearToPq(vec3 L) {\n"
1300 " const float m1 = 2610.0 / 16384.0;\n"
1301 " const float m2 = (2523.0 / 4096.0) * 128.0;\n"
1302 " const float c1 = 3424.0 / 4096.0;\n"
1303 " const float c2 = (2413.0 / 4096.0) * 32.0;\n"
1304 " const float c3 = (2392.0 / 4096.0) * 32.0;\n"
1305 " vec3 Lm = pow(max(L, vec3(0.0)), vec3(m1));\n"
1306 " vec3 num = c1 + c2 * Lm;\n"
1307 " vec3 den = 1.0 + c3 * Lm;\n"
1308 " return pow(num / den, vec3(m2));\n"
1309 "}\n"
1310 "vec3 linearToHlg(vec3 L) {\n"
1311 " const float a = 0.17883277;\n"
1312 " const float b = 0.28466892;\n"
1313 " const float c = 0.55991073;\n"
1314 " vec3 Lc = max(L, vec3(0.0));\n"
1315 " vec3 lo = sqrt(3.0 * Lc);\n"
1316 " // Floor the log argument so the unused 'hi' branch never produces\n"
1317 " // NaN. mix() with NaN is mix(lo, NaN, 0) = lo + NaN*0 = NaN, which\n"
1318 " // would surface as black pixels after the final clamp/UNORM cast.\n"
1319 " vec3 hi = a * log(max(12.0 * Lc - b, vec3(1e-6))) + c;\n"
1320 " return mix(lo, hi, step(vec3(1.0 / 12.0), Lc));\n"
1321 "}\n"
1322 "void main() {\n"
1323 " vec3 lin = texture(samp, tc).rgb;\n"
1324 " // Sanitise potentially-NaN/Inf values from prior user shader\n"
1325 " // computations so they do not survive the OETF and read back as\n"
1326 " // zero (=> black holes in dark/edge regions).\n"
1327 " bvec3 nans = isnan(lin);\n"
1328 " bvec3 infs = isinf(lin);\n"
1329 " lin = mix(lin, vec3(0.0), vec3(nans));\n"
1330 " lin = mix(lin, vec3(0.0), vec3(infs));\n"
1331 " lin = max(lin, vec3(0.0));\n"
1332 " vec3 enc;\n"
1333 " if (transfer == 2) {\n"
1334 " enc = linearToHlg(lin / 3.77358491);\n"
1335 " } else {\n"
1336 " enc = linearToPq(lin / 100.0);\n"
1337 " }\n"
1338 " color = vec4(clamp(enc, 0.0, 1.0), 1.0);\n"
1339 "}\n";
1340
1341// Display shader that can optionally flip the Y coordinate of texture sampling.
1342// Used when --flip is set for windowed display.
1343constexpr const char *kDisplayVertFlip = "#version 330 core\n"
1344 "layout(location = 0) in vec3 aPos;\n"
1345 "layout(location = 1) in vec2 aTex;\n"
1346 "out vec2 tc;\n"
1347 "uniform mat4 mv_matrix;\n"
1348 "uniform mat4 proj_matrix;\n"
1349 "uniform int flip_y; // 1 to flip Y coordinate\n"
1350 "void main() {\n"
1351 " gl_Position = proj_matrix * mv_matrix * vec4(aPos, 1.0);\n"
1352 " vec2 tex = aTex;\n"
1353 " if (flip_y == 1) {\n"
1354 " tex.y = 1.0 - tex.y;\n"
1355 " }\n"
1356 " tc = tex;\n"
1357 "}\n";
1358
1359constexpr const char *kDisplayFragPassthrough = "#version 330 core\n"
1360 "in vec2 tc;\n"
1361 "out vec4 color;\n"
1362 "uniform sampler2D samp;\n"
1363 "void main() {\n"
1364 " color = texture(samp, tc);\n"
1365 "}\n";
1366
1367constexpr const char *kMuxOverlayFrag = "#version 330 core\n"
1368 "out vec4 color;\n"
1369 "in vec2 tc;\n"
1370 "uniform sampler2D samp;\n"
1371 "uniform float time_f;\n"
1372 "uniform vec2 iResolution;\n"
1373 "uniform float alpha;\n"
1374 "void main(void) {\n"
1375 " vec2 uv = (tc * 2.0 - 1.0);\n"
1376 " float aspect = iResolution.x / iResolution.y;\n"
1377 " uv.x *= aspect;\n"
1378 " float d = length(uv);\n"
1379 " float lensStrength = 1.5;\n"
1380 " vec3 normal = normalize(vec3(uv, 1.0 / lensStrength));\n"
1381 " float fisheyeRadius = atan(d, 1.0);\n"
1382 " vec2 distortedUV = normalize(uv + 1e-6) * fisheyeRadius;\n"
1383 " float t = time_f * 0.8;\n"
1384 " float r_dist = length(distortedUV);\n"
1385 " float angle = atan(distortedUV.y, distortedUV.x);\n"
1386 " float spiral = angle + (log(r_dist + 0.1) * 3.0) - t * 1.5;\n"
1387 " float r = sin(spiral * 3.0 + t);\n"
1388 " float g = sin(spiral * 3.0 + t + 2.094);\n"
1389 " float b = sin(spiral * 3.0 + t + 4.188);\n"
1390 " vec3 spiralCol = vec3(r, g, b) * 0.5 + 0.5;\n"
1391 " vec3 lightDir = normalize(vec3(sin(time_f), cos(time_f), 1.0));\n"
1392 " float diff = max(dot(normal, lightDir), 0.0);\n"
1393 " float spec = pow(max(dot(reflect(-lightDir, normal), vec3(0,0,1)), 0.0), 16.0);\n"
1394 " vec4 texColor = texture(samp, tc);\n"
1395 " vec3 finalCol = mix(texColor.rgb, spiralCol * (diff + 0.5) + spec, 0.7);\n"
1396 " finalCol *= smoothstep(2.0, 0.5, d);\n"
1397 " float finalAlpha = texColor.a * alpha;\n"
1398 " color = vec4(finalCol, finalAlpha);\n"
1399 "}\n";
1400
1402 public:
1404
1405 bool open(const std::string &filename, bool prefer_cuda) {
1406 close();
1407
1408 if (avformat_open_input(&format_ctx, filename.c_str(), nullptr, nullptr) < 0) {
1409 return false;
1410 }
1411 if (avformat_find_stream_info(format_ctx, nullptr) < 0) {
1412 return false;
1413 }
1414
1415 stream_index = av_find_best_stream(format_ctx, AVMEDIA_TYPE_VIDEO, -1, -1, nullptr, 0);
1416 if (stream_index < 0) {
1417 return false;
1418 }
1419
1420 AVStream *stream = format_ctx->streams[stream_index];
1421 const AVCodec *codec = avcodec_find_decoder(stream->codecpar->codec_id);
1422 if (!codec) {
1423 return false;
1424 }
1425
1426 codec_ctx = avcodec_alloc_context3(codec);
1427 if (!codec_ctx) {
1428 return false;
1429 }
1430
1431 if (avcodec_parameters_to_context(codec_ctx, stream->codecpar) < 0) {
1432 return false;
1433 }
1434
1435 codec_ctx->thread_count = std::max(1u, std::thread::hardware_concurrency());
1436
1437#ifdef ACMX2_WITH_CUDA
1438 if (prefer_cuda) {
1439 enableCudaHwDecode(codec);
1440 }
1441#else
1442 static_cast<void>(prefer_cuda);
1443#endif
1444
1445 if (avcodec_open2(codec_ctx, codec, nullptr) < 0) {
1446 return false;
1447 }
1448
1449 decoded_frame = av_frame_alloc();
1450 sw_frame = av_frame_alloc();
1451 packet = av_packet_alloc();
1452 if (!decoded_frame || !sw_frame || !packet) {
1453 return false;
1454 }
1455
1456 width = codec_ctx->width;
1457 height = codec_ctx->height;
1458 fps = av_q2d(stream->avg_frame_rate);
1459 if (fps <= 0.0) {
1460 fps = av_q2d(stream->r_frame_rate);
1461 }
1462 frame_count = static_cast<double>(stream->nb_frames);
1463 current_frame = 0;
1464
1465 // HDR detection: BT.2020 primaries, PQ (SMPTE2084) or HLG (ARIB STD-B67)
1466 // transfer characteristics, or >=10-bit raw sample depth all indicate HDR.
1467 {
1468 const AVCodecParameters *par = stream->codecpar;
1469 const bool primaries_hdr = (par->color_primaries == AVCOL_PRI_BT2020);
1470 const bool trc_hdr = (par->color_trc == AVCOL_TRC_SMPTE2084) || (par->color_trc == AVCOL_TRC_ARIB_STD_B67) || (par->color_trc == AVCOL_TRC_BT2020_10) || (par->color_trc == AVCOL_TRC_BT2020_12);
1471 const bool space_hdr = (par->color_space == AVCOL_SPC_BT2020_NCL) || (par->color_space == AVCOL_SPC_BT2020_CL);
1472 int bpp = par->bits_per_raw_sample;
1473 if (bpp <= 0) {
1474 // Fall back to codec context pixel format depth. Many HDR
1475 // files leave bits_per_raw_sample unset in the codecpar but
1476 // the decoded format (yuv420p10le etc.) still reports 10-bit.
1477 const AVPixelFormat pf = codec_ctx->pix_fmt;
1478 if (pf != AV_PIX_FMT_NONE) {
1479 const AVPixFmtDescriptor *desc = av_pix_fmt_desc_get(pf);
1480 if (desc && desc->nb_components > 0) {
1481 bpp = desc->comp[0].depth;
1482 }
1483 }
1484 }
1485 const bool depth_hdr = (bpp >= 10);
1486 is_hdr = (primaries_hdr || trc_hdr || space_hdr) && depth_hdr;
1487 hdr_primaries = par->color_primaries;
1488 hdr_trc = par->color_trc;
1489 hdr_colorspace = par->color_space;
1490 hdr_color_range = par->color_range;
1491 hdr_bit_depth = bpp;
1492
1493 // Capture mastering display + content light side data from the
1494 // input stream so they can be attached to the output when in HDR
1495 // mode. Side data lives on codecpar->coded_side_data in modern
1496 // ffmpeg.
1497 hdr_mastering_display.clear();
1498 hdr_content_light.clear();
1499 for (int i = 0; i < par->nb_coded_side_data; ++i) {
1500 const AVPacketSideData &sd = par->coded_side_data[i];
1501 if (sd.type == AV_PKT_DATA_MASTERING_DISPLAY_METADATA) {
1502 hdr_mastering_display.assign(sd.data, sd.data + sd.size);
1503 } else if (sd.type == AV_PKT_DATA_CONTENT_LIGHT_LEVEL) {
1504 hdr_content_light.assign(sd.data, sd.data + sd.size);
1505 }
1506 }
1507 }
1508
1509 return true;
1510 }
1511
1512 /**
1513 * @brief Decode and discard one frame without transferring or converting it.
1514 *
1515 * Used when an external playback clock has advanced beyond the next video
1516 * timestamp. Hardware frames stay on the decoder device and software
1517 * frames avoid swscale/OpenCV allocation entirely.
1518 */
1519 bool skip() {
1520 if (!codec_ctx || !format_ctx) {
1521 return false;
1522 }
1523
1524 while (true) {
1525 if (!draining) {
1526 const int read_ret = av_read_frame(format_ctx, packet);
1527 if (read_ret >= 0) {
1528 if (packet->stream_index == stream_index) {
1529 if (avcodec_send_packet(codec_ctx, packet) < 0) {
1530 av_packet_unref(packet);
1531 return false;
1532 }
1533 }
1534 av_packet_unref(packet);
1535 } else if (!drain_packet_sent) {
1536 if (avcodec_send_packet(codec_ctx, nullptr) < 0) {
1537 return false;
1538 }
1539 drain_packet_sent = true;
1540 draining = true;
1541 }
1542 }
1543
1544 const int receive_ret = avcodec_receive_frame(codec_ctx, decoded_frame);
1545 if (receive_ret == AVERROR(EAGAIN)) {
1546 if (draining) {
1547 return false;
1548 }
1549 continue;
1550 }
1551 if (receive_ret == AVERROR_EOF) {
1552 return false;
1553 }
1554 if (receive_ret < 0) {
1555 return false;
1556 }
1557
1558 av_frame_unref(decoded_frame);
1559 av_frame_unref(sw_frame);
1560 ++current_frame;
1561 return true;
1562 }
1563 }
1564
1565 bool read(cv::Mat &out_bgr) {
1566 if (!codec_ctx || !format_ctx) {
1567 return false;
1568 }
1569
1570 while (true) {
1571 if (!draining) {
1572 const int read_ret = av_read_frame(format_ctx, packet);
1573 if (read_ret >= 0) {
1574 if (packet->stream_index == stream_index) {
1575 if (avcodec_send_packet(codec_ctx, packet) < 0) {
1576 av_packet_unref(packet);
1577 return false;
1578 }
1579 }
1580 av_packet_unref(packet);
1581 } else {
1582 if (!drain_packet_sent) {
1583 if (avcodec_send_packet(codec_ctx, nullptr) < 0) {
1584 return false;
1585 }
1586 drain_packet_sent = true;
1587 draining = true;
1588 }
1589 }
1590 }
1591
1592 const int receive_ret = avcodec_receive_frame(codec_ctx, decoded_frame);
1593 if (receive_ret == AVERROR(EAGAIN)) {
1594 if (draining) {
1595 return false;
1596 }
1597 continue;
1598 }
1599 if (receive_ret == AVERROR_EOF) {
1600 return false;
1601 }
1602 if (receive_ret < 0) {
1603 return false;
1604 }
1605
1606 AVFrame *src = decoded_frame;
1607 if (hw_decode_enabled && decoded_frame->format == hw_pix_fmt) {
1608 if (av_hwframe_transfer_data(sw_frame, decoded_frame, 0) < 0) {
1609 av_frame_unref(decoded_frame);
1610 return false;
1611 }
1612 src = sw_frame;
1613 }
1614
1615 if (!sws_ctx || sws_src_format != static_cast<AVPixelFormat>(src->format) || sws_w != src->width || sws_h != src->height) {
1616 if (sws_ctx) {
1617 sws_freeContext(sws_ctx);
1618 }
1619 sws_src_format = static_cast<AVPixelFormat>(src->format);
1620 sws_w = src->width;
1621 sws_h = src->height;
1622 sws_ctx = sws_getContext(sws_w, sws_h, sws_src_format, sws_w, sws_h, AV_PIX_FMT_BGR24, SWS_BILINEAR, nullptr, nullptr, nullptr);
1623 if (!sws_ctx) {
1624 av_frame_unref(decoded_frame);
1625 av_frame_unref(sw_frame);
1626 return false;
1627 }
1628 }
1629
1630 out_bgr.create(src->height, src->width, CV_8UC3);
1631 uint8_t *dst_data[4] = {out_bgr.data, nullptr, nullptr, nullptr};
1632 int dst_linesize[4] = {static_cast<int>(out_bgr.step), 0, 0, 0};
1633
1634 sws_scale(sws_ctx, src->data, src->linesize, 0, src->height, dst_data, dst_linesize);
1635
1636 av_frame_unref(decoded_frame);
1637 av_frame_unref(sw_frame);
1638 ++current_frame;
1639 return true;
1640 }
1641 }
1642
1643 // HDR read path: convert decoded HDR frames to 16-bit-per-channel RGBA
1644 // (AV_PIX_FMT_RGBA64LE) so the full 10/12-bit source fidelity and the
1645 // original PQ/HLG transfer encoding are preserved. The output `cv::Mat`
1646 // is CV_16UC4 laid out as R,G,B,A (16-bit unsigned, little-endian).
1647 //
1648 // Callers are expected to upload this straight to a GL_RGBA16 texture
1649 // and run the dedicated HDR decode shader to convert PQ/HLG -> linear
1650 // BT.2020 on the GPU before user shaders see it.
1651 bool readHdr(cv::Mat &out_rgba16) {
1652 if (!codec_ctx || !format_ctx) {
1653 return false;
1654 }
1655
1656 while (true) {
1657 if (!draining) {
1658 const int read_ret = av_read_frame(format_ctx, packet);
1659 if (read_ret >= 0) {
1660 if (packet->stream_index == stream_index) {
1661 if (avcodec_send_packet(codec_ctx, packet) < 0) {
1662 av_packet_unref(packet);
1663 return false;
1664 }
1665 }
1666 av_packet_unref(packet);
1667 } else {
1668 if (!drain_packet_sent) {
1669 if (avcodec_send_packet(codec_ctx, nullptr) < 0) {
1670 return false;
1671 }
1672 drain_packet_sent = true;
1673 draining = true;
1674 }
1675 }
1676 }
1677
1678 const int receive_ret = avcodec_receive_frame(codec_ctx, decoded_frame);
1679 if (receive_ret == AVERROR(EAGAIN)) {
1680 if (draining) {
1681 return false;
1682 }
1683 continue;
1684 }
1685 if (receive_ret == AVERROR_EOF) {
1686 return false;
1687 }
1688 if (receive_ret < 0) {
1689 return false;
1690 }
1691
1692 AVFrame *src = decoded_frame;
1693 if (hw_decode_enabled && decoded_frame->format == hw_pix_fmt) {
1694 if (av_hwframe_transfer_data(sw_frame, decoded_frame, 0) < 0) {
1695 av_frame_unref(decoded_frame);
1696 return false;
1697 }
1698 src = sw_frame;
1699 }
1700
1701 // Fast / correct path: for 10-bit BT.2020 planar YUV formats we
1702 // run a hand-rolled BT.2020 NCL matrix on the CPU. This avoids
1703 // swscale's infamous colorspace-detection quirks (it silently
1704 // uses BT.601 on several internal paths even after
1705 // @c sws_setColorspaceDetails), which manifested as a pink tint
1706 // on HLG output.
1707 if (src->format == AV_PIX_FMT_YUV420P10LE || src->format == AV_PIX_FMT_P010LE) {
1708 if (convertBt2020Yuv10LimitedToRgba16(src, out_rgba16)) {
1709 av_frame_unref(decoded_frame);
1710 av_frame_unref(sw_frame);
1711 ++current_frame;
1712 return true;
1713 }
1714 }
1715
1716 if (!sws_ctx_hdr || sws_src_format_hdr != static_cast<AVPixelFormat>(src->format) || sws_w_hdr != src->width || sws_h_hdr != src->height) {
1717 if (sws_ctx_hdr) {
1718 sws_freeContext(sws_ctx_hdr);
1719 }
1720 sws_src_format_hdr = static_cast<AVPixelFormat>(src->format);
1721 sws_w_hdr = src->width;
1722 sws_h_hdr = src->height;
1723 // Convert to RGBA64LE: 16-bit per channel, interleaved RGBA.
1724 // swscale does NOT perform transfer-function conversion, so
1725 // the output bits are still PQ/HLG-encoded BT.2020 values
1726 // scaled to occupy the full 16-bit range from the source's
1727 // 10/12-bit depth (shifted up). That is exactly what we want
1728 // for the GPU decode pass.
1729 sws_ctx_hdr = sws_getContext(sws_w_hdr, sws_h_hdr, sws_src_format_hdr, sws_w_hdr, sws_h_hdr, AV_PIX_FMT_RGBA64LE, SWS_BILINEAR, nullptr, nullptr, nullptr);
1730 if (!sws_ctx_hdr) {
1731 av_frame_unref(decoded_frame);
1732 av_frame_unref(sw_frame);
1733 return false;
1734 }
1735
1736 // Force swscale to use the source's real YUV->RGB matrix.
1737 // Without this, YUV420P10LE frames tagged BT.2020 get
1738 // converted with the default BT.601 coefficients and the
1739 // output RGB ends up with a pink/green bias. We pick the
1740 // matrix from the decoded frame's colorspace tag (falling
1741 // back to BT.2020 NCL for an HDR source) and the range
1742 // from @c color_range.
1743 int src_space = SWS_CS_DEFAULT;
1744 switch (src->colorspace) {
1745 case AVCOL_SPC_BT2020_NCL:
1746 case AVCOL_SPC_BT2020_CL:
1747 src_space = SWS_CS_BT2020;
1748 break;
1749 case AVCOL_SPC_BT709:
1750 src_space = SWS_CS_ITU709;
1751 break;
1752 case AVCOL_SPC_SMPTE170M:
1753 case AVCOL_SPC_BT470BG:
1754 src_space = SWS_CS_ITU601;
1755 break;
1756 default:
1757 src_space = SWS_CS_BT2020; // HDR default.
1758 break;
1759 }
1760 const int src_range = (src->color_range == AVCOL_RANGE_JPEG) ? 1 : 0;
1761 const int *src_coefs = sws_getCoefficients(src_space);
1762 const int *dst_coefs = sws_getCoefficients(SWS_CS_BT2020);
1763 sws_setColorspaceDetails(sws_ctx_hdr, src_coefs, src_range, dst_coefs, 1 /* full-range RGB output */, 0 /* brightness */, 1 << 16 /* contrast 1.0 */, 1 << 16 /* saturation 1.0 */);
1764 }
1765
1766 out_rgba16.create(src->height, src->width, CV_16UC4);
1767 uint8_t *dst_data[4] = {out_rgba16.data, nullptr, nullptr, nullptr};
1768 int dst_linesize[4] = {static_cast<int>(out_rgba16.step), 0, 0, 0};
1769
1770 sws_scale(sws_ctx_hdr, src->data, src->linesize, 0, src->height, dst_data, dst_linesize);
1771
1772 av_frame_unref(decoded_frame);
1773 av_frame_unref(sw_frame);
1774 ++current_frame;
1775 return true;
1776 }
1777 }
1778
1779 bool seekStart() {
1780 if (!format_ctx || !codec_ctx || stream_index < 0) {
1781 return false;
1782 }
1783 if (av_seek_frame(format_ctx, stream_index, 0, AVSEEK_FLAG_BACKWARD) < 0) {
1784 return false;
1785 }
1786 avcodec_flush_buffers(codec_ctx);
1787 drain_packet_sent = false;
1788 draining = false;
1789 current_frame = 0;
1790 return true;
1791 }
1792
1793 int getWidth() const { return width; }
1794 int getHeight() const { return height; }
1795 double getFps() const { return fps; }
1796 double getFrameCount() const { return frame_count; }
1797 int64_t getCurrentFrame() const { return current_frame; }
1798 bool isHwDecodeEnabled() const { return hw_decode_enabled; }
1799 bool isHdr() const { return is_hdr; }
1800 int getHdrPrimaries() const { return hdr_primaries; }
1801 int getHdrTransfer() const { return hdr_trc; }
1802 int getHdrColorspace() const { return hdr_colorspace; }
1803 int getHdrColorRange() const { return hdr_color_range; }
1804 int getHdrBitDepth() const { return hdr_bit_depth; }
1805 const std::vector<uint8_t> &getHdrMasteringDisplay() const { return hdr_mastering_display; }
1806 const std::vector<uint8_t> &getHdrContentLight() const { return hdr_content_light; }
1807
1808 private:
1809 static AVPixelFormat getHwFormat(AVCodecContext *ctx, const AVPixelFormat *pix_fmts) {
1810 auto *self = static_cast<FFMpegVideoReader *>(ctx->opaque);
1811 if (!self) {
1812 return pix_fmts[0];
1813 }
1814
1815 for (const AVPixelFormat *p = pix_fmts; *p != AV_PIX_FMT_NONE; ++p) {
1816 if (*p == self->hw_pix_fmt) {
1817 return *p;
1818 }
1819 }
1820 return pix_fmts[0];
1821 }
1822
1823 void enableCudaHwDecode(const AVCodec *codec) {
1824#ifdef ACMX2_WITH_CUDA
1825 for (int i = 0;; ++i) {
1826 const AVCodecHWConfig *cfg = avcodec_get_hw_config(codec, i);
1827 if (!cfg) {
1828 return;
1829 }
1830
1831 if ((cfg->methods & AV_CODEC_HW_CONFIG_METHOD_HW_DEVICE_CTX) && cfg->device_type == AV_HWDEVICE_TYPE_CUDA) {
1832 hw_pix_fmt = cfg->pix_fmt;
1833 break;
1834 }
1835 }
1836
1837 if (hw_pix_fmt == AV_PIX_FMT_NONE) {
1838 return;
1839 }
1840
1841 if (av_hwdevice_ctx_create(&hw_device_ctx, AV_HWDEVICE_TYPE_CUDA, nullptr, nullptr, 0) < 0) {
1842 return;
1843 }
1844
1845 codec_ctx->hw_device_ctx = av_buffer_ref(hw_device_ctx);
1846 codec_ctx->opaque = this;
1848 hw_decode_enabled = (codec_ctx->hw_device_ctx != nullptr);
1849#else
1850 static_cast<void>(codec);
1851#endif
1852 }
1853
1854 void close() {
1855 if (packet) {
1856 av_packet_free(&packet);
1857 packet = nullptr;
1858 }
1859 if (decoded_frame) {
1860 av_frame_free(&decoded_frame);
1861 decoded_frame = nullptr;
1862 }
1863 if (sw_frame) {
1864 av_frame_free(&sw_frame);
1865 sw_frame = nullptr;
1866 }
1867 if (sws_ctx) {
1868 sws_freeContext(sws_ctx);
1869 sws_ctx = nullptr;
1870 }
1871 if (sws_ctx_hdr) {
1872 sws_freeContext(sws_ctx_hdr);
1873 sws_ctx_hdr = nullptr;
1874 }
1875 if (codec_ctx) {
1876 avcodec_free_context(&codec_ctx);
1877 codec_ctx = nullptr;
1878 }
1879 if (format_ctx) {
1880 avformat_close_input(&format_ctx);
1881 format_ctx = nullptr;
1882 }
1883 if (hw_device_ctx) {
1884 av_buffer_unref(&hw_device_ctx);
1885 hw_device_ctx = nullptr;
1886 }
1887
1888 stream_index = -1;
1889 hw_pix_fmt = AV_PIX_FMT_NONE;
1890 hw_decode_enabled = false;
1891 draining = false;
1892 drain_packet_sent = false;
1893 width = 0;
1894 height = 0;
1895 fps = 0.0;
1896 frame_count = 0.0;
1897 current_frame = 0;
1898 sws_src_format = AV_PIX_FMT_NONE;
1899 sws_w = 0;
1900 sws_h = 0;
1901 sws_src_format_hdr = AV_PIX_FMT_NONE;
1902 sws_w_hdr = 0;
1903 sws_h_hdr = 0;
1904 is_hdr = false;
1905 hdr_primaries = 0;
1906 hdr_trc = 0;
1907 hdr_colorspace = 0;
1908 hdr_color_range = 0;
1909 hdr_bit_depth = 0;
1910 hdr_mastering_display.clear();
1911 hdr_content_light.clear();
1912 }
1913
1914 AVFormatContext *format_ctx = nullptr;
1915 AVCodecContext *codec_ctx = nullptr;
1916 AVFrame *decoded_frame = nullptr;
1917 AVFrame *sw_frame = nullptr;
1918 AVPacket *packet = nullptr;
1919 SwsContext *sws_ctx = nullptr;
1920 /// Separate SwsContext for the HDR read path (RGBA64LE output).
1921 /// Kept independent of @ref sws_ctx so alternating between read() and
1922 /// readHdr() does not repeatedly re-allocate either context.
1923 SwsContext *sws_ctx_hdr = nullptr;
1924 AVBufferRef *hw_device_ctx = nullptr;
1926 AVPixelFormat hw_pix_fmt = AV_PIX_FMT_NONE;
1927 AVPixelFormat sws_src_format = AV_PIX_FMT_NONE;
1928 int sws_w = 0;
1929 int sws_h = 0;
1930 AVPixelFormat sws_src_format_hdr = AV_PIX_FMT_NONE;
1931 int sws_w_hdr = 0;
1932 int sws_h_hdr = 0;
1933 int width = 0;
1934 int height = 0;
1935 double fps = 0.0;
1936 double frame_count = 0.0;
1937 int64_t current_frame = 0;
1938 bool hw_decode_enabled = false;
1939 bool draining = false;
1940 bool drain_packet_sent = false;
1941 bool is_hdr = false;
1943 int hdr_trc = 0;
1947 std::vector<uint8_t> hdr_mastering_display;
1948 std::vector<uint8_t> hdr_content_light;
1949};
1950
1951/**
1952 * @class SnapshotThreadPool
1953 * @brief A fixed-size thread pool used for writing PNG snapshots asynchronously.
1954 *
1955 * Tasks (snapshot encode + write) are enqueued and executed by worker threads
1956 * so the render loop is never blocked by disk I/O.
1957 */
1959 public:
1960 /**
1961 * @brief Construct a pool with a fixed number of worker threads.
1962 *
1963 * Each worker runs a loop that waits on the shared condition variable.
1964 * When a task is pushed into the queue the condition is signalled and
1965 * exactly one sleeping worker wakes to execute it. Workers remain
1966 * alive until the destructor sets the @c stop flag and joins them.
1967 *
1968 * @param threads Number of OS threads to spawn (typically 2).
1969 */
1970 SnapshotThreadPool(size_t threads) : stop(false) {
1971 for (size_t i = 0; i < threads; ++i)
1972 workers.emplace_back([this] {
1973 for (;;) {
1974 std::function<void()> task;
1975 {
1976 std::unique_lock<std::mutex> lock(this->queue_mutex);
1977 this->condition.wait(lock, [this] { return this->stop || !this->tasks.empty(); });
1978 if (this->stop && this->tasks.empty()) {
1979 return;
1980 }
1981 if (this->tasks.empty()) {
1982 continue;
1983 }
1984 task = std::move(this->tasks.front());
1985 this->tasks.pop();
1986 }
1987 task();
1988 }
1989 });
1990 }
1991
1992 /**
1993 * @brief Submit a callable for asynchronous execution.
1994 *
1995 * The callable is wrapped in a `std::function<void()>` and pushed
1996 * onto the shared task queue. One waiting worker is then notified.
1997 * Throws `std::runtime_error` if the pool has already been stopped.
1998 *
1999 * @tparam F Any callable matching `void()` (typically a lambda
2000 * that captures a FrameData by value for PNG writing).
2001 * @param f The task to execute on a worker thread.
2002 */
2003 template <class F> void enqueue(F &&f) {
2004 {
2005 std::unique_lock<std::mutex> lock(queue_mutex);
2006 if (stop)
2007 throw std::runtime_error("enqueue on stopped SnapshotThreadPool");
2008 tasks.emplace(std::forward<F>(f));
2009 }
2010 condition.notify_one();
2011 }
2012
2013 /**
2014 * @brief Drain remaining tasks and join all worker threads.
2015 *
2016 * Sets the @c stop flag under the lock, then broadcasts the
2017 * condition variable so every sleeping worker wakes and exits.
2018 * Each thread is joined before the destructor returns, guaranteeing
2019 * that no background I/O is still in flight.
2020 */
2022 {
2023 std::unique_lock<std::mutex> lock(queue_mutex);
2024 stop = true;
2025 }
2026 condition.notify_all();
2027 for (std::thread &worker : workers)
2028 worker.join();
2029 }
2030
2031 private:
2032 std::vector<std::thread> workers; ///< Persistent worker threads.
2033 std::queue<std::function<void()>> tasks; ///< FIFO of pending PNG-write tasks.
2034 std::mutex queue_mutex; ///< Protects @c tasks and @c stop.
2035 std::condition_variable condition; ///< Signalled when a task is enqueued or pool stops.
2036 bool stop = false; ///< When true, workers exit after draining the queue.
2037};
2038
2039/**
2040 * @class FrameCache
2041 * @brief Fixed-capacity ring buffer of GL textures for temporal shaders.
2042 *
2043 * Legacy shaders whose filename contains "cache" receive previous frames as
2044 * separate sampler2D uniforms. Array-cache shaders instead receive one
2045 * sampler2DArray whose layers form the ring. In either mode only the newest
2046 * frame is uploaded into the next physical slot. The "oldest → newest"
2047 * indexing used by shaders is preserved via a logical-to-physical offset.
2048 *
2049 * This mirrors the texture-ring approach used by gl_compute_cv and
2050 * eliminates 7 BGR→RGBA conversions plus 7 texture uploads per push.
2051 */
2053 public:
2054 /**
2055 * @param num Maximum number of frames to retain.
2056 * @param use_array Store the ring in one `GL_TEXTURE_2D_ARRAY` instead of
2057 * separate `GL_TEXTURE_2D` objects.
2058 */
2059 explicit FrameCache(std::size_t num, bool use_array = false) : num_frames(num), use_history_array(use_array) {}
2061
2062 FrameCache(const FrameCache &) = delete;
2063 FrameCache &operator=(const FrameCache &) = delete;
2064
2065 /**
2066 * @brief Allocate the ring of GL textures sized to @p w x @p h.
2067 *
2068 * Must be called once a GL context is current. Existing textures (if
2069 * any) are released first. The ring remains empty until the first source
2070 * frame is replicated into every slot by one of the push methods.
2071 *
2072 * @param w Texture width in pixels.
2073 * @param h Texture height in pixels.
2074 * @param hdr When true, allocate textures as @c GL_RGBA16F (matching
2075 * the HDR linear-light pipeline) instead of @c GL_RGBA.
2076 */
2077 void init(int w, int h, bool hdr = false) {
2078 cleanup();
2079 if (num_frames == 0)
2080 return;
2081 width = w;
2082 height = h;
2083 is_hdr = hdr;
2084 if (use_history_array) {
2086 head = 0;
2087 count = 0;
2088 return;
2089 }
2090
2091 textures.assign(num_frames, 0);
2092 glGenTextures(static_cast<GLsizei>(num_frames), textures.data());
2093 const GLint internal = hdr ? GL_RGBA16F : GL_RGBA;
2094 const GLenum type = hdr ? GL_HALF_FLOAT : GL_UNSIGNED_BYTE;
2095 const std::size_t bytes_per_pixel = hdr ? 8u : 4u;
2096 std::vector<unsigned char> zeros(static_cast<size_t>(w) * static_cast<size_t>(h) * bytes_per_pixel, 0);
2097 for (std::size_t i = 0; i < num_frames; ++i) {
2098 glBindTexture(GL_TEXTURE_2D, textures[i]);
2099 glTexImage2D(GL_TEXTURE_2D, 0, internal, w, h, 0, GL_RGBA, type, zeros.data());
2100 glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
2101 glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
2102 glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
2103 glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
2104 }
2105 glBindTexture(GL_TEXTURE_2D, 0);
2106 head = 0;
2107 count = 0;
2108 }
2109
2110 /// Release all GL textures and reset the ring state.
2111 void cleanup() {
2112 if (!textures.empty()) {
2113 glDeleteTextures(static_cast<GLsizei>(textures.size()), textures.data());
2114 textures.clear();
2115 }
2116 if (scratch_fbo != 0) {
2117 glDeleteFramebuffers(1, &scratch_fbo);
2118 scratch_fbo = 0;
2119 }
2120 if (history_texture != 0) {
2121 glDeleteTextures(1, &history_texture);
2122 history_texture = 0;
2123 }
2124 head = 0;
2125 count = 0;
2126 width = 0;
2127 height = 0;
2128 is_hdr = false;
2129 }
2130
2131 /**
2132 * @brief GPU-side push from a source FBO into the next ring slot.
2133 *
2134 * Used by the HDR pipeline to copy the post-decode linear BT.2020
2135 * texture into an @c RGBA16F ring without any CPU readback or pixel
2136 * format conversion. The source FBO must have a colour attachment
2137 * sized at least @p w x @p h and using a compatible internal format
2138 * (e.g. @c GL_RGBA16F when the cache was initialised with @c hdr=true).
2139 *
2140 * @param src_fbo Read framebuffer object containing the new frame.
2141 * @param w,h Region to copy (typically the full FBO dimensions).
2142 */
2143 void pushFromFBO(GLuint src_fbo, int w, int h) {
2144 if (!hasStorage())
2145 return;
2146 GLint prev_read = 0;
2147 glGetIntegerv(GL_READ_FRAMEBUFFER_BINDING, &prev_read);
2148 glBindFramebuffer(GL_READ_FRAMEBUFFER, src_fbo);
2149 glReadBuffer(GL_COLOR_ATTACHMENT0);
2150 if (use_history_array) {
2151 if (w != width || h != height) {
2152 width = w;
2153 height = h;
2155 head = 0;
2156 count = 0;
2157 }
2158 glBindTexture(GL_TEXTURE_2D_ARRAY, history_texture);
2159 if (count == 0) {
2160 for (std::size_t i = 0; i < num_frames; ++i) {
2161 glCopyTexSubImage3D(GL_TEXTURE_2D_ARRAY, 0, 0, 0, static_cast<GLint>(i), 0, 0, w, h);
2162 }
2163 } else {
2164 glCopyTexSubImage3D(GL_TEXTURE_2D_ARRAY, 0, 0, 0, static_cast<GLint>(head), 0, 0, w, h);
2165 }
2166 glBindTexture(GL_TEXTURE_2D_ARRAY, 0);
2167 glBindFramebuffer(GL_READ_FRAMEBUFFER, prev_read);
2168 finishPush();
2169 return;
2170 }
2171
2172 if (w != width || h != height) {
2173 const GLint internal = is_hdr ? GL_RGBA16F : GL_RGBA;
2174 const GLenum type = is_hdr ? GL_HALF_FLOAT : GL_UNSIGNED_BYTE;
2175 for (GLuint texture : textures) {
2176 glBindTexture(GL_TEXTURE_2D, texture);
2177 glTexImage2D(GL_TEXTURE_2D, 0, internal, w, h, 0, GL_RGBA, type, nullptr);
2178 glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
2179 glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
2180 glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
2181 glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
2182 }
2183 width = w;
2184 height = h;
2185 head = 0;
2186 count = 0;
2187 }
2188 if (count == 0) {
2189 for (GLuint texture : textures) {
2190 glBindTexture(GL_TEXTURE_2D, texture);
2191 glCopyTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, 0, 0, w, h);
2192 }
2193 } else {
2194 glBindTexture(GL_TEXTURE_2D, textures[head]);
2195 glCopyTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, 0, 0, w, h);
2196 }
2197 glBindTexture(GL_TEXTURE_2D, 0);
2198 glBindFramebuffer(GL_READ_FRAMEBUFFER, prev_read);
2199 finishPush();
2200 }
2201
2202 /**
2203 * @brief Upload @p frame (BGR) into the next ring slot.
2204 *
2205 * When the ring is full this overwrites the oldest slot in place,
2206 * which becomes the new "newest" frame. Only one BGR→RGBA conversion
2207 * and one texture upload occur per call.
2208 */
2209 void push(const cv::Mat &frame) {
2210 if (!hasStorage())
2211 return;
2212 if (count == 0 || frame.cols != width || frame.rows != height) {
2213 fill(frame);
2214 return;
2215 }
2216 cv::Mat tmp;
2217 cv::cvtColor(frame, tmp, cv::COLOR_BGR2RGBA);
2218 if (use_history_array) {
2219 glBindTexture(GL_TEXTURE_2D_ARRAY, history_texture);
2220 glTexSubImage3D(GL_TEXTURE_2D_ARRAY, 0, 0, 0, static_cast<GLint>(head), tmp.cols, tmp.rows, 1, GL_RGBA, GL_UNSIGNED_BYTE, tmp.ptr());
2221 glBindTexture(GL_TEXTURE_2D_ARRAY, 0);
2222 advance();
2223 return;
2224 }
2225
2226 glBindTexture(GL_TEXTURE_2D, textures[head]);
2227 glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, tmp.cols, tmp.rows, GL_RGBA, GL_UNSIGNED_BYTE, tmp.ptr());
2228 glBindTexture(GL_TEXTURE_2D, 0);
2229 advance();
2230 }
2231
2232 /**
2233 * @brief GPU-side push from an existing GL texture into the next ring slot.
2234 *
2235 * Uses a small scratch FBO so the per-frame BGR->RGBA conversion and
2236 * second host->GPU upload that @ref push() performs can be avoided
2237 * entirely once the camera texture has already been populated for
2238 * the shader chain. This is the path that keeps zero-copy interop
2239 * intact when expensive CPU passes (e.g. ONNX) run earlier in the
2240 * frame and the redundant cv::Mat upload starts dropping frames.
2241 *
2242 * @param src_tex GL texture currently containing the new frame.
2243 * @param w,h Region to copy (typically the full texture size).
2244 */
2245 void pushFromTexture(GLuint src_tex, int w, int h) {
2246 if (!hasStorage() || src_tex == 0)
2247 return;
2248 if (scratch_fbo == 0) {
2249 glGenFramebuffers(1, &scratch_fbo);
2250 }
2251 GLint prev_read = 0;
2252 glGetIntegerv(GL_READ_FRAMEBUFFER_BINDING, &prev_read);
2253 glBindFramebuffer(GL_READ_FRAMEBUFFER, scratch_fbo);
2254 glFramebufferTexture2D(GL_READ_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, src_tex, 0);
2255 glReadBuffer(GL_COLOR_ATTACHMENT0);
2256 if (use_history_array) {
2257 if (w != width || h != height) {
2258 width = w;
2259 height = h;
2261 head = 0;
2262 count = 0;
2263 }
2264 glBindTexture(GL_TEXTURE_2D_ARRAY, history_texture);
2265 if (count == 0) {
2266 for (std::size_t i = 0; i < num_frames; ++i) {
2267 glCopyTexSubImage3D(GL_TEXTURE_2D_ARRAY, 0, 0, 0, static_cast<GLint>(i), 0, 0, w, h);
2268 }
2269 } else {
2270 glCopyTexSubImage3D(GL_TEXTURE_2D_ARRAY, 0, 0, 0, static_cast<GLint>(head), 0, 0, w, h);
2271 }
2272 glBindTexture(GL_TEXTURE_2D_ARRAY, 0);
2273 glFramebufferTexture2D(GL_READ_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, 0, 0);
2274 glBindFramebuffer(GL_READ_FRAMEBUFFER, prev_read);
2275 finishPush();
2276 return;
2277 }
2278
2279 if (w != width || h != height) {
2280 const GLint internal = is_hdr ? GL_RGBA16F : GL_RGBA;
2281 const GLenum type = is_hdr ? GL_HALF_FLOAT : GL_UNSIGNED_BYTE;
2282 for (GLuint texture : textures) {
2283 glBindTexture(GL_TEXTURE_2D, texture);
2284 glTexImage2D(GL_TEXTURE_2D, 0, internal, w, h, 0, GL_RGBA, type, nullptr);
2285 glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
2286 glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
2287 glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
2288 glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
2289 }
2290 width = w;
2291 height = h;
2292 head = 0;
2293 count = 0;
2294 }
2295 if (count == 0) {
2296 for (GLuint texture : textures) {
2297 glBindTexture(GL_TEXTURE_2D, texture);
2298 glCopyTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, 0, 0, w, h);
2299 }
2300 } else {
2301 glBindTexture(GL_TEXTURE_2D, textures[head]);
2302 glCopyTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, 0, 0, w, h);
2303 }
2304 glBindTexture(GL_TEXTURE_2D, 0);
2305 // Detach to avoid keeping a stale reference to the source texture.
2306 glFramebufferTexture2D(GL_READ_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, 0, 0);
2307 glBindFramebuffer(GL_READ_FRAMEBUFFER, prev_read);
2308 finishPush();
2309 }
2310
2311 /**
2312 * @brief Return the GL texture at logical index @p index.
2313 * @param index 0 = oldest retained frame, @c size()-1 = newest.
2314 */
2315 GLuint textureAt(std::size_t index) const {
2316 const std::size_t base = (count == num_frames) ? head : 0;
2317 return textures[(base + index) % num_frames];
2318 }
2319
2320 /// Number of frames currently retained (0 ≤ n ≤ capacity).
2321 std::size_t size() const { return count; }
2322
2323 /// Maximum number of frames retained by the ring.
2324 std::size_t capacity() const { return num_frames; }
2325
2326 /// True when the ring is fully populated.
2327 bool isFull() const { return count == num_frames; }
2328
2329 /// Array texture containing the physical history ring layers.
2330 GLuint historyTexture() const { return history_texture; }
2331
2332 /// Physical array layer corresponding to logical history index zero.
2333 int oldestLayer() const { return isFull() ? static_cast<int>(head) : 0; }
2334
2335 /**
2336 * @brief Pre-fill every slot with copies of a single frame.
2337 *
2338 * Seeds the entire cache with one frame and marks the ring as full.
2339 */
2340 void fill(const cv::Mat &frame) {
2341 if (!hasStorage())
2342 return;
2343 cv::Mat tmp;
2344 cv::cvtColor(frame, tmp, cv::COLOR_BGR2RGBA);
2345 const bool size_matches = (tmp.cols == width && tmp.rows == height);
2346 if (use_history_array) {
2347 if (!size_matches) {
2348 width = tmp.cols;
2349 height = tmp.rows;
2351 }
2352 glBindTexture(GL_TEXTURE_2D_ARRAY, history_texture);
2353 for (std::size_t i = 0; i < num_frames; ++i) {
2354 glTexSubImage3D(GL_TEXTURE_2D_ARRAY, 0, 0, 0, static_cast<GLint>(i), tmp.cols, tmp.rows, 1, GL_RGBA, GL_UNSIGNED_BYTE, tmp.ptr());
2355 }
2356 glBindTexture(GL_TEXTURE_2D_ARRAY, 0);
2357 head = 0;
2358 count = num_frames;
2359 return;
2360 }
2361
2362 for (std::size_t i = 0; i < num_frames; ++i) {
2363 glBindTexture(GL_TEXTURE_2D, textures[i]);
2364 if (!size_matches) {
2365 glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, tmp.cols, tmp.rows, 0, GL_RGBA, GL_UNSIGNED_BYTE, tmp.ptr());
2366 } else {
2367 glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, tmp.cols, tmp.rows, GL_RGBA, GL_UNSIGNED_BYTE, tmp.ptr());
2368 }
2369 }
2370 glBindTexture(GL_TEXTURE_2D, 0);
2371 if (!size_matches) {
2372 width = tmp.cols;
2373 height = tmp.rows;
2374 }
2375 head = 0;
2376 count = num_frames;
2377 }
2378
2379 private:
2380 bool hasStorage() const { return use_history_array ? history_texture != 0 : !textures.empty(); }
2381
2382 void advance() {
2383 head = (head + 1) % num_frames;
2384 if (count < num_frames)
2385 ++count;
2386 }
2387
2388 void finishPush() {
2389 if (count == 0) {
2390 head = 0;
2391 count = num_frames;
2392 } else {
2393 advance();
2394 }
2395 }
2396
2397 /**
2398 * @brief Allocate the optional array texture using the cache's dimensions
2399 * and pixel format.
2400 */
2402 if (!use_history_array || num_frames == 0 || width <= 0 || height <= 0)
2403 return;
2404 if (history_texture == 0) {
2405 glGenTextures(1, &history_texture);
2406 }
2407 glBindTexture(GL_TEXTURE_2D_ARRAY, history_texture);
2408 const GLint internal = is_hdr ? GL_RGBA16F : GL_RGBA;
2409 const GLenum type = is_hdr ? GL_HALF_FLOAT : GL_UNSIGNED_BYTE;
2410 glTexImage3D(GL_TEXTURE_2D_ARRAY, 0, internal, width, height, static_cast<GLsizei>(num_frames), 0, GL_RGBA, type, nullptr);
2411 glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
2412 glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
2413 glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
2414 glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
2415 glBindTexture(GL_TEXTURE_2D_ARRAY, 0);
2416 }
2417
2418 std::size_t num_frames;
2419 int width = 0;
2420 int height = 0;
2421 bool is_hdr = false;
2422 bool use_history_array = false;
2423 std::size_t head = 0;
2424 std::size_t count = 0;
2425 std::vector<GLuint> textures;
2426 GLuint scratch_fbo = 0; ///< Scratch FBO used by pushFromTexture().
2427 GLuint history_texture = 0; ///< Optional array-backed history ring.
2428};
2429
2430/**
2431 * @class TextureUploader
2432 * @brief Host-zero-copy CUDA-to-OpenGL texture transfer via direct image interop.
2433 *
2434 * Registers the OpenGL texture directly with CUDA (cudaGraphicsGLRegisterImage)
2435 * so that a cv::cuda::GpuMat can be copied straight into the texture's backing
2436 * cudaArray with a single device-to-device cudaMemcpy2DToArrayAsync. This
2437 * removes the intermediate PBO and the per-frame glTexSubImage2D DMA that the
2438 * old PBO path required, halving the per-frame interop cost.
2439 *
2440 * The copy is issued on a dedicated CUDA stream so it does not serialise with
2441 * other default-stream work (e.g. the GPU filter chain that produced the
2442 * GpuMat). A CUDA event orders that copy after the producer's default-stream
2443 * work, while cudaGraphicsUnmapResources provides the GL↔CUDA synchronisation
2444 * that subsequent OpenGL draws need.
2445 */
2447 public:
2448 GLuint textureID = 0; ///< OpenGL texture receiving the frame data.
2449#ifdef ACMX2_WITH_CUDA
2450 cudaGraphicsResource *cudaTexResource = nullptr; ///< CUDA handle to the mapped GL texture.
2451 cudaStream_t uploadStream = nullptr; ///< Dedicated stream for the device→array copy.
2452 cudaEvent_t inputReadyEvent = nullptr; ///< Orders the copy after GpuMat production.
2453#endif
2454 int width = 0; ///< Current texture width in pixels.
2455 int height = 0; ///< Current texture height in pixels.
2456
2457 /**
2458 * @brief Create (or recreate) the GL texture and CUDA image registration.
2459 *
2460 * Allocates an RGBA8 OpenGL texture of the requested dimensions and
2461 * registers it with the CUDA runtime via cudaGraphicsGLRegisterImage,
2462 * which exposes the texture's backing storage as a cudaArray that
2463 * subsequent update() calls write into directly.
2464 *
2465 * If the uploader was previously initialised, cleanup() is called first
2466 * so that old resources are released before new ones are created.
2467 *
2468 * @param w Texture width in pixels.
2469 * @param h Texture height in pixels.
2470 */
2471 void init(int w, int h) {
2472 if (textureID != 0)
2473 cleanup();
2474 width = w;
2475 height = h;
2476 glGenTextures(1, &textureID);
2477 glBindTexture(GL_TEXTURE_2D, textureID);
2478 // Use a sized internal format so cudaGraphicsGLRegisterImage can match
2479 // it deterministically across drivers.
2480 glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, width, height, 0, GL_RGBA, GL_UNSIGNED_BYTE, NULL);
2481 glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
2482 glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
2483 glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
2484 glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
2485 glBindTexture(GL_TEXTURE_2D, 0);
2486#ifdef ACMX2_WITH_CUDA
2487 // WriteDiscard: tell the driver we don't need the previous texture
2488 // contents preserved on map — that is true for streaming frames and
2489 // lets the driver skip a possible read-back.
2490 CHECK_CUDA(cudaGraphicsGLRegisterImage(&cudaTexResource, textureID, GL_TEXTURE_2D, cudaGraphicsRegisterFlagsWriteDiscard));
2491 if (!uploadStream) {
2492 CHECK_CUDA(cudaStreamCreateWithFlags(&uploadStream, cudaStreamNonBlocking));
2493 }
2494 if (!inputReadyEvent) {
2495 CHECK_CUDA(cudaEventCreateWithFlags(&inputReadyEvent, cudaEventDisableTiming));
2496 }
2497#endif
2498 }
2499
2500#ifdef ACMX2_WITH_CUDA
2501 /**
2502 * @brief Upload a CUDA GpuMat into the OpenGL texture without a host copy.
2503 *
2504 * Steps:
2505 * 1. Map the GL texture as a cudaArray (cudaGraphicsMapResources).
2506 * 2. cudaMemcpy2DToArrayAsync the GpuMat rows into that array on the
2507 * uploader's dedicated stream — a single device-to-device DMA into
2508 * the texture's own storage, no PBO intermediate.
2509 * 3. Unmap the resource so OpenGL can sample it. cudaGraphicsUnmapResources
2510 * inserts the GL↔CUDA dependency needed before the next draw.
2511 *
2512 * If the incoming frame dimensions differ from the current texture,
2513 * init() is called automatically to reallocate.
2514 *
2515 * @param gpuFrame The CUDA GpuMat (CV_8UC4 / RGBA) to upload.
2516 */
2517 void update(const cv::cuda::GpuMat &gpuFrame) {
2518 if (gpuFrame.cols != width || gpuFrame.rows != height) {
2519 init(gpuFrame.cols, gpuFrame.rows);
2520 }
2521 cudaArray_t texArray = nullptr;
2522 // OpenCV CUDA calls in this pipeline and launch_filter() produce their
2523 // output on the default stream. The uploader stream is nonblocking, so
2524 // establish the dependency explicitly before reading gpuFrame.
2525 CHECK_CUDA(cudaEventRecord(inputReadyEvent, nullptr));
2526 CHECK_CUDA(cudaStreamWaitEvent(uploadStream, inputReadyEvent, 0));
2527 CHECK_CUDA(cudaGraphicsMapResources(1, &cudaTexResource, uploadStream));
2528 CHECK_CUDA(cudaGraphicsSubResourceGetMappedArray(&texArray, cudaTexResource, 0, 0));
2529 CHECK_CUDA(cudaMemcpy2DToArrayAsync(texArray, 0, 0, gpuFrame.data, gpuFrame.step, static_cast<size_t>(width) * 4, static_cast<size_t>(height), cudaMemcpyDeviceToDevice, uploadStream));
2530 CHECK_CUDA(cudaGraphicsUnmapResources(1, &cudaTexResource, uploadStream));
2531 }
2532#endif
2533
2534 /**
2535 * @brief Release all GPU resources (CUDA registration, stream, texture).
2536 *
2537 * Unregisters the texture from CUDA, destroys the upload stream, and
2538 * deletes the OpenGL texture. Safe to call multiple times—each
2539 * resource handle is tested for non-zero before deletion and
2540 * reset to zero / nullptr afterwards.
2541 */
2542 void cleanup() {
2543#ifdef ACMX2_WITH_CUDA
2544 if (cudaTexResource) {
2545 CHECK_CUDA(cudaGraphicsUnregisterResource(cudaTexResource));
2546 cudaTexResource = nullptr;
2547 }
2548 if (uploadStream) {
2549 cudaStreamDestroy(uploadStream);
2550 uploadStream = nullptr;
2551 }
2552 if (inputReadyEvent) {
2553 cudaEventDestroy(inputReadyEvent);
2554 inputReadyEvent = nullptr;
2555 }
2556#endif
2557 if (textureID) {
2558 glDeleteTextures(1, &textureID);
2559 textureID = 0;
2560 }
2561 }
2562};
2563
2564/**
2565 * @struct ShaderCacheEntry
2566 * @brief One shader's precompiled binary data for the on-disk cache.
2567 *
2568 * Stores the GL program binary for a fragment or compute shader, plus an
2569 * FNV-1a hash of the source file so stale entries are detected on load.
2570 * Fragment entries can contain both 2D and 3D variants; compute entries use
2571 * binary_2d for their standalone program and use a 3D passthrough slot.
2572 */
2574 std::string shader_name; ///< Stem of the .glsl filename.
2575 std::vector<char> binary_2d; ///< GL program binary (2D vertex shader).
2576 GLenum format_2d = 0; ///< GL binary format token for 2D.
2577 std::vector<char> binary_3d; ///< GL program binary (3D vertex shader).
2578 GLenum format_3d = 0; ///< GL binary format token for 3D.
2579 uint64_t source_hash = 0; ///< FNV-1a-64 hash of the fragment source.
2581 bool failed = false; ///< True if this shader failed to compile;
2582 ///< a passthrough program is substituted at load time
2583 ///< to preserve the user-visible shader index.
2584};
2585
2586/**
2587 * @struct ShaderCache
2588 * @brief Container for the full on-disk shader cache file.
2589 *
2590 * The binary file format is:
2591 * `[MAGIC][VERSION][gl_renderer][gl_version][dual_mode][count][entries...]`
2592 *
2593 * If the GL renderer or driver version changes, the cache is invalidated.
2594 */
2596 static constexpr uint32_t CACHE_MAGIC = 0x53484452; ///< File magic: "SHDR".
2597 static constexpr uint32_t CACHE_VERSION = 4; ///< Current format version.
2598 std::string gl_renderer;
2599 std::string gl_version;
2600 bool dual_mode = false;
2601 std::vector<ShaderCacheEntry> entries;
2602
2603 /**
2604 * @brief Serialise the entire shader cache to a binary file.
2605 *
2606 * File layout (all values little-endian, no padding):
2607 * | Offset | Field |
2608 * |--------|----------------------|
2609 * | 0 | uint32 CACHE_MAGIC |
2610 * | 4 | uint32 CACHE_VERSION |
2611 * | 8 | string gl_renderer |
2612 * | | string gl_version |
2613 * | | bool dual_mode |
2614 * | | uint32 entry_count |
2615 * | | entries… |
2616 *
2617 * Each string is written as a `uint32` length followed by raw bytes.
2618 * Each entry contains: shader_name (string), kind (uint8), failed (uint8),
2619 * source_hash (uint64),
2620 * format_2d (GLenum), binary_2d (uint32 size + raw bytes),
2621 * format_3d (GLenum), binary_3d (uint32 size + raw bytes).
2622 *
2623 * @param path Filesystem path for the output file (e.g. `library/.shader_cache`).
2624 * @return True if the file was written without stream errors.
2625 */
2626 bool save(const std::string &path) const {
2627 std::error_code ec;
2628 std::filesystem::path parent = std::filesystem::path(path).parent_path();
2629 if (!parent.empty() && !std::filesystem::exists(parent, ec)) {
2630 std::filesystem::create_directories(parent, ec);
2631 if (ec) {
2632 mx::system_err << "acmx2: Could not create cache directory '" << parent.string() << "': " << ec.message() << "\n";
2633 return false;
2634 }
2635 }
2636 std::ofstream file(path, std::ios::binary);
2637 if (!file.is_open()) {
2638 mx::system_err << "acmx2: Could not open cache file for writing: '" << path << "' (errno=" << errno << " - " << std::strerror(errno) << ")\n";
2639 return false;
2640 }
2641
2642 file.write(reinterpret_cast<const char *>(&CACHE_MAGIC), sizeof(CACHE_MAGIC));
2643 file.write(reinterpret_cast<const char *>(&CACHE_VERSION), sizeof(CACHE_VERSION));
2644
2645 auto writeString = [&file](const std::string &s) {
2646 uint32_t len = static_cast<uint32_t>(s.size());
2647 file.write(reinterpret_cast<const char *>(&len), sizeof(len));
2648 file.write(s.data(), len);
2649 };
2650
2651 writeString(gl_renderer);
2652 writeString(gl_version);
2653 file.write(reinterpret_cast<const char *>(&dual_mode), sizeof(dual_mode));
2654
2655 uint32_t count = static_cast<uint32_t>(entries.size());
2656 file.write(reinterpret_cast<const char *>(&count), sizeof(count));
2657
2658 for (const auto &e : entries) {
2659 writeString(e.shader_name);
2660 const uint8_t kind = static_cast<uint8_t>(e.kind);
2661 file.write(reinterpret_cast<const char *>(&kind), sizeof(kind));
2662 uint8_t failed_flag = e.failed ? 1 : 0;
2663 file.write(reinterpret_cast<const char *>(&failed_flag), sizeof(failed_flag));
2664 file.write(reinterpret_cast<const char *>(&e.source_hash), sizeof(e.source_hash));
2665 file.write(reinterpret_cast<const char *>(&e.format_2d), sizeof(e.format_2d));
2666 uint32_t size_2d = static_cast<uint32_t>(e.binary_2d.size());
2667 file.write(reinterpret_cast<const char *>(&size_2d), sizeof(size_2d));
2668 file.write(e.binary_2d.data(), size_2d);
2669
2670 file.write(reinterpret_cast<const char *>(&e.format_3d), sizeof(e.format_3d));
2671 uint32_t size_3d = static_cast<uint32_t>(e.binary_3d.size());
2672 file.write(reinterpret_cast<const char *>(&size_3d), sizeof(size_3d));
2673 file.write(e.binary_3d.data(), size_3d);
2674 }
2675 return file.good();
2676 }
2677
2678 /**
2679 * @brief Deserialise the shader cache from a binary file.
2680 *
2681 * Validates the magic number and version before reading. If either
2682 * does not match (e.g. cache was written by an older version or a
2683 * different application) the method returns false and leaves the
2684 * object in an indeterminate state—callers should discard it.
2685 *
2686 * The layout mirrors save(): magic, version, renderer string,
2687 * GL version string, dual_mode flag, entry count, then each entry.
2688 *
2689 * @param path Filesystem path of the cache file to read.
2690 * @return True on success; false if the file is missing, corrupt,
2691 * or has a version / magic mismatch.
2692 */
2693 bool load(const std::string &path) {
2694 std::ifstream file(path, std::ios::binary);
2695 if (!file.is_open())
2696 return false;
2697
2698 uint32_t magic, version;
2699 file.read(reinterpret_cast<char *>(&magic), sizeof(magic));
2700 file.read(reinterpret_cast<char *>(&version), sizeof(version));
2701
2702 if (magic != CACHE_MAGIC || version != CACHE_VERSION)
2703 return false;
2704
2705 auto readString = [&file]() -> std::string {
2706 uint32_t len;
2707 file.read(reinterpret_cast<char *>(&len), sizeof(len));
2708 std::string s(len, '\0');
2709 file.read(s.data(), len);
2710 return s;
2711 };
2712
2713 gl_renderer = readString();
2714 gl_version = readString();
2715 file.read(reinterpret_cast<char *>(&dual_mode), sizeof(dual_mode));
2716
2717 uint32_t count;
2718 file.read(reinterpret_cast<char *>(&count), sizeof(count));
2719 entries.resize(count);
2720
2721 for (auto &e : entries) {
2722 e.shader_name = readString();
2723 uint8_t kind = 0;
2724 file.read(reinterpret_cast<char *>(&kind), sizeof(kind));
2725 if (kind > static_cast<uint8_t>(ShaderProgramKind::ComputeUnavailable))
2726 return false;
2727 e.kind = static_cast<ShaderProgramKind>(kind);
2728 uint8_t failed_flag = 0;
2729 file.read(reinterpret_cast<char *>(&failed_flag), sizeof(failed_flag));
2730 e.failed = (failed_flag != 0);
2731 file.read(reinterpret_cast<char *>(&e.source_hash), sizeof(e.source_hash));
2732 file.read(reinterpret_cast<char *>(&e.format_2d), sizeof(e.format_2d));
2733 uint32_t size_2d;
2734 file.read(reinterpret_cast<char *>(&size_2d), sizeof(size_2d));
2735 e.binary_2d.resize(size_2d);
2736 file.read(e.binary_2d.data(), size_2d);
2737
2738 file.read(reinterpret_cast<char *>(&e.format_3d), sizeof(e.format_3d));
2739 uint32_t size_3d;
2740 file.read(reinterpret_cast<char *>(&size_3d), sizeof(size_3d));
2741 e.binary_3d.resize(size_3d);
2742 file.read(e.binary_3d.data(), size_3d);
2743 }
2744 return file.good();
2745 }
2746};
2747
2748typedef void(APIENTRYP PFNGLGETPROGRAMBINARYPROC_LOCAL)(GLuint program, GLsizei bufSize, GLsizei *length, GLenum *binaryFormat, void *binary);
2749typedef void(APIENTRYP PFNGLPROGRAMBINARYPROC_LOCAL)(GLuint program, GLenum binaryFormat, const void *binary, GLsizei length);
2750
2751static PFNGLGETPROGRAMBINARYPROC_LOCAL glGetProgramBinaryFunc = nullptr;
2752static PFNGLPROGRAMBINARYPROC_LOCAL glProgramBinaryFunc = nullptr;
2753
2754/// @brief Dynamically load glGetProgramBinary / glProgramBinary via SDL.
2756 if (glGetProgramBinaryFunc != nullptr)
2757 return true;
2758 glGetProgramBinaryFunc = reinterpret_cast<PFNGLGETPROGRAMBINARYPROC_LOCAL>(SDL_GL_GetProcAddress("glGetProgramBinary"));
2759 glProgramBinaryFunc = reinterpret_cast<PFNGLPROGRAMBINARYPROC_LOCAL>(SDL_GL_GetProcAddress("glProgramBinary"));
2760
2761 if (glGetProgramBinaryFunc == nullptr || glProgramBinaryFunc == nullptr) {
2762 mx::system_err << "acmx2: Failed to load glGetProgramBinary/glProgramBinary functions\n";
2763 return false;
2764 }
2765 return true;
2766}
2767
2768#ifdef AUDIO_ENABLED
2769/**
2770 * @class SpectrumTexture
2771 * @brief Manages a 1D OpenGL texture that holds the FFT frequency-magnitude spectrum.
2772 *
2773 * ### What this class does
2774 * Every audio frame, the RtAudio callback captures raw PCM samples into a
2775 * thread-safe analyzer buffer. On the **render** thread, `update()` computes
2776 * a radix-2 FFT and uploads the resulting magnitude array into a
2777 * **GL_TEXTURE_1D** so that
2778 * any GLSL shader can sample it.
2779 *
2780 * ### How the 1D texture works
2781 * A 1D texture is like a single-row image. Each texel (pixel) stores one
2782 * float — the energy at that frequency bin. The texture is `FFT_SIZE/2`
2783 * texels wide (256 by default) because a real-valued FFT is symmetric and
2784 * only the first half carries unique information.
2785 *
2786 * The internal format is **GL_R32F** (one 32-bit float per texel, red
2787 * channel only). In GLSL you read it with:
2788 * @code{.glsl}
2789 * uniform sampler1D spectrum; // bound to texture unit 9
2790 * float energy = texture(spectrum, x).r; // x in [0,1]
2791 * @endcode
2792 * where `x = 0.0` is the DC bin and `x = 1.0` is the Nyquist frequency.
2793 *
2794 * ### Texture parameters
2795 * - **GL_LINEAR** filtering — the GPU interpolates between adjacent bins,
2796 * giving smooth results even when the shader samples at non-integer
2797 * frequency positions.
2798 * - **GL_CLAMP_TO_EDGE** wrapping — lookups outside [0,1] clamp to the
2799 * nearest edge bin instead of wrapping or returning black.
2800 *
2801 * ### Texture unit
2802 * The spectrum is bound to **GL_TEXTURE9** (unit 9). Units 0 is the main
2803 * video frame, units 1–8 are the temporal frame cache, so 9 is the first
2804 * free slot.
2805 *
2806 * ### RAII
2807 * `init()` creates the texture; `cleanup()` deletes it. The destructor
2808 * calls `cleanup()` automatically, so you never leak GPU resources.
2809 *
2810 * @see acmx2::audio::AudioAnalyzer
2811 */
2813 public:
2815
2816 /**
2817 * @brief Create the 1D texture and set its sampling parameters.
2818 *
2819 * Allocates a `GL_TEXTURE_1D` of width `FFT_SIZE / 2` (256 texels)
2820 * with the `GL_R32F` internal format (one float per texel). The
2821 * initial texel data is nullptr — the texture is filled on the first
2822 * `update()` call.
2823 *
2824 * The two filter parameters (`GL_LINEAR`) tell the GPU to linearly
2825 * interpolate when a shader samples *between* two bins, which avoids
2826 * visible staircase artefacts. `GL_CLAMP_TO_EDGE` prevents wrap-
2827 * around artefacts if a shader accidentally samples outside [0,1].
2828 */
2829 void init() {
2830 if (textureID != 0)
2831 return;
2832 bins = FFT_SIZE / 2;
2833
2834 glGenTextures(1, &textureID);
2835 glBindTexture(GL_TEXTURE_1D, textureID);
2836
2837 // Allocate the texture storage — one 32-bit float per texel.
2838 // GL_R32F = internal format (single-channel 32-bit float)
2839 // GL_RED = source channel layout
2840 // GL_FLOAT = source data type
2841 glTexImage1D(GL_TEXTURE_1D, 0, GL_R32F, bins, 0, GL_RED, GL_FLOAT, nullptr);
2842
2843 // GL_LINEAR makes the GPU interpolate between adjacent frequency
2844 // bins when the shader samples at a fractional position.
2845 glTexParameteri(GL_TEXTURE_1D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
2846 glTexParameteri(GL_TEXTURE_1D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
2847
2848 // Clamp so lookups outside [0,1] stick to the edge bin.
2849 glTexParameteri(GL_TEXTURE_1D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
2850
2851 glBindTexture(GL_TEXTURE_1D, 0);
2852 }
2853
2854 /**
2855 * @brief Compute a fresh FFT and upload the magnitudes into the texture.
2856 *
2857 * This is the per-frame call that bridges the audio and GPU worlds:
2858 * 1. AudioAnalyzer reads the latest PCM snapshot, applies a
2859 * Hann window, runs the radix-2 FFT, and writes the magnitudes.
2860 * 2. `glTexSubImage1D()` copies those magnitudes into the existing
2861 * texture **without** reallocating — much cheaper than `glTexImage1D`
2862 * every frame.
2863 *
2864 * Call this once per frame from the render thread, before binding the
2865 * texture for shader use.
2866 */
2867 void update() {
2868 if (textureID == 0)
2869 return;
2871 const auto &mags = analyzer.spectrum();
2872 glBindTexture(GL_TEXTURE_1D, textureID);
2873 glTexSubImage1D(GL_TEXTURE_1D, 0, 0, bins, GL_RED, GL_FLOAT, mags.data());
2874 glBindTexture(GL_TEXTURE_1D, 0);
2875 }
2876
2877 void update(float scale) {
2878 if (textureID == 0)
2879 return;
2881 const auto &mags = analyzer.spectrum();
2882 scaled_buf.resize(mags.size());
2883 for (size_t i = 0; i < mags.size(); ++i)
2884 scaled_buf[i] = mags[i] * scale;
2885 glBindTexture(GL_TEXTURE_1D, textureID);
2886 glTexSubImage1D(GL_TEXTURE_1D, 0, 0, bins, GL_RED, GL_FLOAT, scaled_buf.data());
2887 glBindTexture(GL_TEXTURE_1D, 0);
2888 }
2889
2890 /**
2891 * @brief Bind the spectrum texture to a specific texture unit.
2892 *
2893 * The caller passes a unit index (e.g. 9) and the corresponding
2894 * `GL_TEXTURE9` unit is activated. After this call the shader
2895 * uniform `spectrum` should be set to the same unit index via
2896 * `glUniform1i(loc, unit)`.
2897 *
2898 * @param unit Texture unit index (0-based). Default is 9.
2899 */
2900 void bind(int unit = SPECTRUM_TEXTURE_UNIT) const {
2901 glActiveTexture(GL_TEXTURE0 + unit);
2902 glBindTexture(GL_TEXTURE_1D, textureID);
2903 }
2904
2905 /**
2906 * @brief Delete the OpenGL texture and reset state.
2907 *
2908 * Safe to call more than once — the texture ID is checked before
2909 * deletion and zeroed afterwards.
2910 */
2911 void cleanup() {
2912 if (textureID) {
2913 glDeleteTextures(1, &textureID);
2914 textureID = 0;
2915 }
2916 }
2917
2919
2920 /// Texture unit reserved for the spectrum (units 0–8 are taken).
2921 static constexpr int SPECTRUM_TEXTURE_UNIT = 9;
2922
2923 private:
2925 GLuint textureID = 0; ///< OpenGL name for the 1D texture.
2926 int bins = 0; ///< Number of texels (== FFT_SIZE / 2).
2927 std::vector<float> scaled_buf; ///< Scratch buffer for sensitivity-scaled magnitudes.
2928};
2929
2930/**
2931 * @class SpectrumHistory
2932 * @brief Runtime-sized ring buffer stored in one 1D array texture.
2933 *
2934 * Enabled via the `--enable-audio-buffers <N>` CLI option. Allocates one
2935 * `GL_TEXTURE_1D_ARRAY` with @p N `GL_R32F` layers and exposes it to GLSL as
2936 * `uniform sampler1DArray spectrum_history;`. The requested layer count is
2937 * clamped only to the active GPU's `GL_MAX_ARRAY_TEXTURE_LAYERS` limit.
2938 *
2939 * Each call to `update()` writes the current FFT magnitudes into the
2940 * layer at the ring head and advances the head. No texture data is copied
2941 * between layers. Shaders use `spectrum_history_head` and
2942 * `spectrum_history_size` to convert a logical age into a physical layer.
2943 *
2944 * The complete array uses only texture unit 10. Unit 9 remains the live
2945 * `spectrum`/`spectrum0` texture.
2946 */
2948 public:
2950
2951 /// Texture unit assigned to the complete spectrum history array.
2952 static constexpr int TEXTURE_UNIT = 10;
2953
2954 /**
2955 * @brief Allocate one array texture with @p requested_count history layers.
2956 *
2957 * @param requested_count Number of requested history frames.
2958 * @return The allocated layer count after applying the GPU limit.
2959 */
2960 int init(int requested_count) {
2961 cleanup();
2962 if (requested_count <= 0)
2963 return 0;
2964
2965 GLint max_layers = 0;
2966 glGetIntegerv(GL_MAX_ARRAY_TEXTURE_LAYERS, &max_layers);
2967 if (max_layers <= 0) {
2968 mx::system_err << "acmx2: GL_TEXTURE_1D_ARRAY is unavailable on this context\n";
2969 return 0;
2970 }
2971 layer_count = std::min(requested_count, static_cast<int>(max_layers));
2972 if (layer_count != requested_count) {
2973 mx::system_err << "acmx2: --enable-audio-buffers clamped to GPU "
2974 "array-layer limit "
2975 << layer_count << " (was " << requested_count << ")\n";
2976 }
2977
2978 bins = FFT_SIZE / 2;
2979 std::vector<float> zeros(static_cast<size_t>(bins) * static_cast<size_t>(layer_count), 0.0f);
2980 glGenTextures(1, &texture_id);
2981 glBindTexture(GL_TEXTURE_1D_ARRAY, texture_id);
2982 glTexImage2D(GL_TEXTURE_1D_ARRAY, 0, GL_R32F, bins, layer_count, 0, GL_RED, GL_FLOAT, zeros.data());
2983 glTexParameteri(GL_TEXTURE_1D_ARRAY, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
2984 glTexParameteri(GL_TEXTURE_1D_ARRAY, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
2985 glTexParameteri(GL_TEXTURE_1D_ARRAY, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
2986 glBindTexture(GL_TEXTURE_1D_ARRAY, 0);
2987 write_idx = 0;
2988 return layer_count;
2989 }
2990
2991 /**
2992 * @brief Push the current FFT magnitudes into the head of the ring buffer.
2993 *
2994 * Reuses magnitudes already computed by `SpectrumTexture::update()` for
2995 * the same frame (caller must invoke that first). When @p scale is
2996 * non-zero, magnitudes are multiplied by it (matching the live
2997 * spectrum's sensitivity scaling).
2998 */
2999 void update(float scale = 0.0f) {
3000 if (texture_id == 0 || layer_count == 0)
3001 return;
3002 const auto &mags = analyzer.spectrum();
3003 const float *src = mags.data();
3004 if (scale != 0.0f) {
3005 scaled_buf.resize(mags.size());
3006 for (size_t i = 0; i < mags.size(); ++i)
3007 scaled_buf[i] = mags[i] * scale;
3008 src = scaled_buf.data();
3009 }
3010 glBindTexture(GL_TEXTURE_1D_ARRAY, texture_id);
3011 glTexSubImage2D(GL_TEXTURE_1D_ARRAY, 0, 0, write_idx, bins, 1, GL_RED, GL_FLOAT, src);
3012 glBindTexture(GL_TEXTURE_1D_ARRAY, 0);
3014 }
3015
3016 /// Bind the complete spectrum history array to its single texture unit.
3017 void bind() const {
3018 if (texture_id == 0)
3019 return;
3020 glActiveTexture(GL_TEXTURE0 + TEXTURE_UNIT);
3021 glBindTexture(GL_TEXTURE_1D_ARRAY, texture_id);
3022 glActiveTexture(GL_TEXTURE0);
3023 }
3024
3025 /// Physical layer containing the newest spectrum frame.
3026 int newestLayer() const { return layer_count > 0 ? (write_idx - 1 + layer_count) % layer_count : 0; }
3027
3028 /// Number of history layers currently allocated.
3029 int count() const { return layer_count; }
3030
3031 /// Delete the array texture and reset state.
3032 void cleanup() {
3033 if (texture_id != 0) {
3034 glDeleteTextures(1, &texture_id);
3035 texture_id = 0;
3036 }
3037 layer_count = 0;
3038 write_idx = 0;
3039 }
3040
3042
3043 private:
3045 GLuint texture_id = 0; ///< One `GL_TEXTURE_1D_ARRAY` object.
3046 int bins = 0; ///< Texels per layer (== FFT_SIZE / 2).
3047 int layer_count = 0; ///< Runtime-selected array depth.
3048 int write_idx = 0; ///< Next physical layer to overwrite.
3049 std::vector<float> scaled_buf; ///< Scratch for sensitivity-scaled magnitudes.
3050};
3051#endif // AUDIO_ENABLED
3052
3053/**
3054 * @class ShaderLibrary
3055 * @brief Manages the complete collection of compiled GLSL shader programs.
3056 *
3057 * Responsibilities:
3058 * - Load a single shader or a full library from JSON or text manifests.
3059 * - Optionally build and restore a binary shader cache for fast startup.
3060 * - Track and upload all shader uniforms each frame (time, mouse, audio, etc.).
3061 * - Support dual-mode (2D + 3D vertex shader) compilation.
3062 * - Provide navigation (inc/dec/setIndex) and bypass controls.
3063 *
3064 * acidcamGL-compatible uniforms (value_alpha_r/g/b, optx, random_var, etc.)
3065 * are maintained here so legacy shaders work unmodified.
3066 */
3068 float alpha = 0.1f;
3069 bool time_active = true;
3070 float time_f = 1.0;
3071 float time_speed = 1.0f;
3072 bool normalized_time = false;
3073 double video_fps = 0.0;
3074#ifdef AUDIO_ENABLED
3076 int audio_buffer_count = 0; ///< Runtime depth of the spectrum history array.
3077 int spectrum_history_head = 0; ///< Physical layer containing the newest spectrum.
3078 float audio_warmup_envelope = 1.0f; ///< Startup ramp [0..1] used to soften initial audio-reactive intensity.
3079#endif
3080#ifdef MIDI_ENABLED
3081 float midi_slider[4] = {0.0f, 0.0f, 0.0f, 0.0f}; ///< MIDI CC slider values (0.0–1.0) for shader uniforms slider1–slider4.
3082#endif
3083 bool is3d = false;
3084 bool dual_mode = false;
3085
3086 // acidcamGL-compatible uniform state
3087 float color_alpha_r = 0.1f;
3088 float color_alpha_g = 0.2f;
3089 float color_alpha_b = 0.3f;
3090 bool alpha_dir = true;
3091 glm::vec4 optx = glm::vec4(0.5f, 0.5f, 0.5f, 0.5f);
3092 glm::vec4 random_var = glm::vec4(0.0f);
3093 glm::vec4 inc_value = glm::vec4(0.0f);
3094 glm::vec4 inc_valuex = glm::vec4(0.0f);
3095 bool restore_black = false;
3096 std::vector<ShaderManifestData::CustomUniform> custom_uniforms;
3097 std::vector<std::regex> custom_uniform_declarations;
3098 std::vector<std::regex> custom_uniform_references;
3099
3100 /**
3101 * @struct ProgramData
3102 * @brief Cached uniform locations for a single compiled shader program.
3103 *
3104 * Querying glGetUniformLocation every frame is expensive; this struct
3105 * stores all locations once at compile time.
3106 */
3108 std::string name;
3109 GLint compute_work_group_size[3] = {1, 1, 1};
3110 GLint loc = -1, iTime = -1, iMouse = -1, time_f = -1, iResolution = -1;
3111#ifdef AUDIO_ENABLED
3112 GLint amp = -1, amp_untouched = -1;
3113 GLint iamp = -1;
3114 GLint amp_peak = -1, amp_rms = -1, amp_smooth = -1;
3115 GLint amp_low = -1, amp_mid = -1, amp_high = -1;
3116 GLint spectrum_loc = -1; ///< Live `sampler1D spectrum`.
3117 GLint spectrum_zero_loc = -1; ///< Current-frame `spectrum0` alias.
3118 GLint spectrum_history_loc = -1; ///< `sampler1DArray spectrum_history`.
3119 GLint spectrum_history_head_loc = -1; ///< Newest physical array layer.
3120 GLint spectrum_history_size_loc = -1; ///< Runtime array depth.
3121#endif
3122 GLint texture_cache_loc[8] = {-1, -1, -1, -1, -1, -1, -1, -1};
3123 // Array-form alias of texture_cache_loc: locations of `textures[0]..textures[N-1]`
3124 // for shaders that declare `uniform sampler2D textures[SIZE];`. Aliases the same
3125 // texture units as samp1..samp8 (for the first 8 entries) — no extra binds or
3126 // copies needed. Sized at runtime to match the active --texture-cache-size so
3127 // shaders can scale beyond the legacy 8-frame ceiling.
3128 std::vector<GLint> texture_array_loc;
3129 GLint texture_array_base_loc = -1; ///< Base location of `textures[0]` for bulk glUniform1iv assignment.
3130 GLint history_loc = -1; ///< Location of optional `uniform sampler2DArray history;`.
3131 GLint history_head_loc = -1; ///< Location of the array ring's oldest physical layer.
3132 GLint iFrame = -1;
3133 GLint iTimeDelta = -1;
3134 GLint iDate = -1;
3135 GLint iChannelTime[4] = {-1, -1, -1, -1};
3136 GLint iChannelResolution[4] = {-1, -1, -1, -1};
3137 GLint iSampleRate = -1;
3138 GLint iFrameRate = -1;
3139 GLint iMouseClick = -1;
3140 // acidcamGL-compatible uniform locations
3142 GLint alpha_r_loc = -1, alpha_g_loc = -1, alpha_b_loc = -1;
3143 GLint alpha_value = -1;
3144 GLint index_value = -1;
3145 GLint optx_loc = -1;
3146 GLint random_var_loc = -1;
3148 GLint inc_value_loc = -1;
3149 GLint inc_valuex_loc = -1;
3150 GLint time_speed_loc = -1;
3151 std::vector<GLint> custom_uniform_locs;
3152#ifdef MIDI_ENABLED
3153 GLint slider_loc[4] = {-1, -1, -1, -1}; ///< Locations of optional `uniform float slider1..slider4;`
3154#endif
3155 };
3156 size_t library_index = 0;
3157 bool use_cache = false;
3158
3159 /**
3160 * @brief Resolve the on-disk path for the shader binary cache file.
3161 *
3162 * The cache is preferentially stored under the assets directory (`--path`)
3163 * so users can point multiple read-only shader libraries at a single
3164 * writable cache location. If the assets directory is not writable we
3165 * fall back to placing the cache directly inside the shader library
3166 * directory (legacy behaviour).
3167 *
3168 * The filename is derived from a hash of the absolute, normalised
3169 * library path so different libraries do not collide.
3170 *
3171 * @param assets_path Assets directory passed via `--path` (may be empty).
3172 * @param library_path Shader library directory containing a supported manifest.
3173 * @param cache_size Active texture-cache size used to key binary-cache compatibility.
3174 * @param use_array Whether texture history uses a sampler array; included in the cache key.
3175 * @return Absolute or relative path to the shader cache file.
3176 */
3177 static std::string shaderCacheFilePath(const std::string &assets_path, const std::string &library_path, int cache_size = 8, bool use_array = false) {
3178 std::error_code ec;
3179 std::filesystem::path lib(library_path);
3180 std::filesystem::path abs_lib = std::filesystem::absolute(lib, ec);
3181 std::string key = ec ? library_path : abs_lib.lexically_normal().string();
3182 // Append cache_size to the hash key so each --texture-cache-size value
3183 // gets its own cache file. Shader binaries compiled with `#define SIZE N`
3184 // for one N must not be reused for a different N — the sampler array
3185 // declaration would mismatch the linked program.
3186 key += "|s=" + std::to_string(cache_size);
3187 key += "|a=" + std::to_string(use_array ? 1 : 0);
3188 std::hash<std::string> hasher;
3189 std::ostringstream name_stream;
3190 name_stream << ".shader_cache_" << std::hex << hasher(key);
3191 const std::string filename = name_stream.str();
3192
3193 auto isWritable = [](const std::filesystem::path &dir) {
3194 std::error_code e;
3195 if (dir.empty())
3196 return false;
3197 if (!std::filesystem::exists(dir, e)) {
3198 std::filesystem::create_directories(dir, e);
3199 if (e)
3200 return false;
3201 }
3202 // Probe writability by creating a temp file.
3203 std::filesystem::path probe = dir / ".acmx2_write_probe";
3204 std::ofstream f(probe, std::ios::binary);
3205 bool ok = f.is_open();
3206 f.close();
3207 if (ok)
3208 std::filesystem::remove(probe, e);
3209 return ok;
3210 };
3211
3212 std::filesystem::path assets(assets_path);
3213 std::filesystem::path libdir(library_path);
3214
3215 // Prefer existing cache in assets; otherwise existing cache in lib dir.
3216 std::filesystem::path assets_cache = assets.empty() ? std::filesystem::path() : assets / filename;
3217 std::filesystem::path lib_cache = libdir / filename;
3218 if (!assets.empty() && std::filesystem::exists(assets_cache, ec)) {
3219 return assets_cache.string();
3220 }
3221 if (std::filesystem::exists(lib_cache, ec)) {
3222 return lib_cache.string();
3223 }
3224 // No existing cache; pick a writable location, preferring assets.
3225 if (!assets.empty() && isWritable(assets)) {
3226 return assets_cache.string();
3227 }
3228 if (isWritable(libdir)) {
3229 return lib_cache.string();
3230 }
3231 // Last resort: return assets path even if it isn't writable so the
3232 // caller can surface a meaningful error.
3233 return assets.empty() ? lib_cache.string() : assets_cache.string();
3234 }
3235
3236 /**
3237 * @brief Active runtime size of the texture-cache ring buffer.
3238 *
3239 * Set via setCacheSize() before any program load; defaults to 8 to
3240 * preserve the legacy samp1..samp8 binding behaviour. Used to:
3241 * - inject `#define SIZE N` into fragment sources at compile time
3242 * so `uniform sampler2D textures[SIZE];` declarations resolve to
3243 * the right array length;
3244 * - size the per-program `texture_array_loc` lookup vector;
3245 * - bound the runtime binding loop in the cache-render path.
3246 */
3247 int cache_size = 8;
3248 bool use_history_array = false;
3250
3251 /**
3252 * @brief Read a fragment shader file and inject `#define SIZE N`
3253 * immediately after its `#version` directive.
3254 *
3255 * GLSL sampler array sizes must be compile-time constants, so the
3256 * cache-frame count has to be baked into the source string before
3257 * `glShaderSource`. This helper preserves the original source line
3258 * numbering as best it can: the inserted line replaces no existing
3259 * line, only shifts everything after `#version` down by one.
3260 *
3261 * If the file cannot be opened the returned string is empty (the
3262 * caller falls through to the file-based loader, which surfaces the
3263 * proper error).
3264 *
3265 * @param frag_path Filesystem path to the fragment .glsl source.
3266 * @param size Cache ring length to bake as `SIZE`.
3267 * @param use_array Whether to inject the texture-array compatibility define.
3268 * @return Modified fragment source, or empty string on read failure.
3269 */
3270 std::string injectShaderSize(const std::string &frag_path, int size, bool use_array) const {
3271 std::ifstream in(frag_path);
3272 if (!in.is_open())
3273 return {};
3274 std::ostringstream ss;
3275 ss << in.rdbuf();
3276 std::string src = ss.str();
3277 // Locate the first `#version` directive and insert after its newline.
3278 std::size_t v_pos = src.find("#version");
3279 std::size_t insert_pos = 0;
3280 if (v_pos != std::string::npos) {
3281 std::size_t nl = src.find('\n', v_pos);
3282 insert_pos = (nl == std::string::npos) ? src.size() : nl + 1;
3283 }
3284 std::string define = "#define SIZE " + std::to_string(size) + "\n" + "#define USE_HISTORY_TEXTURE_ARRAY " + std::to_string(use_array ? 1 : 0) + "\n";
3285 std::string sourceWithoutComments;
3286 sourceWithoutComments.reserve(src.size());
3287 bool lineComment = false;
3288 bool blockComment = false;
3289 for (std::size_t i = 0; i < src.size(); ++i) {
3290 if (lineComment) {
3291 if (src[i] == '\n') {
3292 lineComment = false;
3293 sourceWithoutComments.push_back('\n');
3294 } else {
3295 sourceWithoutComments.push_back(' ');
3296 }
3297 continue;
3298 }
3299 if (blockComment) {
3300 if (src[i] == '*' && i + 1 < src.size() && src[i + 1] == '/') {
3301 sourceWithoutComments.append(" ");
3302 ++i;
3303 blockComment = false;
3304 } else {
3305 sourceWithoutComments.push_back(src[i] == '\n' ? '\n' : ' ');
3306 }
3307 continue;
3308 }
3309 if (src[i] == '/' && i + 1 < src.size() && src[i + 1] == '/') {
3310 sourceWithoutComments.append(" ");
3311 ++i;
3312 lineComment = true;
3313 } else if (src[i] == '/' && i + 1 < src.size() && src[i + 1] == '*') {
3314 sourceWithoutComments.append(" ");
3315 ++i;
3316 blockComment = true;
3317 } else {
3318 sourceWithoutComments.push_back(src[i]);
3319 }
3320 }
3321 for (std::size_t i = 0; i < custom_uniforms.size(); ++i) {
3322 const bool referenced = i < custom_uniform_references.size() && std::regex_search(sourceWithoutComments, custom_uniform_references[i]);
3323 const bool declared = i < custom_uniform_declarations.size() && std::regex_search(sourceWithoutComments, custom_uniform_declarations[i]);
3324 if (referenced && !declared) {
3325 define += "uniform float " + custom_uniforms[i].name + ";\n";
3326 }
3327 }
3328 src.insert(insert_pos, define);
3329 return src;
3330 }
3331
3332 uint64_t preparedFragmentHash(const std::string &frag_path) const {
3333 const std::string source = injectShaderSize(frag_path, cache_size, use_history_array);
3334 if (source.empty())
3335 return 0;
3336 uint64_t hash = 1469598103934665603ull;
3337 for (unsigned char byte : source) {
3338 hash ^= byte;
3339 hash *= 1099511628211ull;
3340 }
3341 return hash;
3342 }
3343
3344 /**
3345 * @brief Compile a shader from file pair, injecting `#define SIZE N`
3346 * into the fragment source. Falls back to the plain file-based
3347 * loader if the fragment cannot be read in-process.
3348 *
3349 * The vertex source is not modified — SIZE is only meaningful for
3350 * fragment-side cache shaders.
3351 *
3352 * @return true if the program compiled and linked successfully.
3353 */
3354 bool loadProgramWithSize(gl::ShaderProgram *prog, const std::string &vert_path, const std::string &frag_path) const {
3355 std::string frag_src = injectShaderSize(frag_path, cache_size, use_history_array);
3356 if (frag_src.empty()) {
3357 // Could not read the fragment file; fall back so the loader can
3358 // produce its own diagnostic.
3359 return prog->loadProgram(vert_path, frag_path);
3360 }
3361 std::ifstream vin(vert_path);
3362 if (!vin.is_open()) {
3363 return prog->loadProgram(vert_path, frag_path);
3364 }
3365 std::ostringstream vss;
3366 vss << vin.rdbuf();
3367 return prog->loadProgramFromText(vss.str(), frag_src);
3368 }
3369
3370 std::vector<std::unique_ptr<gl::ShaderProgram>> programs_2d;
3371 std::vector<std::unique_ptr<gl::ShaderProgram>> programs_3d;
3372 std::vector<ShaderProgramKind> program_kinds;
3373 bool time_audio = false;
3374 bool audio_delta = false;
3375 std::unordered_map<int, ProgramData> program_names_2d;
3376 std::unordered_map<int, ProgramData> program_names_3d;
3377 bool shader_bypass = false;
3378 bool isDraggingLeft = false;
3379 bool isDraggingRight = false;
3380 bool wasClicked = false;
3381 float clickStartX = 0.0f;
3382 float clickStartY = 0.0f;
3383 float lastClickX = 0.0f;
3384 float lastClickY = 0.0f;
3385
3386 std::unique_ptr<gl::ShaderProgram> makeProgram() {
3387 if (use_cache)
3388 return std::make_unique<ac::ShaderProgram>();
3389 return std::make_unique<gl::ShaderProgram>();
3390 }
3391
3392 /** Compile and link a standalone OpenGL compute program. */
3393 std::unique_ptr<gl::ShaderProgram> makeComputeProgram(const std::string &compute_path, std::string &error) const {
3395 error = "OpenGL 4.3 compute shaders are unavailable";
3396 return {};
3397 }
3398
3399 const std::string source = injectShaderSize(compute_path, cache_size, use_history_array);
3400 if (source.empty()) {
3401 error = "Could not read compute shader: " + compute_path;
3402 return {};
3403 }
3404
3405 if (use_cache) {
3406 GLuint cached_program = 0;
3407 if (ac::loadComputeProgramBinaryFromCache(source, cached_program)) {
3408 mx::system_out << "acmx2: Loaded cached compute shader: " << compute_path << "\n";
3409 return std::make_unique<gl::ShaderProgram>(cached_program);
3410 }
3411 }
3412
3413 const GLuint shader = glCreateShader(GL_COMPUTE_SHADER);
3414 if (shader == 0) {
3415 error = "Could not create compute shader object";
3416 return {};
3417 }
3418 const char *source_ptr = source.c_str();
3419 glShaderSource(shader, 1, &source_ptr, nullptr);
3420 glCompileShader(shader);
3421 GLint compiled = GL_FALSE;
3422 glGetShaderiv(shader, GL_COMPILE_STATUS, &compiled);
3423 if (compiled != GL_TRUE) {
3424 error = "Compute shader compilation failed: " + compute_path;
3425 const std::string log = shaderInfoLog(shader);
3426 if (!log.empty())
3427 error += "\n" + log;
3428 glDeleteShader(shader);
3429 return {};
3430 }
3431
3432 const GLuint program = glCreateProgram();
3433 if (program == 0) {
3434 glDeleteShader(shader);
3435 error = "Could not create compute program";
3436 return {};
3437 }
3438 glAttachShader(program, shader);
3439#if !defined(__APPLE__)
3440 if (glProgramParameteri != nullptr) {
3441 glProgramParameteri(program, GL_PROGRAM_BINARY_RETRIEVABLE_HINT, GL_TRUE);
3442 }
3443#endif
3444 glLinkProgram(program);
3445 GLint linked = GL_FALSE;
3446 glGetProgramiv(program, GL_LINK_STATUS, &linked);
3447 if (linked != GL_TRUE) {
3448 error = "Compute program link failed: " + compute_path;
3449 const std::string log = programInfoLog(program);
3450 if (!log.empty())
3451 error += "\n" + log;
3452 glDeleteProgram(program);
3453 glDeleteShader(shader);
3454 return {};
3455 }
3456
3457 glDetachShader(program, shader);
3458 glDeleteShader(shader);
3459 if (use_cache)
3460 ac::saveComputeProgramBinaryToCache(source, program);
3461 error.clear();
3462 return std::make_unique<gl::ShaderProgram>(program);
3463 }
3464
3465 static std::string shaderInfoLog(GLuint shader) {
3466 GLint length = 0;
3467 glGetShaderiv(shader, GL_INFO_LOG_LENGTH, &length);
3468 if (length <= 1)
3469 return {};
3470 std::vector<char> buffer(static_cast<std::size_t>(length), '\0');
3471 GLsizei written = 0;
3472 glGetShaderInfoLog(shader, length, &written, buffer.data());
3473 std::size_t used = static_cast<std::size_t>(std::max<GLsizei>(written, 0));
3474 while (used > 0 && buffer[used - 1] == '\0')
3475 --used;
3476 return std::string(buffer.data(), used);
3477 }
3478
3479 static std::string programInfoLog(GLuint program) {
3480 GLint length = 0;
3481 glGetProgramiv(program, GL_INFO_LOG_LENGTH, &length);
3482 if (length <= 1)
3483 return {};
3484 std::vector<char> buffer(static_cast<std::size_t>(length), '\0');
3485 GLsizei written = 0;
3486 glGetProgramInfoLog(program, length, &written, buffer.data());
3487 std::size_t used = static_cast<std::size_t>(std::max<GLsizei>(written, 0));
3488 while (used > 0 && buffer[used - 1] == '\0')
3489 --used;
3490 return std::string(buffer.data(), used);
3491 }
3492
3493 /** Compile a replacement program while retaining complete driver diagnostics. */
3494 std::unique_ptr<gl::ShaderProgram> compileProgramForReload(const std::string &vert_path, const std::string &frag_path, std::string &error) const {
3495 if (isComputeShaderFile(frag_path))
3496 return makeComputeProgram(frag_path, error);
3497 std::ifstream vertex_file(vert_path);
3498 if (!vertex_file.is_open()) {
3499 error = "Could not read vertex shader: " + vert_path;
3500 return {};
3501 }
3502 std::ostringstream vertex_stream;
3503 vertex_stream << vertex_file.rdbuf();
3504 const std::string vertex_source = vertex_stream.str();
3505 const std::string fragment_source = injectShaderSize(frag_path, cache_size, use_history_array);
3506 if (fragment_source.empty()) {
3507 error = "Could not read fragment shader: " + frag_path;
3508 return {};
3509 }
3510
3511 const auto compile_stage = [&error](GLenum type, const std::string &source, const std::string &label) -> GLuint {
3512 const GLuint shader = glCreateShader(type);
3513 if (shader == 0) {
3514 error = "Could not create " + label + " shader object";
3515 return 0;
3516 }
3517 const char *source_ptr = source.c_str();
3518 glShaderSource(shader, 1, &source_ptr, nullptr);
3519 glCompileShader(shader);
3520 GLint compiled = GL_FALSE;
3521 glGetShaderiv(shader, GL_COMPILE_STATUS, &compiled);
3522 if (compiled != GL_TRUE) {
3523 error = label + " shader compilation failed";
3524 const std::string log = shaderInfoLog(shader);
3525 if (!log.empty())
3526 error += ":\n" + log;
3527 glDeleteShader(shader);
3528 return 0;
3529 }
3530 return shader;
3531 };
3532
3533 const GLuint vertex_shader = compile_stage(GL_VERTEX_SHADER, vertex_source, vert_path);
3534 if (vertex_shader == 0)
3535 return {};
3536 const GLuint fragment_shader = compile_stage(GL_FRAGMENT_SHADER, fragment_source, frag_path);
3537 if (fragment_shader == 0) {
3538 glDeleteShader(vertex_shader);
3539 return {};
3540 }
3541
3542 const GLuint program = glCreateProgram();
3543 if (program == 0) {
3544 glDeleteShader(vertex_shader);
3545 glDeleteShader(fragment_shader);
3546 error = "Could not create shader program";
3547 return {};
3548 }
3549 glAttachShader(program, vertex_shader);
3550 glAttachShader(program, fragment_shader);
3551 glLinkProgram(program);
3552 GLint linked = GL_FALSE;
3553 glGetProgramiv(program, GL_LINK_STATUS, &linked);
3554 if (linked != GL_TRUE) {
3555 error = "Shader program link failed";
3556 const std::string log = programInfoLog(program);
3557 if (!log.empty())
3558 error += ":\n" + log;
3559 glDeleteProgram(program);
3560 glDeleteShader(vertex_shader);
3561 glDeleteShader(fragment_shader);
3562 return {};
3563 }
3564
3565 glDetachShader(program, vertex_shader);
3566 glDetachShader(program, fragment_shader);
3567 glDeleteShader(vertex_shader);
3568 glDeleteShader(fragment_shader);
3569 error.clear();
3570 return std::make_unique<gl::ShaderProgram>(program);
3571 }
3572
3573 /**
3574 * @brief Compile a minimal passthrough fragment shader as a stand-in.
3575 *
3576 * Used when a shader in the library fails to compile (either during
3577 * cache-build or source-compile). A placeholder program is inserted
3578 * at the failing shader's index so that numeric indices remain
3579 * aligned with the on-disk shader manifest — this keeps
3580 * user-selected indices (e.g. from the Qt interface or CLI
3581 * --shader-index) pointing at the intended slot even when one or
3582 * more shaders in the library are broken.
3583 *
3584 * The fragment shader samples the input frame texture `samp` and
3585 * writes it unchanged, so the placeholder renders as a no-op
3586 * passthrough instead of causing a hard error.
3587 *
3588 * @param vert_path Path to the vertex shader to pair with the passthrough.
3589 * @return A compiled ShaderProgram, or an empty unique_ptr on failure.
3590 */
3591 std::unique_ptr<gl::ShaderProgram> makePassthroughProgram(const std::string &vert_path) {
3592 static constexpr const char *kPassthroughFrag = "#version 330 core\n"
3593 "in vec2 tc;\n"
3594 "out vec4 color;\n"
3595 "uniform sampler2D samp;\n"
3596 "void main() {\n"
3597 " color = texture(samp, tc);\n"
3598 "}\n";
3599
3600 std::ifstream vf(vert_path);
3601 if (!vf.is_open())
3602 return {};
3603 std::stringstream vss;
3604 vss << vf.rdbuf();
3605 std::string vert_source = vss.str();
3606
3607 auto prog = makeProgram();
3608 prog->setSilent(true);
3609 if (!prog->loadProgramFromText(vert_source, kPassthroughFrag)) {
3610 return {};
3611 }
3612 return prog;
3613 }
3614
3615 public:
3616#ifdef AUDIO_ENABLED
3617 explicit ShaderLibrary(const acmx2::audio::AudioAnalyzer *analyzer = nullptr) : audio_analyzer(analyzer) {}
3618#else
3619 ShaderLibrary() = default;
3620#endif
3621
3622 void setCustomUniformValues(const std::vector<ShaderManifestData::CustomUniform> &uniforms) {
3623 const bool namesChanged = custom_uniforms.size() != uniforms.size() || !std::equal(custom_uniforms.begin(), custom_uniforms.end(), uniforms.begin(), [](const auto &left, const auto &right) { return left.name == right.name; });
3624 custom_uniforms = uniforms;
3625 if (!namesChanged)
3626 return;
3631 for (const auto &uniform : custom_uniforms) {
3632 custom_uniform_declarations.emplace_back("\\buniform\\s+(?:(?:lowp|mediump|highp)\\s+)?float\\s+" + uniform.name + "\\b");
3633 custom_uniform_references.emplace_back("\\b" + uniform.name + "\\b");
3634 }
3635 const auto refreshLocations = [this](auto &names, const auto &programs) {
3636 for (auto &[index, data] : names) {
3637 data.custom_uniform_locs.assign(custom_uniforms.size(), -1);
3638 if (index < 0 || static_cast<std::size_t>(index) >= programs.size())
3639 continue;
3640 for (std::size_t i = 0; i < custom_uniforms.size(); ++i) {
3641 data.custom_uniform_locs[i] = glGetUniformLocation(programs[static_cast<std::size_t>(index)]->id(), custom_uniforms[i].name.c_str());
3642 }
3643 }
3644 };
3645 refreshLocations(program_names_2d, programs_2d);
3646 refreshLocations(program_names_3d, programs_3d);
3647 }
3649
3650#ifdef MIDI_ENABLED
3651 /// Set a MIDI slider value (index 0–3, value 0.0–1.0).
3652 void setMidiSlider(int idx, float val) {
3653 if (idx >= 0 && idx < 4)
3654 midi_slider[idx] = val;
3655 }
3656#endif
3657
3658 /**
3659 * @brief Enable or disable use of the ac::ShaderProgram binary-cache wrapper.
3660 *
3661 * When enabled, makeProgram() returns an ac::ShaderProgram instead of
3662 * the base gl::ShaderProgram, allowing shader binaries to be loaded
3663 * from the on-disk cache rather than recompiled from source.
3664 *
3665 * @param enable True to use the caching wrapper.
3666 */
3667 void enableCache(bool enable) { use_cache = enable; }
3668
3669 /**
3670 * @brief Set the active texture-cache ring length.
3671 *
3672 * Must be called before loadPrograms / loadProgramsWithCache so the
3673 * value is in effect when shaders are compiled (it is baked into the
3674 * fragment source as `#define SIZE N`) and when uniform locations are
3675 * queried (it sizes the per-program `texture_array_loc` lookup).
3676 *
3677 * Values outside [1, 64] are clamped silently to keep us under any
3678 * reasonable `GL_MAX_TEXTURE_IMAGE_UNITS` minimum (driver minimum for
3679 * GL 3.3 core is 16; the runtime caller already clamps to 1..64).
3680 *
3681 * @param size Ring buffer size in frames.
3682 */
3683 void setCacheSize(int size) {
3684 if (size < 1)
3685 size = 1;
3686 if (size > 64)
3687 size = 64;
3688 cache_size = size;
3689 }
3690
3691 /// @brief Active cache ring size as seen by the shader-compile path.
3692 int cacheSize() const { return cache_size; }
3693
3694 /**
3695 * @brief Select the cache sampler representation before compiling shaders.
3696 *
3697 * Array mode binds a single `sampler2DArray history` at texture unit 1.
3698 * Legacy mode binds `samp1..samp8` and `textures[SIZE]` to units 1..SIZE.
3699 */
3700 void setHistoryTextureArray(bool enabled) { use_history_array = enabled; }
3701
3702 /**
3703 * @brief Remove all compiled shader programs and reset the library index.
3704 *
3705 * Releases every unique_ptr in both the 2D and 3D program vectors,
3706 * clears the associated uniform-location maps, and resets the
3707 * current library_index to zero. Called before rebuilding the
3708 * shader cache when a count or hash mismatch is detected.
3709 */
3710 void clear() {
3711 programs_2d.clear();
3712 programs_3d.clear();
3713 program_kinds.clear();
3714 program_names_2d.clear();
3715 program_names_3d.clear();
3716 library_index = 0;
3717 }
3718
3719 /**
3720 * @brief Compile a single fragment shader with the 2D (and optionally 3D) vertex shader.
3721 *
3722 * Loads `data/vert.glsl` (2D) and optionally `data/vertex.glsl` (3D)
3723 * paired with the supplied fragment shader path. After compilation
3724 * the shader's uniform locations are cached via setupProgramUniforms().
3725 *
3726 * @param win Pointer to the GL window (provides asset path resolution).
3727 * @param text Full path to the fragment shader source file.
3728 * @throws mx::Exception if either compile/link stage fails.
3729 */
3730 void loadProgram(gl::GLWindow *win, const std::string text) {
3731 const bool is_compute = isComputeShaderFile(text);
3732 if (is_compute && compute_shader_supported) {
3733 std::string error;
3734 auto compute_program = makeComputeProgram(text, error);
3735 if (!compute_program)
3736 throw mx::Exception(error);
3737 programs_2d.push_back(std::move(compute_program));
3739 } else {
3740 programs_2d.push_back(makeProgram());
3741 if (!is_compute && !loadProgramWithSize(programs_2d.back().get(), win->util.getFilePath("data/vert.glsl"), text)) {
3742 throw mx::Exception("Error loading 2D shader program: " + text);
3743 }
3744 if (is_compute) {
3745 programs_2d.pop_back();
3746 auto passthrough = makePassthroughProgram(win->util.getFilePath("data/vert.glsl"));
3747 if (!passthrough)
3748 throw mx::Exception("Could not create compute fallback: " + text);
3749 programs_2d.push_back(std::move(passthrough));
3751 mx::system_out << "acmx2: Skipping compute shader on this context: " << text << "\n";
3752 } else {
3754 }
3755 }
3756 setupProgramUniforms(win, programs_2d.back().get(), program_names_2d, programs_2d.size() - 1, text);
3757 if (dual_mode) {
3758 if (is_compute) {
3759 auto passthrough = makePassthroughProgram(win->util.getFilePath("data/vertex.glsl"));
3760 if (!passthrough)
3761 throw mx::Exception("Could not create 3D compute passthrough: " + text);
3762 programs_3d.push_back(std::move(passthrough));
3763 } else {
3764 programs_3d.push_back(makeProgram());
3765 if (!loadProgramWithSize(programs_3d.back().get(), win->util.getFilePath("data/vertex.glsl"), text)) {
3766 throw mx::Exception("Error loading 3D shader program: " + text);
3767 }
3768 }
3769 setupProgramUniforms(win, programs_3d.back().get(), program_names_3d, programs_3d.size() - 1, text);
3770 mx::system_out << "acmx2: Compiled Shader 0 (2D+3D): " << text << " ✔ \n";
3771 } else {
3772 mx::system_out << "acmx2: Compiled Shader 0 (2D): " << text << " ✔ \n";
3773 }
3774 }
3775
3776 /**
3777 * @brief Recompile one library slot and atomically replace it on success.
3778 *
3779 * Both 2D and 3D variants are compiled into temporary programs first.
3780 * The currently running programs remain installed if compilation, linking,
3781 * or uniform setup fails.
3782 */
3783 bool reloadProgram(gl::GLWindow *win, size_t shader_index, const std::string &fragment_path, std::string &error) {
3784 if (shader_index >= programs_2d.size()) {
3785 error = "Shader reload index is outside the loaded library: " + std::to_string(shader_index);
3786 return false;
3787 }
3788 if (dual_mode && shader_index >= programs_3d.size()) {
3789 error = "Shader reload index is outside the loaded 3D library: " + std::to_string(shader_index);
3790 return false;
3791 }
3792
3793 const bool is_compute = isComputeShaderFile(fragment_path);
3794 auto replacement_2d = compileProgramForReload(win->util.getFilePath("data/vert.glsl"), fragment_path, error);
3795 if (!replacement_2d)
3796 return false;
3797
3798 std::unique_ptr<gl::ShaderProgram> replacement_3d;
3799 if (dual_mode) {
3800 if (is_compute) {
3801 replacement_3d = makePassthroughProgram(win->util.getFilePath("data/vertex.glsl"));
3802 if (!replacement_3d)
3803 error = "Could not create 3D passthrough for compute shader";
3804 } else {
3805 replacement_3d = compileProgramForReload(win->util.getFilePath("data/vertex.glsl"), fragment_path, error);
3806 }
3807 if (!replacement_3d)
3808 return false;
3809 }
3810
3811 std::unordered_map<int, ProgramData> replacement_names_2d;
3812 std::unordered_map<int, ProgramData> replacement_names_3d;
3813 try {
3814 setupProgramUniforms(win, replacement_2d.get(), replacement_names_2d, shader_index, fragment_path);
3815 if (dual_mode) {
3816 setupProgramUniforms(win, replacement_3d.get(), replacement_names_3d, shader_index, fragment_path);
3817 }
3818 } catch (const std::exception &e) {
3819 error = std::string("Shader uniform setup failed: ") + e.what();
3820 shader()->useProgram();
3821 return false;
3822 } catch (...) {
3823 error = "Shader uniform setup failed with an unknown error";
3824 shader()->useProgram();
3825 return false;
3826 }
3827
3828 const int index_key = static_cast<int>(shader_index);
3829 const auto names_2d_it = replacement_names_2d.find(index_key);
3830 const auto current_names_2d_it = program_names_2d.find(index_key);
3831 if (names_2d_it == replacement_names_2d.end()) {
3832 error = "Shader reload did not produce 2D uniform metadata";
3833 shader()->useProgram();
3834 return false;
3835 }
3836 if (current_names_2d_it == program_names_2d.end()) {
3837 error = "Loaded shader is missing 2D uniform metadata";
3838 shader()->useProgram();
3839 return false;
3840 }
3841
3842 auto names_3d_it = replacement_names_3d.end();
3843 auto current_names_3d_it = program_names_3d.end();
3844 if (dual_mode) {
3845 names_3d_it = replacement_names_3d.find(index_key);
3846 current_names_3d_it = program_names_3d.find(index_key);
3847 if (names_3d_it == replacement_names_3d.end() || current_names_3d_it == program_names_3d.end()) {
3848 error = "Shader reload is missing 3D uniform metadata";
3849 shader()->useProgram();
3850 return false;
3851 }
3852 }
3853
3854 programs_2d[shader_index].swap(replacement_2d);
3855 if (shader_index < program_kinds.size()) {
3857 }
3858 std::swap(current_names_2d_it->second, names_2d_it->second);
3859 if (dual_mode) {
3860 programs_3d[shader_index].swap(replacement_3d);
3861 std::swap(current_names_3d_it->second, names_3d_it->second);
3862 }
3863 shader()->useProgram();
3864 error.clear();
3865 return true;
3866 }
3867
3868 /**
3869 * @brief Query and store all uniform locations for a compiled shader program.
3870 *
3871 * Called immediately after a program is compiled (or restored from
3872 * cache). Activates the program with `useProgram()`, then queries
3873 * locations for every known uniform—Shadertoy-compatible (iTime,
3874 * iResolution, iMouse, iFrame, iDate, iTimeDelta, iChannelTime,
3875 * iChannelResolution, iSampleRate, iFrameRate), audio reactivity
3876 * (amp, uamp, iamp, amp_peak, amp_rms, amp_smooth, amp_low/mid/high),
3877 * and acidcamGL legacy (value_alpha_r/g/b, alpha_r/g/b, alpha_value,
3878 * index_value, optx, random_var, restore_black, inc_value, inc_valuex).
3879 *
3880 * Results are stored in the ProgramData struct keyed by program
3881 * index in the provided map, avoiding per-frame glGetUniformLocation
3882 * calls.
3883 *
3884 * @param win GL window (used for error reporting).
3885 * @param prog The compiled shader program to query.
3886 * @param names Map to store the resulting ProgramData into.
3887 * @param pos Index key under which the data is stored.
3888 * @param text Fragment shader file path (stem becomes the display name).
3889 * @throws mx::Exception on any GL error after useProgram or setUniform.
3890 */
3891 void setupProgramUniforms(gl::GLWindow *win, gl::ShaderProgram *prog, std::unordered_map<int, ProgramData> &names, size_t pos, const std::string &text) {
3892 GLenum error = glGetError();
3893 if (error != GL_NO_ERROR) {
3894 throw mx::Exception("OpenGL Error: on ShaderLibary::loadProgram: " + std::to_string(error));
3895 }
3896 prog->useProgram();
3897 GLint loc = glGetUniformLocation(prog->id(), "iResolution");
3898 glUniform2f(loc, win->w, win->h);
3899 error = glGetError();
3900 if (error != GL_NO_ERROR) {
3901 throw mx::Exception("setUniform");
3902 }
3903
3904 std::filesystem::path file_path(text);
3905 std::string name = file_path.stem().string();
3906 if (!name.empty()) {
3907 names[pos].name = name;
3908 if (&names == &program_names_2d && pos < program_kinds.size() && program_kinds[pos] == ShaderProgramKind::Compute) {
3909 glGetProgramiv(prog->id(), GL_COMPUTE_WORK_GROUP_SIZE, names[pos].compute_work_group_size);
3910 }
3911 names[pos].loc = glGetUniformLocation(prog->id(), "alpha");
3912 names[pos].iTime = glGetUniformLocation(prog->id(), "iTime");
3913 names[pos].iMouse = glGetUniformLocation(prog->id(), "iMouse");
3914 names[pos].time_f = glGetUniformLocation(prog->id(), "time_f");
3915 names[pos].iResolution = glGetUniformLocation(prog->id(), "iResolution");
3916 names[pos].iFrame = glGetUniformLocation(prog->id(), "iFrame");
3917 names[pos].iTimeDelta = glGetUniformLocation(prog->id(), "iTimeDelta");
3918 names[pos].iDate = glGetUniformLocation(prog->id(), "iDate");
3919 names[pos].iFrameRate = glGetUniformLocation(prog->id(), "iFrameRate");
3920 names[pos].iMouseClick = glGetUniformLocation(prog->id(), "iMouseClick");
3921
3922 for (int i = 0; i < 4; ++i) {
3923 std::string channelTime = "iChannelTime[" + std::to_string(i) + "]";
3924 std::string channelRes = "iChannelResolution[" + std::to_string(i) + "]";
3925 names[pos].iChannelTime[i] = glGetUniformLocation(prog->id(), channelTime.c_str());
3926 names[pos].iChannelResolution[i] = glGetUniformLocation(prog->id(), channelRes.c_str());
3927 }
3928
3929 if (name.find("cache") != std::string::npos) {
3930 names[pos].history_loc = glGetUniformLocation(prog->id(), "history");
3931 names[pos].history_head_loc = glGetUniformLocation(prog->id(), "history_head");
3932 if (use_history_array && names[pos].history_loc != -1) {
3933 glUniform1i(names[pos].history_loc, 1);
3934 }
3935 if (use_history_array && names[pos].history_head_loc != -1) {
3936 glUniform1i(names[pos].history_head_loc, history_head);
3937 }
3938 // samp1..samp8 are the legacy slot-named samplers (capped
3939 // at 8 because the engine only ever declared that many).
3940 for (int i = 0; i < 8; ++i) {
3941 names[pos].texture_cache_loc[i] = glGetUniformLocation(prog->id(), std::string("samp" + std::to_string(i + 1)).c_str());
3942 }
3943 // Prefer assigning the whole sampler array through its base
3944 // location; some drivers do not reliably expose locations for
3945 // every `textures[i]` element queried individually.
3946 names[pos].texture_array_base_loc = glGetUniformLocation(prog->id(), "textures[0]");
3947 if (!use_history_array && names[pos].texture_array_base_loc != -1) {
3948 std::vector<GLint> units(static_cast<std::size_t>(cache_size), 0);
3949 for (int i = 0; i < cache_size; ++i) {
3950 units[static_cast<std::size_t>(i)] = i + 1;
3951 }
3952 glUniform1iv(names[pos].texture_array_base_loc, cache_size, units.data());
3953 }
3954 // textures[0..N-1] is the array-form alias and scales with
3955 // the runtime --texture-cache-size. glGetUniformLocation
3956 // returns -1 for any element the shader doesn't actually
3957 // declare/reference; glUniform1i on -1 is a silent no-op.
3958 names[pos].texture_array_loc.assign(static_cast<std::size_t>(cache_size), -1);
3959 for (int i = 0; i < cache_size; ++i) {
3960 names[pos].texture_array_loc[i] = glGetUniformLocation(prog->id(), std::string("textures[" + std::to_string(i) + "]").c_str());
3961 }
3962 }
3963
3964#ifdef AUDIO_ENABLED
3965 names[pos].amp = glGetUniformLocation(prog->id(), "amp");
3966 names[pos].amp_untouched = glGetUniformLocation(prog->id(), "uamp");
3967 names[pos].iamp = glGetUniformLocation(prog->id(), "iamp");
3968 names[pos].amp_peak = glGetUniformLocation(prog->id(), "amp_peak");
3969 names[pos].amp_rms = glGetUniformLocation(prog->id(), "amp_rms");
3970 names[pos].amp_smooth = glGetUniformLocation(prog->id(), "amp_smooth");
3971 names[pos].amp_low = glGetUniformLocation(prog->id(), "amp_low");
3972 names[pos].amp_mid = glGetUniformLocation(prog->id(), "amp_mid");
3973 names[pos].amp_high = glGetUniformLocation(prog->id(), "amp_high");
3974 names[pos].iSampleRate = glGetUniformLocation(prog->id(), "iSampleRate");
3975 names[pos].spectrum_loc = glGetUniformLocation(prog->id(), "spectrum");
3976 names[pos].spectrum_zero_loc = glGetUniformLocation(prog->id(), "spectrum0");
3977 names[pos].spectrum_history_loc = glGetUniformLocation(prog->id(), "spectrum_history");
3978 names[pos].spectrum_history_head_loc = glGetUniformLocation(prog->id(), "spectrum_history_head");
3979 names[pos].spectrum_history_size_loc = glGetUniformLocation(prog->id(), "spectrum_history_size");
3980#endif
3981 // acidcamGL-compatible uniform locations
3982 names[pos].value_alpha_r = glGetUniformLocation(prog->id(), "value_alpha_r");
3983 names[pos].value_alpha_g = glGetUniformLocation(prog->id(), "value_alpha_g");
3984 names[pos].value_alpha_b = glGetUniformLocation(prog->id(), "value_alpha_b");
3985 names[pos].alpha_r_loc = glGetUniformLocation(prog->id(), "alpha_r");
3986 names[pos].alpha_g_loc = glGetUniformLocation(prog->id(), "alpha_g");
3987 names[pos].alpha_b_loc = glGetUniformLocation(prog->id(), "alpha_b");
3988 names[pos].alpha_value = glGetUniformLocation(prog->id(), "alpha_value");
3989 names[pos].index_value = glGetUniformLocation(prog->id(), "index_value");
3990 names[pos].optx_loc = glGetUniformLocation(prog->id(), "optx");
3991 names[pos].random_var_loc = glGetUniformLocation(prog->id(), "random_var");
3992 names[pos].restore_black_loc = glGetUniformLocation(prog->id(), "restore_black");
3993 names[pos].inc_value_loc = glGetUniformLocation(prog->id(), "inc_value");
3994 names[pos].inc_valuex_loc = glGetUniformLocation(prog->id(), "inc_valuex");
3995 names[pos].time_speed_loc = glGetUniformLocation(prog->id(), "time_speed");
3996 names[pos].custom_uniform_locs.assign(custom_uniforms.size(), -1);
3997 for (std::size_t i = 0; i < custom_uniforms.size(); ++i) {
3998 names[pos].custom_uniform_locs[i] = glGetUniformLocation(prog->id(), custom_uniforms[i].name.c_str());
3999 }
4000#ifdef MIDI_ENABLED
4001 names[pos].slider_loc[0] = glGetUniformLocation(prog->id(), "slider1");
4002 names[pos].slider_loc[1] = glGetUniformLocation(prog->id(), "slider2");
4003 names[pos].slider_loc[2] = glGetUniformLocation(prog->id(), "slider3");
4004 names[pos].slider_loc[3] = glGetUniformLocation(prog->id(), "slider4");
4005#endif
4006 }
4007 }
4008
4009 /**
4010 * @brief Upload the current frame-rate to the active shader's iFrameRate uniform.
4011 * @param fps_value Frames per second to send to the GPU.
4012 */
4013 void setFPS(float fps_value) {
4014 auto &names = is3d ? program_names_3d : program_names_2d;
4015 auto it = names.find(index());
4016 if (it == names.end())
4017 return;
4018 GLint loc = it->second.iFrameRate;
4019 if (loc != -1)
4020 glUniform1f(loc, fps_value);
4021 }
4022
4023 /**
4024 * @brief Bind a cache sampler slot for texture-cache shaders.
4025 *
4026 * Sets the sampler uniform `samp1`–`samp8` at the given slot to point
4027 * at texture unit `value + 1`. Only meaningful for shaders whose
4028 * filename contains "cache".
4029 *
4030 * @param name Uniform name (unused — the slot index is used directly).
4031 * @param value Zero-based cache texture slot (0–7).
4032 */
4033 void setUniform(const std::string &name, int value) {
4034 if (value < 0 || value >= cache_size) {
4035 return;
4036 }
4037 auto &names = is3d ? program_names_3d : program_names_2d;
4038 if (names.find(index()) == names.end()) {
4039 return;
4040 }
4041 if (use_history_array) {
4042 if (value == 0 && names[index()].history_loc != -1) {
4043 glUniform1i(names[index()].history_loc, 1);
4044 }
4045 return;
4046 }
4047 // samp1..samp8 only exist for the first 8 slots (legacy ceiling).
4048 if (value < 8) {
4049 glUniform1i(names[index()].texture_cache_loc[value], value + 1);
4050 }
4051 // If a base location exists, the whole array was assigned in
4052 // setupProgramUniforms() via glUniform1iv, so no per-element update
4053 // is needed here.
4054 if (names[index()].texture_array_base_loc != -1) {
4055 return;
4056 }
4057 // Array-form `textures[value]` scales to whatever cache_size is
4058 // active. glUniform1i on -1 (uniform absent or optimized out) is
4059 // a no-op, so the bounds check above is the only guard needed.
4060 if (value < static_cast<int>(names[index()].texture_array_loc.size())) {
4061 glUniform1i(names[index()].texture_array_loc[value], value + 1);
4062 }
4063 }
4064
4065 /**
4066 * @brief Set the physical array layer that represents logical history zero.
4067 *
4068 * The array texture is updated as a ring, so this offset advances whenever
4069 * the oldest frame is overwritten. Shaders map a logical index with
4070 * `(history_head + index) % SIZE`.
4071 */
4072 void setHistoryHead(int layer) { history_head = layer; }
4073
4074 /**
4075 * @brief Switch between the 2D and 3D program vectors.
4076 * @param is3d True selects the 3D shader set; false selects 2D.
4077 */
4078 void is3D(bool is3d) { this->is3d = is3d; }
4079
4080 /// @brief Set the absolute time_f advancement speed multiplier.
4081 void setTimeSpeed(float speed) { time_speed = speed; }
4082
4083 /// @brief Select fixed-per-frame or elapsed real-time advancement.
4084 void setNormalizedTime(bool enabled) { normalized_time = enabled; }
4085
4086#ifdef AUDIO_ENABLED
4087 /**
4088 * @brief Configure the runtime depth of `spectrum_history`.
4089 */
4091 audio_buffer_count = std::max(n, 0);
4092 if (audio_buffer_count == 0)
4094 }
4095
4096 /// @brief Number of spectrum history array layers currently configured.
4097 int audioBufferCount() const { return audio_buffer_count; }
4098
4099 /// @brief Set the physical array layer containing the newest FFT frame.
4101
4102 /// @brief Set startup audio warmup envelope in [0,1] for uniform scaling.
4103 void setAudioWarmupEnvelope(float env) { audio_warmup_envelope = std::clamp(env, 0.0f, 1.0f); }
4104#endif
4105
4106 /// @brief Set the frame rate used by normalized time advancement.
4107 void setVideoFPS(double fps) {
4108 if (std::isfinite(fps) && fps > 0.0)
4109 video_fps = fps;
4110 }
4111
4112 /**
4113 * @brief Increase the time_f speed multiplier by @p step.
4114 *
4115 * Holding Page Up in the render loop calls this every frame,
4116 * giving the user continuous acceleration control.
4117 *
4118 * @param step Amount to add to the speed (e.g. 0.1).
4119 */
4120 void incTimeSpeed(float step) {
4121 time_speed += step;
4122 mx::system_out << "acmx2: Time speed: " << time_speed << "\n";
4123 fflush(stdout);
4124 }
4125
4126 /**
4127 * @brief Decrease the time_f speed multiplier by @p step, clamped to zero.
4128 * @param step Amount to subtract (e.g. 0.1).
4129 */
4130 void decTimeSpeed(float step) {
4131 time_speed -= step;
4132
4133 // Optional: Add a 'deadzone' to make it easier to stop the animation
4134 if (std::abs(time_speed) < 0.01f) {
4135 time_speed = 0.0f;
4136 }
4137
4138 mx::system_out << "acmx2: Time speed (decelerating): " << time_speed << "\n";
4139 fflush(stdout);
4140 }
4141
4142 /**
4143 * @brief Enable or disable dual-mode compilation (2D + 3D vertex shaders).
4144 *
4145 * When dual mode is on, every shader source is compiled twice: once with
4146 * `data/vert.glsl` (flat quad) and once with `data/vertex.glsl` (3D
4147 * model). This doubles compilation time but allows runtime switching
4148 * between 2D and 3D rendering with a single key press.
4149 *
4150 * @param enable True to compile both shader variants.
4151 */
4152 void enableDualMode(bool enable) { dual_mode = enable; }
4153
4154 /// @brief Return whether dual mode (2D + 3D) is active.
4155 bool isDualMode() const { return dual_mode; }
4156
4157 /**
4158 * @brief Toggle between 2D and 3D rendering modes.
4159 *
4160 * Only effective when dual_mode is enabled; otherwise prints a
4161 * diagnostic and returns. Flips the internal is3d flag so that
4162 * subsequent calls to shader(), useProgram(), size(), etc. use the
4163 * alternate program vector.
4164 */
4165 void toggle3D() {
4166 if (!dual_mode) {
4167 mx::system_out << "acmx2: Cannot switch to 3D - dual mode not enabled\n";
4168 fflush(stdout);
4169 return;
4170 }
4171 is3d = !is3d;
4172 mx::system_out << "acmx2: Switched to " << (is3d ? "3D" : "2D") << " mode\n";
4173 fflush(stdout);
4174 }
4175
4176 /// @brief Return true if currently in 3D mode.
4177 bool get3D() const { return is3d; }
4178
4179 /**
4180 * @brief Toggle the shader bypass flag.
4181 *
4182 * When bypassed, ACView::draw() uses the plain framebuffer pass-through
4183 * shader instead of the current library shader, showing the raw
4184 * camera/video feed. Press Space at runtime to toggle.
4185 */
4188 std::string state = shader_bypass ? "disabled" : "enabled";
4189 mx::system_out << "acmx2: Shader processing " << state << "\n";
4190 fflush(stdout);
4191 }
4192
4193 /// @brief Return true if shader processing is currently bypassed.
4194 bool isBypassed() const { return shader_bypass; }
4195
4196 /// @brief Compile every shader listed in the preferred manifest.
4197 void loadPrograms(gl::GLWindow *win, const std::string &text, mx::Font &loadingFont) {
4198 ShaderManifestData manifest;
4199 std::string manifest_error;
4200 if (!loadShaderManifest(text, manifest, manifest_error))
4201 throw mx::Exception("acmx2: " + manifest_error);
4203
4204 std::vector<std::string> shader_files;
4205 for (const std::string &entry : manifest.entries) {
4206 auto shader_entry = normalizeShaderIndexEntry(entry);
4207 if (!shader_entry)
4208 continue;
4209 std::string full_path;
4210 if (resolveShaderPathInLibrary(text, *shader_entry, full_path)) {
4211 shader_files.push_back(*shader_entry);
4212 }
4213 }
4214
4215 // Case-insensitive sort to match Qt interface behavior
4216 std::sort(shader_files.begin(), shader_files.end(), [](const std::string &a, const std::string &b) { return std::lexicographical_compare(a.begin(), a.end(), b.begin(), b.end(), [](unsigned char ca, unsigned char cb) { return std::tolower(ca) < std::tolower(cb); }); });
4217
4218 size_t total_shaders = shader_files.size();
4219
4220 const char *load_action = use_cache ? "Loading" : "Compiling";
4221 mx::system_out << "acmx2: " << load_action << " " << total_shaders << " shaders (" << (dual_mode ? "2D+3D" : "2D") << ")...\n";
4222 fflush(stdout);
4223
4224 static constexpr const char *kLogoVert = "#version 330 core\n"
4225 "layout(location = 0) in vec3 aPos;\n"
4226 "layout(location = 1) in vec2 aTex;\n"
4227 "out vec2 tc;\n"
4228 "void main() { gl_Position = vec4(aPos, 1.0); tc = aTex; }\n";
4229 static constexpr const char *kLogoFrag = "#version 330 core\n"
4230 "in vec2 tc;\n"
4231 "out vec4 color;\n"
4232 "uniform sampler2D samp;\n"
4233 "void main() { color = texture(samp, tc); }\n";
4234
4235 gl::ShaderProgram logo_shader;
4236 auto logo_sprite = std::make_unique<gl::GLSprite>();
4237 bool logo_loaded = false;
4238 {
4239 std::string logo_path = win->util.getFilePath("data/logo.png");
4240 if (std::filesystem::exists(logo_path)) {
4241 GLuint logo_tex = 0;
4242 try {
4243 int lw = 0, lh = 0;
4244 logo_tex = gl::loadTexture(logo_path, lw, lh);
4245 if (logo_tex && logo_shader.loadProgramFromText(kLogoVert, kLogoFrag)) {
4246 logo_sprite->initSize(win->w, win->h);
4247 logo_sprite->setName("samp");
4248 logo_sprite->setShader(&logo_shader);
4249 float scale = std::min(static_cast<float>(win->w) / lw, static_cast<float>(win->h) / lh);
4250 int dw = static_cast<int>(lw * scale);
4251 int dh = static_cast<int>(lh * scale);
4252 int lx = (win->w - dw) / 2;
4253 int ly = (win->h - dh) / 2;
4254 logo_sprite->initWithTexture(&logo_shader, logo_tex, lx, ly, dw, dh);
4255 logo_tex = 0;
4256 logo_loaded = true;
4257 }
4258 } catch (...) {
4259 }
4260 if (logo_tex) {
4261 glDeleteTextures(1, &logo_tex);
4262 }
4263 }
4264 }
4265
4266 int last_percent_reported = -1;
4267 for (size_t shader_index = 0; shader_index < shader_files.size(); ++shader_index) {
4268 const std::string &line_data = shader_files[shader_index];
4269 std::string full_path = text + "/" + line_data;
4270 std::string vert_2d = win->util.getFilePath("data/vert.glsl");
4271 std::string vert_3d = win->util.getFilePath("data/vertex.glsl");
4272
4273 const bool is_compute = isComputeShaderFile(line_data);
4274 bool ok_2d = false;
4275 if (is_compute && compute_shader_supported) {
4276 std::string compute_error;
4277 auto compute_program = makeComputeProgram(full_path, compute_error);
4278 if (compute_program) {
4279 programs_2d.push_back(std::move(compute_program));
4280 ok_2d = true;
4281 } else {
4282 mx::system_out << "acmx2: ⚠ " << compute_error << " — substituting passthrough placeholder\n";
4283 }
4284 } else if (!is_compute) {
4285 programs_2d.push_back(makeProgram());
4286 try {
4287 ok_2d = loadProgramWithSize(programs_2d.back().get(), vert_2d, full_path);
4288 } catch (const std::exception &e) {
4289 mx::system_out << "acmx2: ⚠ Exception compiling 2D shader: " << line_data << " (" << e.what() << ")\n";
4290 } catch (...) {
4291 mx::system_out << "acmx2: ⚠ Unknown exception compiling 2D shader: " << line_data << "\n";
4292 }
4293 }
4294 if (!ok_2d) {
4295 if (!programs_2d.empty() && programs_2d.size() > shader_index)
4296 programs_2d.pop_back();
4297 auto ph = makePassthroughProgram(vert_2d);
4298 if (!ph)
4299 throw mx::Exception("acmx2: Error could not build 2D passthrough placeholder for: " + line_data);
4300 programs_2d.push_back(std::move(ph));
4301 if (is_compute && !compute_shader_supported) {
4302 mx::system_out << "acmx2: Skipping unsupported compute shader: " << line_data << "\n";
4303 }
4304 }
4306 setupProgramUniforms(win, programs_2d.back().get(), program_names_2d, programs_2d.size() - 1, full_path);
4307 if (dual_mode) {
4308 if (is_compute) {
4309 auto ph = makePassthroughProgram(vert_3d);
4310 if (!ph)
4311 throw mx::Exception("acmx2: Error could not build 3D compute passthrough for: " + line_data);
4312 programs_3d.push_back(std::move(ph));
4313 } else {
4314 bool ok_3d = false;
4315 programs_3d.push_back(makeProgram());
4316 try {
4317 ok_3d = loadProgramWithSize(programs_3d.back().get(), vert_3d, full_path);
4318 } catch (...) {
4319 }
4320 if (!ok_3d) {
4321 programs_3d.pop_back();
4322 auto ph = makePassthroughProgram(vert_3d);
4323 if (!ph)
4324 throw mx::Exception("acmx2: Error could not build 3D passthrough placeholder for: " + line_data);
4325 programs_3d.push_back(std::move(ph));
4326 }
4327 }
4328 setupProgramUniforms(win, programs_3d.back().get(), program_names_3d, programs_3d.size() - 1, full_path);
4329 }
4330
4331 int percent = static_cast<int>((shader_index + 1) * 100 / total_shaders);
4332 int percent_bucket = (percent / 10) * 10;
4333 if (percent_bucket > last_percent_reported) {
4334 last_percent_reported = percent_bucket;
4335 mx::system_out << "acmx2: " << load_action << "... " << percent_bucket << "% (" << (shader_index + 1) << "/" << total_shaders << " shaders)\n";
4336 fflush(stdout);
4337
4338 glClearColor(0.0f, 0.0f, 0.0f, 1.0f);
4339 glClear(GL_COLOR_BUFFER_BIT);
4340 if (logo_loaded) {
4341 logo_sprite->draw();
4342 }
4343 if (loadingFont.handle().has_value()) {
4344 std::string loadingText = std::string(load_action) + " Shader " + std::to_string(shader_index + 1) + "/" + std::to_string(total_shaders) + "...";
4345 win->text.printText_Blended(loadingFont, 10, 10, loadingText);
4346 }
4347 SDL_GL_SwapWindow(win->getWindow());
4348 SDL_PumpEvents();
4349 }
4350 }
4351 mx::system_out << "acmx2: " << (use_cache ? "Loaded " : "Compiled ") << shader_files.size() << " shaders (" << (dual_mode ? "2D+3D" : "2D only") << ")\n";
4352 fflush(stdout);
4353 }
4354
4355 /**
4356 * @brief Compile every shader in a library and rewrite its manifest without
4357 * the ones that fail to compile.
4358 *
4359 * Reads `library.json` when present, otherwise `index.txt`, and compiles each
4360 * fragment shader against the supplied 2D (and optionally 3D) vertex
4361 * shader. The selected manifest is rewritten without broken entries and
4362 * preserved as a `.bak` file before replacement.
4363 *
4364 * Non-shader lines (blank lines and lines containing "material") are
4365 * preserved verbatim. Files listed in the manifest that do not exist on
4366 * disk are also dropped.
4367 *
4368 * The existing `.shader_cache` is deleted so the library is rebuilt
4369 * fresh on next launch.
4370 *
4371 * @param win GL window (for asset resolution).
4372 * @param library_path Directory containing a shader manifest and GLSL files.
4373 * @param vert_2d Path to the 2D vertex shader.
4374 * @param vert_3d Path to the 3D vertex shader (only used when @ref dual_mode is set).
4375 * @return true if the manifest was rewritten successfully.
4376 */
4377 bool removeBrokenShaders(gl::GLWindow *win, const std::string &library_path, const std::string &vert_2d, const std::string &vert_3d) {
4378 static_cast<void>(win);
4379 if (glGetString(GL_VERSION) == nullptr) {
4380 mx::system_err << "acmx2: remove-broken requires a valid OpenGL context\n";
4381 return false;
4382 }
4383
4384 ShaderManifestData manifest;
4385 std::string manifest_error;
4386 if (!loadShaderManifest(library_path, manifest, manifest_error)) {
4387 mx::system_err << "acmx2: " << manifest_error << "\n";
4388 return false;
4389 }
4391 const std::string manifest_path = manifest.path;
4392 std::string crash_marker_path = library_path + "/.remove_broken_last_shader";
4393
4394 auto writeCrashMarker = [&](const std::string &shader_name) {
4395 std::ofstream marker_out(crash_marker_path, std::ios::trunc);
4396 if (marker_out.is_open()) {
4397 marker_out << shader_name;
4398 marker_out.flush();
4399 }
4400 };
4401
4402 auto clearCrashMarker = [&]() {
4403 std::error_code marker_ec;
4404 std::filesystem::remove(crash_marker_path, marker_ec);
4405 };
4406
4407 std::string last_crashed_shader;
4408 {
4409 std::ifstream marker_in(crash_marker_path);
4410 if (marker_in.is_open()) {
4411 std::getline(marker_in, last_crashed_shader);
4412 }
4413 }
4414
4415 // Preserve ordering and, for legacy manifests, non-shader lines.
4416 struct Line {
4417 std::string raw; ///< Original line text.
4418 bool is_shader; ///< True if this line references a fragment shader file.
4419 bool keep = true; ///< False if the shader failed to compile.
4420 };
4421 std::vector<Line> lines;
4422 for (const std::string &manifest_entry : manifest.entries) {
4423 Line entry;
4424 entry.raw = manifest_entry;
4425 auto shader_entry = normalizeShaderIndexEntry(manifest_entry);
4426 std::string full_path;
4427 bool is_shader_line = shader_entry.has_value() && resolveShaderPathInLibrary(library_path, *shader_entry, full_path);
4428 if (shader_entry)
4429 entry.raw = *shader_entry;
4430 entry.is_shader = is_shader_line;
4431 if (!is_shader_line && !manifest_entry.empty() && manifest_entry.find("material") == std::string::npos) {
4432 mx::system_out << "acmx2: ⚠ Removing missing file from manifest: " << manifest_entry << "\n";
4433 entry.is_shader = false;
4434 entry.keep = false;
4435 }
4436 lines.push_back(std::move(entry));
4437 }
4438
4439 size_t pre_removed_from_crash = 0;
4440 if (!last_crashed_shader.empty()) {
4441 for (auto &entry : lines) {
4442 if (!entry.is_shader || !entry.keep) {
4443 continue;
4444 }
4445 if (entry.raw == last_crashed_shader) {
4446 entry.keep = false;
4447 ++pre_removed_from_crash;
4448 mx::system_out << "acmx2: ⚠ Previous scan crashed while compiling '" << last_crashed_shader << "' — removing it and resuming scan\n";
4449 break;
4450 }
4451 }
4452 }
4453
4454 size_t total_shaders = 0;
4455 for (const auto &e : lines) {
4456 if (e.is_shader)
4457 ++total_shaders;
4458 }
4459
4460 mx::system_out << "acmx2: Scanning " << total_shaders << " shaders in " << library_path << " for compile errors (" << (dual_mode ? "2D+3D" : "2D only") << ")\n";
4461 fflush(stdout);
4462
4463 size_t removed = pre_removed_from_crash;
4464 size_t kept = 0;
4465 size_t scanned = 0;
4466 for (auto &entry : lines) {
4467 if (!entry.is_shader || !entry.keep)
4468 continue;
4469 ++scanned;
4470 std::string full_path = library_path + "/" + entry.raw;
4471
4472 // Print the identifying line as a single complete line and
4473 // fully flush BOTH the C++ ostream buffer and the underlying
4474 // C stdout buffer before invoking the GL driver. On macOS
4475 // some shaders cause the Metal-backed GL driver to abort the
4476 // process; without a complete flush here, the output gets
4477 // truncated mid-line (e.g. "[790/") and the user can't tell
4478 // which shader killed the scan.
4479 {
4480 std::string scan_line = "acmx2: [" + std::to_string(scanned) + "/" + std::to_string(total_shaders) + "] " + entry.raw + " ...\n";
4481 mx::system_out << scan_line;
4482 mx::system_out.flush();
4483 fflush(stdout);
4484 }
4485
4486 bool compiled = true;
4487 try {
4488 writeCrashMarker(entry.raw);
4489#if defined(__APPLE__)
4490 // On macOS, shader compile/link failures can emit massive
4491 // driver diagnostics to stderr; keep the scan log concise.
4492 ScopedStderrSilence silence_stderr;
4493#endif
4494 if (isComputeShaderFile(entry.raw)) {
4496 std::string compute_error;
4497 compiled = static_cast<bool>(makeComputeProgram(full_path, compute_error));
4498 if (!compiled && !compute_error.empty())
4499 mx::system_out << compute_error << " ";
4500 } else {
4501 mx::system_out << "compute unavailable; kept without validation ";
4502 }
4503 } else {
4504 gl::ShaderProgram prog_2d;
4505 prog_2d.setSilent(true);
4506 if (!loadProgramWithSize(&prog_2d, vert_2d, full_path)) {
4507 compiled = false;
4508 } else {
4509 GLint link_status = 0;
4510 glGetProgramiv(prog_2d.id(), GL_LINK_STATUS, &link_status);
4511 if (link_status != GL_TRUE)
4512 compiled = false;
4513 }
4514 if (compiled && dual_mode) {
4515 gl::ShaderProgram prog_3d;
4516 prog_3d.setSilent(true);
4517 if (!loadProgramWithSize(&prog_3d, vert_3d, full_path)) {
4518 compiled = false;
4519 } else {
4520 GLint link_status = 0;
4521 glGetProgramiv(prog_3d.id(), GL_LINK_STATUS, &link_status);
4522 if (link_status != GL_TRUE)
4523 compiled = false;
4524 }
4525 }
4526 }
4527 } catch (const std::exception &e) {
4528 mx::system_out << "exception: " << e.what() << " ";
4529 compiled = false;
4530 } catch (...) {
4531 compiled = false;
4532 }
4533 clearCrashMarker();
4534
4535 if (compiled) {
4536 mx::system_out << "acmx2: -> OK\n";
4537 ++kept;
4538 } else {
4539 mx::system_out << "acmx2: -> REMOVED\n";
4540 entry.keep = false;
4541 ++removed;
4542 }
4543 mx::system_out.flush();
4544 fflush(stdout);
4545 }
4546
4547 clearCrashMarker();
4548
4549 // Back up the selected manifest before rewriting.
4550 std::error_code ec;
4551 std::filesystem::copy_file(manifest_path, manifest_path + ".bak", std::filesystem::copy_options::overwrite_existing, ec);
4552 if (ec) {
4553 mx::system_out << "acmx2: Warning: could not create manifest backup (" << ec.message() << ")\n";
4554 }
4555
4556 std::vector<std::string> kept_entries;
4557 for (const auto &entry : lines) {
4558 if (entry.keep)
4559 kept_entries.push_back(entry.raw);
4560 }
4561 if (manifest.format == ShaderManifestFormat::Json) {
4562 if (!writeJsonShaderManifest(manifest_path, kept_entries, manifest.custom_uniforms, manifest_error)) {
4563 mx::system_err << "acmx2: " << manifest_error << "\n";
4564 return false;
4565 }
4566 } else {
4567 std::ofstream out(manifest_path, std::ios::trunc);
4568 if (!out.is_open()) {
4569 mx::system_err << "acmx2: Could not rewrite manifest at: " << manifest_path << "\n";
4570 return false;
4571 }
4572 for (const std::string &entry : kept_entries)
4573 out << entry << "\n";
4574 out.close();
4575 }
4576
4577 // Invalidate the on-disk cache since the library composition changed.
4578 std::string cache_file = shaderCacheFilePath(win ? win->util.path : std::string(), library_path, cache_size, use_history_array);
4579 if (std::filesystem::exists(cache_file)) {
4580 std::filesystem::remove(cache_file, ec);
4581 if (!ec) {
4582 mx::system_out << "acmx2: Removed stale shader cache: " << cache_file << "\n";
4583 }
4584 }
4585
4586 mx::system_out << "acmx2: Remove-broken complete: kept " << kept << ", removed " << removed << " shader(s). "
4587 << "Backup written to " << manifest_path << ".bak\n";
4588 fflush(stdout);
4589 return true;
4590 }
4591
4592 /**
4593 * @brief Compile one shader and replace a single binary-cache entry.
4594 *
4595 * A failed compile is recorded in the entry so its manifest slot remains
4596 * stable and can use the normal passthrough fallback.
4597 */
4598 bool compileShaderCacheEntry(const std::string &shader_file, const std::string &full_path, const std::string &vert_2d, const std::string &vert_3d, bool include_3d, ShaderCacheEntry &entry) {
4599 entry = {};
4600 entry.shader_name = std::filesystem::path(shader_file).stem().string();
4601 entry.source_hash = preparedFragmentHash(full_path);
4603
4604 const auto markFailed = [&](const std::string &reason) {
4605 entry.failed = true;
4606 entry.binary_2d.clear();
4607 entry.binary_3d.clear();
4608 entry.format_2d = 0;
4609 entry.format_3d = 0;
4610 mx::system_err << "acmx2: Incremental cache compile failed for " << shader_file << ": " << reason << "\n";
4611 mx::system_err.flush();
4612 };
4613 const auto extractBinary = [](GLuint program, std::vector<char> &binary, GLenum &format) {
4614 GLint binaryLength = 0;
4615 glGetProgramiv(program, GL_PROGRAM_BINARY_LENGTH, &binaryLength);
4616 if (binaryLength <= 0)
4617 return false;
4618 binary.resize(static_cast<std::size_t>(binaryLength));
4619 GLsizei actualLength = 0;
4620 format = 0;
4621 while (glGetError() != GL_NO_ERROR) {
4622 }
4623 glGetProgramBinaryFunc(program, binaryLength, &actualLength, &format, binary.data());
4624 if (glGetError() != GL_NO_ERROR || actualLength <= 0) {
4625 binary.clear();
4626 format = 0;
4627 return false;
4628 }
4629 binary.resize(static_cast<std::size_t>(actualLength));
4630 return true;
4631 };
4632
4633 try {
4634 if (entry.kind == ShaderProgramKind::Compute) {
4637 markFailed("OpenGL 4.3 compute shaders are unavailable");
4638 return false;
4639 }
4640
4641 std::string compute_error;
4642 auto compute_program = makeComputeProgram(full_path, compute_error);
4643 if (!compute_program) {
4644 markFailed(compute_error);
4645 return false;
4646 }
4647 if (!extractBinary(compute_program->id(), entry.binary_2d, entry.format_2d)) {
4648 markFailed("could not extract compute program binary");
4649 return false;
4650 }
4651 entry.failed = false;
4652 return true;
4653 }
4654
4655 gl::ShaderProgram program2d;
4656 program2d.setSilent(true);
4657 if (!loadProgramWithSize(&program2d, vert_2d, full_path)) {
4658 markFailed("2D compile failed");
4659 return false;
4660 }
4661 if (!extractBinary(program2d.id(), entry.binary_2d, entry.format_2d)) {
4662 markFailed("could not extract 2D program binary");
4663 return false;
4664 }
4665
4666 if (include_3d) {
4667 gl::ShaderProgram program3d;
4668 program3d.setSilent(true);
4669 if (!loadProgramWithSize(&program3d, vert_3d, full_path)) {
4670 markFailed("3D compile failed");
4671 return false;
4672 }
4673 if (!extractBinary(program3d.id(), entry.binary_3d, entry.format_3d)) {
4674 markFailed("could not extract 3D program binary");
4675 return false;
4676 }
4677 }
4678 } catch (const std::exception &error) {
4679 markFailed(error.what());
4680 return false;
4681 } catch (...) {
4682 markFailed("unknown exception");
4683 return false;
4684 }
4685
4686 entry.failed = false;
4687 return true;
4688 }
4689
4690 /**
4691 * @brief Build the on-disk shader binary cache for all shaders in a library.
4692 * @param win GL window (provides vertex shader paths).
4693 * @param library_path Directory containing a shader manifest and GLSL files.
4694 * @param vert_2d Path to the 2D vertex shader.
4695 * @param vert_3d Path to the 3D vertex shader.
4696 * @param loadingFont Optional font used to render rebuild progress.
4697 * @return true on success.
4698 */
4699 bool buildShaderCache(gl::GLWindow *win, const std::string &library_path, const std::string &vert_2d, const std::string &vert_3d, mx::Font *loadingFont = nullptr) {
4700 if (glGetString(GL_VERSION) == nullptr) {
4701 mx::system_err << "acmx2: build-cache requires a valid OpenGL context\n";
4702 return false;
4703 }
4704
4705 GLint numFormats = 0;
4706 glGetIntegerv(GL_NUM_PROGRAM_BINARY_FORMATS, &numFormats);
4707 if (numFormats == 0) {
4708 mx::system_err << "acmx2: Error - OpenGL driver does not support program binaries\n";
4709 return false;
4710 }
4711 mx::system_out << "acmx2: OpenGL supports " << numFormats << " program binary format(s)\n";
4712 fflush(stdout);
4713
4715 mx::system_err << "acmx2: Error - Failed to load program binary extension functions\n";
4716 return false;
4717 }
4718 mx::system_out << "acmx2: Program binary functions loaded successfully\n";
4719 fflush(stdout);
4720
4721 std::string cache_file = shaderCacheFilePath(win ? win->util.path : std::string(), library_path, cache_size, use_history_array);
4722 ShaderManifestData manifest;
4723 std::string manifest_error;
4724 if (!loadShaderManifest(library_path, manifest, manifest_error)) {
4725 mx::system_err << "acmx2: " << manifest_error << "\n";
4726 return false;
4727 }
4729 std::vector<std::string> shader_files;
4730 if (!collectShaderLibraryEntries(library_path, shader_files, manifest_error)) {
4731 mx::system_err << "acmx2: " << manifest_error << "\n";
4732 return false;
4733 }
4734
4735 ShaderCache cache;
4736 cache.gl_renderer = safeGLString(GL_RENDERER);
4737 cache.gl_version = safeGLString(GL_VERSION);
4738 cache.dual_mode = dual_mode;
4739
4740 mx::system_out << "acmx2: Building shader cache for " << shader_files.size() << " shaders...\n";
4741 fflush(stdout);
4742
4743 static constexpr const char *kCacheLogoVert = "#version 330 core\n"
4744 "layout(location = 0) in vec3 aPos;\n"
4745 "layout(location = 1) in vec2 aTex;\n"
4746 "out vec2 tc;\n"
4747 "void main() { gl_Position = vec4(aPos, 1.0); tc = aTex; }\n";
4748 static constexpr const char *kCacheLogoFrag = "#version 330 core\n"
4749 "in vec2 tc;\n"
4750 "out vec4 color;\n"
4751 "uniform sampler2D samp;\n"
4752 "void main() { color = texture(samp, tc); }\n";
4753
4754 gl::ShaderProgram cache_logo_shader;
4755 auto cache_logo_sprite = std::make_unique<gl::GLSprite>();
4756 bool cache_logo_loaded = false;
4757 if (win != nullptr) {
4758 const std::string logo_path = win->util.getFilePath("data/logo.png");
4759 if (std::filesystem::exists(logo_path)) {
4760 GLuint logo_texture = 0;
4761 try {
4762 int logo_width = 0;
4763 int logo_height = 0;
4764 logo_texture = gl::loadTexture(logo_path, logo_width, logo_height);
4765 if (logo_texture && cache_logo_shader.loadProgramFromText(kCacheLogoVert, kCacheLogoFrag)) {
4766 cache_logo_sprite->initSize(win->w, win->h);
4767 cache_logo_sprite->setName("samp");
4768 cache_logo_sprite->setShader(&cache_logo_shader);
4769 const float scale = std::min(static_cast<float>(win->w) / logo_width, static_cast<float>(win->h) / logo_height);
4770 const int draw_width = static_cast<int>(logo_width * scale);
4771 const int draw_height = static_cast<int>(logo_height * scale);
4772 const int draw_x = (win->w - draw_width) / 2;
4773 const int draw_y = (win->h - draw_height) / 2;
4774 cache_logo_sprite->initWithTexture(&cache_logo_shader, logo_texture, draw_x, draw_y, draw_width, draw_height);
4775 logo_texture = 0;
4776 cache_logo_loaded = true;
4777 }
4778 } catch (...) {
4779 }
4780 if (logo_texture)
4781 glDeleteTextures(1, &logo_texture);
4782 }
4783 }
4784
4785 int last_progress_percent = -1;
4786 const auto present_cache_progress = [&](std::size_t completed) {
4787 // Pump native window events for every shader so desktop
4788 // environments do not mark the synchronous rebuild as hung.
4789 SDL_PumpEvents();
4790 if (win == nullptr)
4791 return;
4792
4793 const int percent = shader_files.empty() ? 100 : static_cast<int>(completed * 100 / shader_files.size());
4794 if (percent == last_progress_percent)
4795 return;
4796 last_progress_percent = percent;
4797
4798 glViewport(0, 0, win->w, win->h);
4799 glClearColor(0.0f, 0.0f, 0.0f, 1.0f);
4800 glClear(GL_COLOR_BUFFER_BIT);
4801 if (cache_logo_loaded)
4802 cache_logo_sprite->draw();
4803 if (loadingFont != nullptr && loadingFont->handle().has_value()) {
4804 const std::string progress_text = "Building Shader Cache " + std::to_string(completed) + "/" + std::to_string(shader_files.size()) + "...";
4805 win->text.printText_Blended(*loadingFont, 10, 10, progress_text);
4806 }
4807 SDL_GL_SwapWindow(win->getWindow());
4808 SDL_PumpEvents();
4809 };
4810
4811 present_cache_progress(0);
4812
4813 for (size_t i = 0; i < shader_files.size(); ++i) {
4814 present_cache_progress(i);
4815 const std::string &shader_file = shader_files[i];
4816 std::string full_path = library_path + "/" + shader_file;
4817
4818 mx::system_out << "acmx2: Caching Shader " << i << "/" << shader_files.size() << ": [" << shader_file << "] \n";
4819 fflush(stdout);
4820
4821 ShaderCacheEntry entry;
4822 std::filesystem::path file_path(shader_file);
4823 entry.shader_name = file_path.stem().string();
4824
4825 mx::system_out << " - Computing hash... ";
4826 fflush(stdout);
4827 entry.source_hash = preparedFragmentHash(full_path);
4828 mx::system_out << "done\n";
4829 fflush(stdout);
4830
4831 if (isComputeShaderFile(shader_file)) {
4832 const bool compiled = compileShaderCacheEntry(shader_file, full_path, vert_2d, vert_3d, false, entry);
4833 cache.entries.push_back(std::move(entry));
4834 mx::system_out << (compiled ? " ✔ COMPUTE SUCCESS\n" : " ⚠ COMPUTE SKIPPED (passthrough placeholder)\n");
4835 fflush(stdout);
4836 continue;
4837 }
4838
4839 auto mark_failed = [&](const char *reason) {
4840 entry.failed = true;
4841 entry.binary_2d.clear();
4842 entry.binary_3d.clear();
4843 entry.format_2d = 0;
4844 entry.format_3d = 0;
4845 mx::system_out << " ⚠ SKIPPED: " << reason << " (slot preserved; will run as passthrough)\n";
4846 fflush(stdout);
4847 cache.entries.push_back(std::move(entry));
4848 };
4849
4850 try {
4851 mx::system_out << " - Compiling 2D shader... ";
4852 fflush(stdout);
4853
4854 gl::ShaderProgram prog_2d;
4855 prog_2d.setSilent(true);
4856 if (!loadProgramWithSize(&prog_2d, vert_2d, full_path)) {
4857 mx::system_out << " ❌ (2D compile failed)\n";
4858 fflush(stdout);
4859 mark_failed("2D compile failed");
4860 continue;
4861 }
4862 mx::system_out << "done (id=" << prog_2d.id() << ")\n";
4863 fflush(stdout);
4864
4865 GLint link_status = 0;
4866 glGetProgramiv(prog_2d.id(), GL_LINK_STATUS, &link_status);
4867 if (link_status != GL_TRUE) {
4868 mx::system_out << " - ❌ Program not properly linked\n";
4869 fflush(stdout);
4870 mark_failed("2D link failed");
4871 continue;
4872 }
4873
4874 GLint binary_retrievable = 0;
4875 glGetProgramiv(prog_2d.id(), GL_PROGRAM_BINARY_RETRIEVABLE_HINT, &binary_retrievable);
4876 mx::system_out << " - Binary retrievable hint: " << binary_retrievable << "\n";
4877 fflush(stdout);
4878
4879 mx::system_out << " - Getting binary length... ";
4880 fflush(stdout);
4881
4882 GLint binary_length = 0;
4883 glGetProgramiv(prog_2d.id(), GL_PROGRAM_BINARY_LENGTH, &binary_length);
4884 GLenum gl_error = glGetError();
4885
4886 mx::system_out << binary_length << " bytes\n";
4887 fflush(stdout);
4888
4889 if (gl_error != GL_NO_ERROR) {
4890 mx::system_out << " ❌ (GL error: " << gl_error << ")\n";
4891 fflush(stdout);
4892 mark_failed("GL error retrieving 2D binary length");
4893 continue;
4894 }
4895
4896 if (binary_length > 0) {
4897 mx::system_out << " - Extracting binary... ";
4898 fflush(stdout);
4899
4900 void *binary_buffer = malloc(binary_length);
4901 if (!binary_buffer) {
4902 mx::system_out << "❌ (malloc failed)\n";
4903 fflush(stdout);
4904 mark_failed("malloc failed for 2D binary");
4905 continue;
4906 }
4907
4908 GLsizei actual_length = 0;
4909 GLenum format = 0;
4910
4911 mx::system_out << "calling glGetProgramBinary... ";
4912 fflush(stdout);
4913
4914 glGetProgramBinaryFunc(prog_2d.id(), binary_length, &actual_length, &format, binary_buffer);
4915 gl_error = glGetError();
4916
4917 mx::system_out << "done. actual=" << actual_length << ", format=" << format << "\n";
4918 fflush(stdout);
4919
4920 if (gl_error != GL_NO_ERROR || actual_length == 0) {
4921 mx::system_out << " ❌ (binary extraction failed, gl_error=" << gl_error << ")\n";
4922 fflush(stdout);
4923 free(binary_buffer);
4924 mark_failed("2D binary extraction failed");
4925 continue;
4926 }
4927
4928 entry.binary_2d.resize(actual_length);
4929 memcpy(entry.binary_2d.data(), binary_buffer, actual_length);
4930 entry.format_2d = format;
4931 free(binary_buffer);
4932 } else {
4933 mx::system_out << " ❌ (no binary available)\n";
4934 fflush(stdout);
4935 mark_failed("no 2D binary available");
4936 continue;
4937 }
4938
4939 if (dual_mode) {
4940 mx::system_out << " - Compiling 3D shader... ";
4941 fflush(stdout);
4942
4943 gl::ShaderProgram prog_3d;
4944 prog_3d.setSilent(true);
4945 if (!loadProgramWithSize(&prog_3d, vert_3d, full_path)) {
4946 mx::system_out << " ❌ (3D compile failed)\n";
4947 fflush(stdout);
4948 mark_failed("3D compile failed");
4949 continue;
4950 }
4951
4952 mx::system_out << "done (id=" << prog_3d.id() << ")\n";
4953 fflush(stdout);
4954
4955 mx::system_out << " - Getting 3D binary... ";
4956 fflush(stdout);
4957
4958 GLint binary_length_3d = 0;
4959 glGetProgramiv(prog_3d.id(), GL_PROGRAM_BINARY_LENGTH, &binary_length_3d);
4960
4961 mx::system_out << binary_length_3d << " bytes\n";
4962 fflush(stdout);
4963
4964 if (binary_length_3d > 0) {
4965 entry.binary_3d.resize(binary_length_3d);
4966 GLsizei actual_length_3d = 0;
4967 glGetProgramBinaryFunc(prog_3d.id(), binary_length_3d, &actual_length_3d, &entry.format_3d, entry.binary_3d.data());
4968 entry.binary_3d.resize(actual_length_3d);
4969 mx::system_out << " - 3D binary extracted: " << actual_length_3d << " bytes\n";
4970 fflush(stdout);
4971 }
4972 }
4973
4974 cache.entries.push_back(std::move(entry));
4975 mx::system_out << " ✔ SUCCESS\n";
4976 fflush(stdout);
4977 } catch (const std::exception &e) {
4978 mx::system_out << " ❌ (exception: " << e.what() << ")\n";
4979 fflush(stdout);
4980 mark_failed("exception during compile");
4981 continue;
4982 } catch (...) {
4983 mx::system_out << " ❌ (unknown exception)\n";
4984 fflush(stdout);
4985 mark_failed("unknown exception during compile");
4986 continue;
4987 }
4988 }
4989
4990 present_cache_progress(shader_files.size());
4991
4992 if (cache.save(cache_file)) {
4993 size_t ok_count = 0;
4994 size_t failed_count = 0;
4995 for (const auto &e : cache.entries) {
4996 if (e.failed)
4997 ++failed_count;
4998 else
4999 ++ok_count;
5000 }
5001 mx::system_out << "acmx2: Shader cache saved to: " << cache_file << "\n";
5002 mx::system_out << "acmx2: Cached " << ok_count << " shaders (" << (dual_mode ? "2D+3D" : "2D only") << "), " << failed_count << " failed (passthrough placeholders)\n";
5003 fflush(stdout);
5004 return true;
5005 } else {
5006 mx::system_err << "acmx2: Failed to save shader cache to: " << cache_file << "\n";
5007 return false;
5008 }
5009 }
5010
5011 /**
5012 * @brief Get a display string for the currently active shader.
5013 *
5014 * Returns a string of the form `"42: bloom"` (index + stem name).
5015 * Used for the on-screen overlay and window title.
5016 *
5017 * @return Formatted shader name, or empty string if index is invalid.
5018 */
5019 std::string getFullShaderName() {
5020 std::string name;
5021 auto &names = is3d ? program_names_3d : program_names_2d;
5022 if (names.find(library_index) != names.end()) {
5023 name = std::to_string(library_index) + ": " + names[library_index].name;
5024 }
5025 return name;
5026 }
5027
5028 /**
5029 * @brief Get the stem name of a shader by its numeric index.
5030 * @param idx Zero-based shader program index.
5031 * @return Shader name, or empty string if @p idx is out of range.
5032 */
5033 std::string getShaderNameByIndex(size_t idx) {
5034 auto &names = is3d ? program_names_3d : program_names_2d;
5035 if (names.find(idx) != names.end()) {
5036 return names[idx].name;
5037 }
5038 return "";
5039 }
5040
5041 /**
5042 * @brief Get a display string including the active shader and the pass list.
5043 *
5044 * Format: `"42: bloom [edge, blur, vhs]"` when multi-pass is active.
5045 *
5046 * @param pass_list Vector of additional shader indices applied as post-passes.
5047 * @return Formatted composite name.
5048 */
5049 std::string getFullShaderName(const std::vector<int> &pass_list) {
5050 std::string name;
5051 auto &names = is3d ? program_names_3d : program_names_2d;
5052 if (names.find(library_index) != names.end()) {
5053 name = std::to_string(library_index) + ": " + names[library_index].name;
5054 }
5055 if (!pass_list.empty()) {
5056 name += " [";
5057 for (size_t i = 0; i < pass_list.size(); ++i) {
5058 int idx = pass_list[i];
5059 if (names.find(idx) != names.end()) {
5060 name += names[idx].name;
5061 } else {
5062 name += std::to_string(idx);
5063 }
5064 if (i + 1 < pass_list.size()) {
5065 name += ", ";
5066 }
5067 }
5068 name += "]";
5069 }
5070 return name;
5071 }
5072
5073 /// @brief Attempt to load all shader programs from the binary cache file.
5074 bool loadFromCache(gl::GLWindow *win, const std::string &library_path, mx::Font &loadingFont, const std::string &vert_2d = "", const std::string &vert_3d = "") {
5075 ShaderManifestData manifest;
5076 std::string manifest_error;
5077 if (!loadShaderManifest(library_path, manifest, manifest_error)) {
5078 mx::system_out << "acmx2: " << manifest_error << "\n";
5079 return false;
5080 }
5082 std::string cache_file = shaderCacheFilePath(win ? win->util.path : std::string(), library_path, cache_size, use_history_array);
5083
5084 mx::system_out << "acmx2: Checking for shader cache at: " << cache_file << "\n";
5085 fflush(stdout);
5086
5087 if (!std::filesystem::exists(cache_file)) {
5088 mx::system_out << "acmx2: No shader cache found, will compile shaders\n";
5089 fflush(stdout);
5090 return false;
5091 }
5092
5093 mx::system_out << "acmx2: Found shader cache, loading...\n";
5094 fflush(stdout);
5095
5096 ShaderCache cache;
5097 if (!cache.load(cache_file)) {
5098 mx::system_out << "acmx2: Shader cache corrupted or incompatible, will recompile\n";
5099 return false;
5100 }
5101
5103 mx::system_out << "acmx2: Could not load program binary extension, will recompile\n";
5104 return false;
5105 }
5106
5107 std::string current_renderer = safeGLString(GL_RENDERER);
5108 std::string current_version = safeGLString(GL_VERSION);
5109
5110 if (cache.gl_renderer != current_renderer) {
5111 mx::system_out << "acmx2: GPU changed (was: " << cache.gl_renderer << ", now: " << current_renderer << "), will recompile\n";
5112 return false;
5113 }
5114
5115 if (cache.gl_version != current_version) {
5116 mx::system_out << "acmx2: Driver version changed (was: " << cache.gl_version << ", now: " << current_version << "), will recompile\n";
5117 return false;
5118 }
5119
5120 if (dual_mode && !cache.dual_mode) {
5121 mx::system_out << "acmx2: Cache was built in 2D-only mode but 3D is enabled, will rebuild with 2D+3D\n";
5122 return false;
5123 }
5124
5125 std::vector<std::string> shader_files;
5126 if (!collectShaderLibraryEntries(library_path, shader_files, manifest_error)) {
5127 mx::system_out << "acmx2: " << manifest_error << "\n";
5128 return false;
5129 }
5130
5131 if (shader_files.size() != cache.entries.size()) {
5132 mx::system_out << "acmx2: Shader count mismatch: manifest has " << shader_files.size() << " shaders but cache has " << cache.entries.size() << " entries. Rebuilding cache...\n";
5133 fflush(stdout);
5134 return false;
5135 }
5136
5137 std::vector<std::size_t> staleIndices;
5138 for (std::size_t i = 0; i < shader_files.size(); ++i) {
5139 const std::string fullPath = library_path + "/" + shader_files[i];
5140 const uint64_t currentHash = preparedFragmentHash(fullPath);
5142 if (currentHash != cache.entries[i].source_hash || cache.entries[i].shader_name != std::filesystem::path(shader_files[i]).stem().string() || cache.entries[i].kind != expectedKind) {
5143 staleIndices.push_back(i);
5144 }
5145 }
5146
5147 if (!staleIndices.empty()) {
5148 mx::system_out << "acmx2: Updating " << staleIndices.size() << " changed shader cache entr" << (staleIndices.size() == 1 ? "y" : "ies") << "...\n";
5149 mx::system_out.flush();
5150 for (const std::size_t index : staleIndices) {
5151 const std::string fullPath = library_path + "/" + shader_files[index];
5152 mx::system_out << "acmx2: Incremental cache update: " << shader_files[index] << "\n";
5153 mx::system_out.flush();
5154 compileShaderCacheEntry(shader_files[index], fullPath, vert_2d, vert_3d, cache.dual_mode, cache.entries[index]);
5155 }
5156 if (!cache.save(cache_file)) {
5157 mx::system_err << "acmx2: Could not save incrementally updated shader cache\n";
5158 return false;
5159 }
5160 }
5161
5162 mx::system_out << "acmx2: Loading " << cache.entries.size() << " shaders from cache...\n";
5163 fflush(stdout);
5165
5166 static constexpr const char *kLogoVertC = "#version 330 core\n"
5167 "layout(location = 0) in vec3 aPos;\n"
5168 "layout(location = 1) in vec2 aTex;\n"
5169 "out vec2 tc;\n"
5170 "void main() { gl_Position = vec4(aPos, 1.0); tc = aTex; }\n";
5171 static constexpr const char *kLogoFragC = "#version 330 core\n"
5172 "in vec2 tc;\n"
5173 "out vec4 color;\n"
5174 "uniform sampler2D samp;\n"
5175 "void main() { color = texture(samp, tc); }\n";
5176
5177 gl::ShaderProgram logo_shader_c;
5178 auto logo_sprite_c = std::make_unique<gl::GLSprite>();
5179 bool logo_loaded_c = false;
5180 {
5181 std::string logo_path = win ? win->util.getFilePath("data/logo.png") : std::string();
5182 if (!logo_path.empty() && std::filesystem::exists(logo_path)) {
5183 GLuint logo_tex = 0;
5184 try {
5185 int lw = 0, lh = 0;
5186 logo_tex = gl::loadTexture(logo_path, lw, lh);
5187 if (logo_tex && logo_shader_c.loadProgramFromText(kLogoVertC, kLogoFragC)) {
5188 logo_sprite_c->initSize(win->w, win->h);
5189 logo_sprite_c->setName("samp");
5190 logo_sprite_c->setShader(&logo_shader_c);
5191 float scale = std::min(static_cast<float>(win->w) / lw, static_cast<float>(win->h) / lh);
5192 int dw = static_cast<int>(lw * scale);
5193 int dh = static_cast<int>(lh * scale);
5194 int lx = (win->w - dw) / 2;
5195 int ly = (win->h - dh) / 2;
5196 logo_sprite_c->initWithTexture(&logo_shader_c, logo_tex, lx, ly, dw, dh);
5197 logo_tex = 0;
5198 logo_loaded_c = true;
5199 }
5200 } catch (...) {
5201 }
5202 if (logo_tex) {
5203 glDeleteTextures(1, &logo_tex);
5204 }
5205 }
5206 }
5207
5208 int last_percent_reported = -1;
5209 size_t binary_fail_count = 0;
5210
5211 // Helper: insert a passthrough program at the current slot to preserve
5212 // index alignment with the manifest when a cache entry cannot be used.
5213 auto push_passthrough_2d = [&](size_t i, const char *reason) {
5214 auto ph = makePassthroughProgram(vert_2d.empty() ? win->util.getFilePath("data/vert.glsl") : vert_2d);
5215 if (!ph) {
5216 mx::system_err << "acmx2: ❌ Failed to build 2D passthrough for slot " << i << " [" << (i < shader_files.size() ? shader_files[i] : std::string("?")) << "]\n";
5217 return false;
5218 }
5219 mx::system_out << "acmx2: ⚠ Slot " << i << " [" << (i < shader_files.size() ? shader_files[i] : std::string("?")) << "] using passthrough (" << reason << ")\n";
5220 fflush(stdout);
5221 programs_2d.push_back(std::move(ph));
5222 setupProgramUniforms(win, programs_2d.back().get(), program_names_2d, programs_2d.size() - 1, library_path + "/" + shader_files[i]);
5223 return true;
5224 };
5225 auto push_passthrough_3d = [&](size_t i, const char *reason) {
5226 auto ph = makePassthroughProgram(vert_3d.empty() ? win->util.getFilePath("data/vertex.glsl") : vert_3d);
5227 if (!ph) {
5228 mx::system_err << "acmx2: ❌ Failed to build 3D passthrough for slot " << i << " [" << (i < shader_files.size() ? shader_files[i] : std::string("?")) << "]\n";
5229 return false;
5230 }
5231 mx::system_out << "acmx2: ⚠ Slot " << i << " [" << (i < shader_files.size() ? shader_files[i] : std::string("?")) << "] using 3D passthrough (" << reason << ")\n";
5232 fflush(stdout);
5233 programs_3d.push_back(std::move(ph));
5234 setupProgramUniforms(win, programs_3d.back().get(), program_names_3d, programs_3d.size() - 1, library_path + "/" + shader_files[i]);
5235 return true;
5236 };
5237
5238 for (size_t i = 0; i < cache.entries.size(); ++i) {
5239 const auto &entry = cache.entries[i];
5240 program_kinds[i] = entry.failed && entry.kind == ShaderProgramKind::Compute ? ShaderProgramKind::ComputeUnavailable : entry.kind;
5241
5242 // If this entry was marked as failed when the cache was built,
5243 // substitute a passthrough program so the slot index stays
5244 // aligned with the manifest.
5245 if (entry.failed || entry.binary_2d.empty()) {
5246 if (!push_passthrough_2d(i, entry.failed ? "cached as failed" : "missing 2D binary")) {
5247 return false;
5248 }
5249 if (dual_mode) {
5250 if (!push_passthrough_3d(i, entry.failed ? "cached as failed" : "missing 3D binary")) {
5251 return false;
5252 }
5253 }
5254 continue;
5255 }
5256
5257 programs_2d.push_back(makeProgram());
5258 GLuint prog_id_2d = glCreateProgram();
5259
5260 GLenum gl_err = glGetError();
5261 glProgramBinaryFunc(prog_id_2d, entry.format_2d, entry.binary_2d.data(), static_cast<GLsizei>(entry.binary_2d.size()));
5262 gl_err = glGetError();
5263
5264 GLint link_status = 0;
5265 glGetProgramiv(prog_id_2d, GL_LINK_STATUS, &link_status);
5266 if (link_status != GL_TRUE) {
5267 GLchar info_log[512];
5268 glGetProgramInfoLog(prog_id_2d, 512, nullptr, info_log);
5269 mx::system_out << "acmx2: ❌ Shader " << i << " [" << entry.shader_name << "] 2D binary load failed, gl_err=" << gl_err << ", format=" << entry.format_2d << ", size=" << entry.binary_2d.size() << ", log=" << info_log << "\n";
5270 fflush(stdout);
5271 glDeleteProgram(prog_id_2d);
5272 programs_2d.pop_back();
5273 ++binary_fail_count;
5274 if (entry.kind == ShaderProgramKind::Compute)
5276 // Substitute passthrough to keep slot index valid.
5277 if (!push_passthrough_2d(i, "2D binary load failed")) {
5278 return false;
5279 }
5280 if (dual_mode) {
5281 if (!push_passthrough_3d(i, "2D binary load failed")) {
5282 return false;
5283 }
5284 }
5285 continue;
5286 }
5287
5288 *programs_2d.back() = gl::ShaderProgram(prog_id_2d);
5289 setupProgramUniforms(win, programs_2d.back().get(), program_names_2d, programs_2d.size() - 1, library_path + "/" + shader_files[i]);
5290
5291 if (dual_mode) {
5292 if (entry.binary_3d.empty()) {
5293 if (!push_passthrough_3d(i, "missing 3D binary")) {
5294 return false;
5295 }
5296 } else {
5297 programs_3d.push_back(makeProgram());
5298 GLuint prog_id_3d = glCreateProgram();
5299 glProgramBinaryFunc(prog_id_3d, entry.format_3d, entry.binary_3d.data(), static_cast<GLsizei>(entry.binary_3d.size()));
5300
5301 glGetProgramiv(prog_id_3d, GL_LINK_STATUS, &link_status);
5302 if (link_status != GL_TRUE) {
5303 mx::system_out << "acmx2: ❌ Shader " << i << " [" << entry.shader_name << "] 3D binary load failed\n";
5304 fflush(stdout);
5305 glDeleteProgram(prog_id_3d);
5306 programs_3d.pop_back();
5307 if (!push_passthrough_3d(i, "3D binary load failed")) {
5308 return false;
5309 }
5310 } else {
5311 *programs_3d.back() = gl::ShaderProgram(prog_id_3d);
5312 setupProgramUniforms(win, programs_3d.back().get(), program_names_3d, programs_3d.size() - 1, library_path + "/" + shader_files[i]);
5313 }
5314 }
5315 }
5316
5317 int percent = static_cast<int>((i + 1) * 100 / cache.entries.size());
5318 int percent_bucket = (percent / 10) * 10;
5319 if (percent_bucket > last_percent_reported) {
5320 last_percent_reported = percent_bucket;
5321 mx::system_out << "acmx2: Cache loading... " << percent_bucket << "% (" << (i + 1) << "/" << cache.entries.size() << " shaders)\n";
5322 fflush(stdout);
5323
5324 glClearColor(0.0f, 0.0f, 0.0f, 1.0f);
5325 glClear(GL_COLOR_BUFFER_BIT);
5326 if (logo_loaded_c) {
5327 logo_sprite_c->draw();
5328 }
5329 if (loadingFont.handle().has_value()) {
5330 std::string loadingText = "Loading Cached Shader " + std::to_string(i + 1) + "/" + std::to_string(cache.entries.size()) + "...";
5331 win->text.printText_Blended(loadingFont, 10, 10, loadingText);
5332 }
5333 SDL_GL_SwapWindow(win->getWindow());
5334 SDL_PumpEvents();
5335 }
5336 }
5337
5338 mx::system_out << "acmx2: Loaded " << cache.entries.size() << " shaders from cache (" << (dual_mode ? "2D+3D" : "2D only") << ")\n";
5339 fflush(stdout);
5340
5341 // If more than 10% of the cached binaries failed to load (stale driver/GPU),
5342 // delete the cache so loadProgramsWithCache() can rebuild it once.
5343 if (binary_fail_count > 0 && cache.entries.size() > 0) {
5344 size_t fail_pct = (binary_fail_count * 100) / cache.entries.size();
5345 if (fail_pct >= 10) {
5346 mx::system_out << "acmx2: ⚠ " << binary_fail_count << "/" << cache.entries.size() << " cached shaders failed to load (" << fail_pct << "%) — cache is stale.\n";
5347 fflush(stdout);
5348 std::error_code rm_ec;
5349 std::filesystem::remove(cache_file, rm_ec);
5350 programs_2d.clear();
5351 programs_3d.clear();
5352 program_kinds.clear();
5353 program_names_2d.clear();
5354 program_names_3d.clear();
5355 return false;
5356 }
5357 }
5358
5359 return true;
5360 }
5361
5362 /**
5363 * @brief Try loading a full shader library from the binary cache, with fallback.
5364 *
5365 * Calls loadFromCache() first; if the cache is missing, stale, or
5366 * incompatible, falls back to loadPrograms() (compile from source).
5367 *
5368 * @param win GL window for asset resolution.
5369 * @param text Shader library directory containing a supported manifest.
5370 * @param loadingFont Font used for the on-screen progress overlay.
5371 */
5372 void loadProgramsWithCache(gl::GLWindow *win, const std::string &text, mx::Font &loadingFont) {
5373 ShaderManifestData manifest;
5374 std::string manifest_error;
5375 if (!loadShaderManifest(text, manifest, manifest_error))
5376 throw mx::Exception("acmx2: " + manifest_error);
5378 std::string vert_2d = win->util.getFilePath("data/vert.glsl");
5379 std::string vert_3d = win->util.getFilePath("data/vertex.glsl");
5380 if (loadFromCache(win, text, loadingFont, vert_2d, vert_3d)) {
5381 return;
5382 }
5383 // Cache miss (file absent / corrupt / source changed). Try to build the
5384 // cache now and reload from it so subsequent runs hit the binary cache
5385 // instead of recompiling 1700+ shaders every launch. If building or
5386 // reloading fails for any reason, fall back to a plain source compile.
5387 std::string cache_file = shaderCacheFilePath(win ? win->util.path : std::string(), text, cache_size, use_history_array);
5388 mx::system_out << "acmx2: Building shader cache at: " << cache_file << "\n";
5389 fflush(stdout);
5390 programs_2d.clear();
5391 programs_3d.clear();
5392 program_kinds.clear();
5393 program_names_2d.clear();
5394 program_names_3d.clear();
5395 if (buildShaderCache(win, text, vert_2d, vert_3d, &loadingFont) && loadFromCache(win, text, loadingFont, vert_2d, vert_3d)) {
5396 return;
5397 }
5398 mx::system_out << "acmx2: Cache build/reload failed; compiling from source.\n";
5399 fflush(stdout);
5400 loadPrograms(win, text, loadingFont);
5401 }
5402
5403 /**
5404 * @brief Check whether the currently active shader is a texture-cache shader.
5405 *
5406 * Returns true when the shader name contains the substring "cache",
5407 * which signals ACView::draw() to bind the 8 historical textures.
5408 *
5409 * @return True if the current shader is a cache shader.
5410 */
5411 bool isCache() {
5412 auto &names = is3d ? program_names_3d : program_names_2d;
5413 if (library_index < names.size() && names[library_index].name.find("cache") != std::string::npos)
5414 return true;
5415 return false;
5416 }
5417
5418 /**
5419 * @brief Check whether the 2D shader at @p idx is a texture-cache shader.
5420 *
5421 * Used by the multipass pipeline (which always renders pass shaders
5422 * out of the 2D set) to decide per-pass whether to bind the cache
5423 * texture units.
5424 */
5425 bool isCache2D(size_t idx) const {
5426 auto it = program_names_2d.find(static_cast<int>(idx));
5427 if (it == program_names_2d.end())
5428 return false;
5429 return it->second.name.find("cache") != std::string::npos;
5430 }
5431
5432 /// Return whether a library slot contains an executable compute program.
5433 bool isCompute(size_t idx) const { return idx < program_kinds.size() && program_kinds[idx] == ShaderProgramKind::Compute; }
5434
5435 /**
5436 * Run a compute library slot as a full-frame image pass.
5437 *
5438 * Compute shaders read the input through `uniform sampler2D samp` on
5439 * texture unit 0 and write an RGBA16F image on image unit 0. The image may
5440 * be declared with explicit `binding = 0`, or use one of the conventional
5441 * names outputImage, output_image, destTex, or img_output.
5442 */
5443 bool dispatchCompute2D(gl::GLWindow *win, size_t idx, GLuint input_texture, GLuint output_texture) {
5444 if (!compute_shader_supported || !isCompute(idx) || idx >= programs_2d.size() || input_texture == 0 || output_texture == 0 || input_texture == output_texture) {
5445 return false;
5446 }
5447
5448 gl::ShaderProgram *program = programs_2d[idx].get();
5449 while (glGetError() != GL_NO_ERROR) {
5450 }
5451 program->useProgram();
5452 updateShaderUniforms2D(win, idx);
5453 glActiveTexture(GL_TEXTURE0);
5454 glBindTexture(GL_TEXTURE_2D, input_texture);
5455 const GLint input_location = glGetUniformLocation(program->id(), "samp");
5456 if (input_location != -1)
5457 glUniform1i(input_location, 0);
5458
5459 static constexpr const char *OUTPUT_IMAGE_NAMES[] = {"outputImage", "output_image", "destTex", "img_output"};
5460 for (const char *name : OUTPUT_IMAGE_NAMES) {
5461 const GLint location = glGetUniformLocation(program->id(), name);
5462 if (location != -1)
5463 glUniform1i(location, 0);
5464 }
5465 glBindImageTexture(0, output_texture, 0, GL_FALSE, 0, GL_WRITE_ONLY, GL_RGBA16F);
5466
5467 const ProgramData &data = program_names_2d.at(static_cast<int>(idx));
5468 const GLuint local_x = static_cast<GLuint>(std::max(data.compute_work_group_size[0], 1));
5469 const GLuint local_y = static_cast<GLuint>(std::max(data.compute_work_group_size[1], 1));
5470 const GLuint groups_x = (static_cast<GLuint>(win->w) + local_x - 1) / local_x;
5471 const GLuint groups_y = (static_cast<GLuint>(win->h) + local_y - 1) / local_y;
5472 glDispatchCompute(groups_x, groups_y, 1);
5473 glMemoryBarrier(GL_SHADER_IMAGE_ACCESS_BARRIER_BIT | GL_TEXTURE_FETCH_BARRIER_BIT | GL_FRAMEBUFFER_BARRIER_BIT);
5474 glBindImageTexture(0, 0, 0, GL_FALSE, 0, GL_WRITE_ONLY, GL_RGBA16F);
5475 return glGetError() == GL_NO_ERROR;
5476 }
5477
5478 /**
5479 * @brief Set the active shader by index.
5480 *
5481 * Bounds-checked against the current program vector (2D or 3D).
5482 * Logs the shader name to stdout.
5483 *
5484 * @param i Zero-based shader index in the active program vector.
5485 */
5486 void setIndex(size_t i) {
5487 auto &progs = is3d ? programs_3d : programs_2d;
5488 auto &names = is3d ? program_names_3d : program_names_2d;
5489 if (i < progs.size()) {
5490 library_index = i;
5491 mx::system_out << "acmx2: Set Shader to Index: " << i << " [" << names[i].name << "] (" << (is3d ? "3D" : "2D") << ")\n";
5492 fflush(stdout);
5493 }
5494 }
5495 /// @brief Advance to the next shader in the library (clamped at the end).
5496 void inc() {
5497 auto &progs = is3d ? programs_3d : programs_2d;
5498 if (library_index + 1 < progs.size())
5500 }
5501 /// @brief Move to the previous shader in the library (clamped at zero).
5502 void dec() {
5503 if (library_index > 0)
5505 }
5506 /// @brief Return the current shader index.
5507 size_t index() { return library_index; }
5508
5509 /**
5510 * @brief Look up a shader index by its stem name.
5511 * @param name Stem name to search for (case-sensitive).
5512 * @return Zero-based index, or -1 if not found.
5513 */
5514 int findShaderByName(const std::string &name) {
5515 auto &names = is3d ? program_names_3d : program_names_2d;
5516 for (auto &[idx, data] : names) {
5517 if (data.name == name)
5518 return static_cast<int>(idx);
5519 }
5520 return -1;
5521 }
5522
5523 /// @brief Number of programs in the currently active set (2D or 3D).
5524 size_t size() { return is3d ? programs_3d.size() : programs_2d.size(); }
5525 /// @brief Number of 2D programs.
5526 size_t size2d() { return programs_2d.size(); }
5527 /// @brief Number of 3D programs.
5528 size_t size3d() { return programs_3d.size(); }
5529
5530 /// @brief Activate the current shader program on the GPU (glUseProgram).
5531 void useProgram() {
5532 auto &progs = is3d ? programs_3d : programs_2d;
5533 progs[index()]->useProgram();
5534 }
5535 /// @brief Return a raw pointer to the currently active ShaderProgram.
5536 gl::ShaderProgram *shader() {
5537 auto &progs = is3d ? programs_3d : programs_2d;
5538 return progs[index()].get();
5539 }
5540
5541 /**
5542 * @brief Get a shader by index from the active set (2D or 3D).
5543 * @param idx Zero-based program index.
5544 * @return Pointer to the program, or nullptr if out of range.
5545 */
5546 gl::ShaderProgram *getShader(size_t idx) {
5547 auto &progs = is3d ? programs_3d : programs_2d;
5548 if (idx < progs.size())
5549 return progs[idx].get();
5550 return nullptr;
5551 }
5552
5553 /**
5554 * @brief Get a 2D shader by index (regardless of current 2D/3D mode).
5555 * @param idx Zero-based program index in the 2D vector.
5556 * @return Pointer, or nullptr if out of range.
5557 */
5558 gl::ShaderProgram *getShader2D(size_t idx) {
5559 if (idx < programs_2d.size())
5560 return programs_2d[idx].get();
5561 return nullptr;
5562 }
5563
5564 /**
5565 * @brief Get a 3D shader by index (regardless of current 2D/3D mode).
5566 * @param idx Zero-based program index in the 3D vector.
5567 * @return Pointer, or nullptr if out of range.
5568 */
5569 gl::ShaderProgram *getShader3D(size_t idx) {
5570 if (idx < programs_3d.size())
5571 return programs_3d[idx].get();
5572 return nullptr;
5573 }
5574
5575 /**
5576 * @brief Upload all uniforms for an arbitrary shader in the active set.
5577 *
5578 * Used by the multi-pass pipeline when each pass may target a
5579 * different shader index. Computes elapsed time, delta time,
5580 * frame count, date, mouse state, and all audio metrics, then
5581 * uploads them to the program at @p idx.
5582 *
5583 * @param win GL window (provides resolution and mouse coordinates).
5584 * @param idx Index of the shader whose uniforms to upload.
5585 */
5586 void updateShaderUniforms(gl::GLWindow *win, size_t idx) {
5587 auto &progs = is3d ? programs_3d : programs_2d;
5588 auto &names = is3d ? program_names_3d : program_names_2d;
5589 if (idx >= progs.size())
5590 return;
5591 if (names.find(idx) == names.end())
5592 return;
5593
5594 static Uint64 start_time = SDL_GetPerformanceCounter();
5595 static Uint64 last_frame_time = start_time;
5596 static uint64_t frame_counter = 0;
5597 Uint64 now_time = SDL_GetPerformanceCounter();
5598 double elapsed_time = static_cast<double>(now_time - start_time) / SDL_GetPerformanceFrequency();
5599 double delta_time = static_cast<double>(now_time - last_frame_time) / SDL_GetPerformanceFrequency();
5600 last_frame_time = now_time;
5601 frame_counter++;
5602 auto &n = names[idx];
5603 progs[idx]->useProgram();
5604 glUniform1f(n.loc, alpha);
5605 glUniform1f(n.iTime, static_cast<float>(elapsed_time));
5606 glUniform1f(n.time_f, time_f);
5607 glUniform1i(n.iFrame, static_cast<int>(frame_counter % INT_MAX));
5608 glUniform1f(n.iTimeDelta, static_cast<float>(delta_time));
5609 auto now = std::chrono::system_clock::now();
5610 std::time_t now_c = std::chrono::system_clock::to_time_t(now);
5611 std::tm localTime_buf{};
5612#ifdef _WIN32
5613 localtime_s(&localTime_buf, &now_c);
5614#else
5615 localtime_r(&now_c, &localTime_buf);
5616#endif
5617 float year = static_cast<float>(localTime_buf.tm_year + 1900);
5618 float month = static_cast<float>(localTime_buf.tm_mon + 1);
5619 float day = static_cast<float>(localTime_buf.tm_mday);
5620 float seconds = static_cast<float>(localTime_buf.tm_hour * 3600 + localTime_buf.tm_min * 60 + localTime_buf.tm_sec);
5621 glUniform4f(n.iDate, year, month, day, seconds);
5622 if (n.iFrameRate != -1) {
5623 glUniform1f(n.iFrameRate, 24.0f);
5624 }
5625 int mouseX = 0, mouseY = 0;
5626 Uint32 mouseState = SDL_GetMouseState(&mouseX, &mouseY);
5627 float currentY = static_cast<float>(win->h - mouseY);
5628 float currentX = static_cast<float>(mouseX);
5629
5630 if (mouseState & SDL_BUTTON(SDL_BUTTON_LEFT)) {
5631 if (!isDraggingLeft) {
5632 clickStartX = currentX;
5633 clickStartY = currentY;
5634 lastClickX = currentX;
5635 lastClickY = currentY;
5636 isDraggingLeft = true;
5637 wasClicked = true;
5638 }
5639 } else {
5640 isDraggingLeft = false;
5641 }
5642 if (mouseState & SDL_BUTTON(SDL_BUTTON_RIGHT)) {
5643 if (!isDraggingRight) {
5644 isDraggingRight = true;
5645 }
5646 } else {
5647 isDraggingRight = false;
5648 }
5649 float leftClickFlag = isDraggingLeft ? 1.0f : 0.0f;
5650 float rightClickFlag = isDraggingRight ? 1.0f : 0.0f;
5651 glUniform4f(n.iMouse, currentX, currentY, leftClickFlag, rightClickFlag);
5652 if (wasClicked && n.iMouseClick != -1) {
5653 glUniform2f(n.iMouseClick, lastClickX, lastClickY);
5654 }
5655 glUniform2f(n.iResolution, static_cast<float>(win->w), static_cast<float>(win->h));
5656 if (n.time_speed_loc != -1) {
5657 glUniform1f(n.time_speed_loc, time_speed);
5658 }
5659 uploadAcidCamUniforms(n, idx);
5660#ifdef AUDIO_ENABLED
5661 const auto audio_metrics = audio_analyzer != nullptr ? audio_analyzer->metrics() : acmx2::audio::AudioMetrics{};
5662 const float audio_sensitivity = audio_analyzer != nullptr ? audio_analyzer->sensitivity() : 1.0f;
5663 if (time_audio) {
5664 glUniform1f(n.amp, audio_metrics.amplitude * audio_warmup_envelope);
5665 glUniform1f(n.amp_untouched, audio_sensitivity);
5666 }
5667 if (n.iSampleRate != -1) {
5668 const float sample_rate = audio_analyzer != nullptr ? static_cast<float>(audio_analyzer->sample_rate()) : 44100.0f;
5669 glUniform1f(n.iSampleRate, sample_rate);
5670 }
5671 if (n.iamp != -1) {
5672 glUniform1f(n.iamp, audio_metrics.frequency);
5673 }
5674 {
5675 float sense = audio_sensitivity * 4.0f * audio_warmup_envelope;
5676 if (n.amp_peak != -1) {
5677 glUniform1f(n.amp_peak, std::sqrt(audio_metrics.peak) * sense);
5678 }
5679 if (n.amp_rms != -1) {
5680 glUniform1f(n.amp_rms, std::sqrt(audio_metrics.rms) * sense);
5681 }
5682 if (n.amp_smooth != -1) {
5683 glUniform1f(n.amp_smooth, std::sqrt(audio_metrics.smooth) * sense);
5684 }
5685 if (n.amp_low != -1) {
5686 glUniform1f(n.amp_low, std::sqrt(audio_metrics.low) * sense);
5687 }
5688 if (n.amp_mid != -1) {
5689 glUniform1f(n.amp_mid, std::sqrt(audio_metrics.mid) * sense);
5690 }
5691 if (n.amp_high != -1) {
5692 glUniform1f(n.amp_high, std::sqrt(audio_metrics.high) * sense);
5693 }
5694 }
5695 // Tell the shader which texture unit holds the spectrum.
5696 if (n.spectrum_loc != -1) {
5697 glUniform1i(n.spectrum_loc, SpectrumTexture::SPECTRUM_TEXTURE_UNIT);
5698 }
5699 if (n.spectrum_zero_loc != -1)
5700 glUniform1i(n.spectrum_zero_loc, SpectrumTexture::SPECTRUM_TEXTURE_UNIT);
5701 if (n.spectrum_history_loc != -1)
5702 glUniform1i(n.spectrum_history_loc, SpectrumHistory::TEXTURE_UNIT);
5703 if (n.spectrum_history_head_loc != -1)
5704 glUniform1i(n.spectrum_history_head_loc, spectrum_history_head);
5705 if (n.spectrum_history_size_loc != -1)
5706 glUniform1i(n.spectrum_history_size_loc, audio_buffer_count);
5707#endif
5708 // Re-assert the selected texture-cache sampler representation.
5709 if (use_history_array) {
5710 if (n.history_loc != -1)
5711 glUniform1i(n.history_loc, 1);
5712 if (n.history_head_loc != -1)
5713 glUniform1i(n.history_head_loc, history_head);
5714 } else {
5715 if (n.texture_array_base_loc != -1) {
5716 std::vector<GLint> units(static_cast<std::size_t>(cache_size), 0);
5717 for (int i = 0; i < cache_size; ++i) {
5718 units[static_cast<std::size_t>(i)] = i + 1;
5719 }
5720 glUniform1iv(n.texture_array_base_loc, cache_size, units.data());
5721 } else {
5722 for (int i = 0; i < static_cast<int>(n.texture_array_loc.size()); ++i) {
5723 if (n.texture_array_loc[i] != -1)
5724 glUniform1i(n.texture_array_loc[i], i + 1);
5725 }
5726 }
5727 for (int i = 0; i < 8 && i < cache_size; ++i) {
5728 if (n.texture_cache_loc[i] != -1)
5729 glUniform1i(n.texture_cache_loc[i], i + 1);
5730 }
5731 }
5732#ifdef MIDI_ENABLED
5733 for (int i = 0; i < 4; ++i) {
5734 if (n.slider_loc[i] != -1)
5735 glUniform1f(n.slider_loc[i], midi_slider[i]);
5736 }
5737#endif
5739 }
5740
5741 /**
5742 * @brief Upload all uniforms for a shader in the **2D** set specifically.
5743 *
5744 * Identical to updateShaderUniforms() but explicitly indexes into
5745 * `programs_2d` / `program_names_2d`, bypassing the 2D/3D mode
5746 * switch. This is used during 3D-mode multi-pass rendering,
5747 * where the post-processing passes always run in 2D but the
5748 * final composite runs in 3D.
5749 *
5750 * @param win GL window.
5751 * @param idx Index in the 2D program vector.
5752 */
5753 void updateShaderUniforms2D(gl::GLWindow *win, size_t idx) {
5754 if (idx >= programs_2d.size())
5755 return;
5756 if (program_names_2d.find(idx) == program_names_2d.end())
5757 return;
5758
5759 static Uint64 start_time = SDL_GetPerformanceCounter();
5760 static Uint64 last_frame_time = start_time;
5761 static uint64_t frame_counter = 0;
5762 Uint64 now_time = SDL_GetPerformanceCounter();
5763 double elapsed_time = static_cast<double>(now_time - start_time) / SDL_GetPerformanceFrequency();
5764 double delta_time = static_cast<double>(now_time - last_frame_time) / SDL_GetPerformanceFrequency();
5765 last_frame_time = now_time;
5766 frame_counter++;
5767 auto &n = program_names_2d[idx];
5768 programs_2d[idx]->useProgram();
5769 glUniform1f(n.loc, alpha);
5770 glUniform1f(n.iTime, static_cast<float>(elapsed_time));
5771 glUniform1f(n.time_f, time_f);
5772 glUniform1i(n.iFrame, static_cast<int>(frame_counter % INT_MAX));
5773 glUniform1f(n.iTimeDelta, static_cast<float>(delta_time));
5774 auto now = std::chrono::system_clock::now();
5775 std::time_t now_c = std::chrono::system_clock::to_time_t(now);
5776 std::tm localTime_buf{};
5777#ifdef _WIN32
5778 localtime_s(&localTime_buf, &now_c);
5779#else
5780 localtime_r(&now_c, &localTime_buf);
5781#endif
5782 float year = static_cast<float>(localTime_buf.tm_year + 1900);
5783 float month = static_cast<float>(localTime_buf.tm_mon + 1);
5784 float day = static_cast<float>(localTime_buf.tm_mday);
5785 float seconds = static_cast<float>(localTime_buf.tm_hour * 3600 + localTime_buf.tm_min * 60 + localTime_buf.tm_sec);
5786 glUniform4f(n.iDate, year, month, day, seconds);
5787 if (n.iFrameRate != -1) {
5788 glUniform1f(n.iFrameRate, 24.0f);
5789 }
5790 int mouseX = 0, mouseY = 0;
5791 Uint32 mouseState = SDL_GetMouseState(&mouseX, &mouseY);
5792 float currentY = static_cast<float>(win->h - mouseY);
5793 float currentX = static_cast<float>(mouseX);
5794 if (mouseState & SDL_BUTTON(SDL_BUTTON_LEFT)) {
5795 if (!isDraggingLeft) {
5796 clickStartX = currentX;
5797 clickStartY = currentY;
5798 lastClickX = currentX;
5799 lastClickY = currentY;
5800 isDraggingLeft = true;
5801 wasClicked = true;
5802 }
5803 } else {
5804 isDraggingLeft = false;
5805 }
5806 if (mouseState & SDL_BUTTON(SDL_BUTTON_RIGHT)) {
5807 if (!isDraggingRight) {
5808 isDraggingRight = true;
5809 }
5810 } else {
5811 isDraggingRight = false;
5812 }
5813 float leftClickFlag = isDraggingLeft ? 1.0f : 0.0f;
5814 float rightClickFlag = isDraggingRight ? 1.0f : 0.0f;
5815 glUniform4f(n.iMouse, currentX, currentY, leftClickFlag, rightClickFlag);
5816 if (wasClicked && n.iMouseClick != -1) {
5817 glUniform2f(n.iMouseClick, lastClickX, lastClickY);
5818 }
5819 glUniform2f(n.iResolution, static_cast<float>(win->w), static_cast<float>(win->h));
5820 if (n.time_speed_loc != -1) {
5821 glUniform1f(n.time_speed_loc, time_speed);
5822 }
5823 uploadAcidCamUniforms(n, idx);
5824#ifdef AUDIO_ENABLED
5825 const auto audio_metrics = audio_analyzer != nullptr ? audio_analyzer->metrics() : acmx2::audio::AudioMetrics{};
5826 const float audio_sensitivity = audio_analyzer != nullptr ? audio_analyzer->sensitivity() : 1.0f;
5827 if (time_audio) {
5828 glUniform1f(n.amp, audio_metrics.amplitude * audio_warmup_envelope);
5829 glUniform1f(n.amp_untouched, audio_sensitivity);
5830 }
5831 if (n.iSampleRate != -1) {
5832 const float sample_rate = audio_analyzer != nullptr ? static_cast<float>(audio_analyzer->sample_rate()) : 44100.0f;
5833 glUniform1f(n.iSampleRate, sample_rate);
5834 }
5835 if (n.iamp != -1) {
5836 glUniform1f(n.iamp, audio_metrics.frequency);
5837 }
5838 {
5839 float sense = audio_sensitivity * 4.0f * audio_warmup_envelope;
5840 if (n.amp_peak != -1) {
5841 glUniform1f(n.amp_peak, std::sqrt(audio_metrics.peak) * sense);
5842 }
5843 if (n.amp_rms != -1) {
5844 glUniform1f(n.amp_rms, std::sqrt(audio_metrics.rms) * sense);
5845 }
5846 if (n.amp_smooth != -1) {
5847 glUniform1f(n.amp_smooth, std::sqrt(audio_metrics.smooth) * sense);
5848 }
5849 if (n.amp_low != -1) {
5850 glUniform1f(n.amp_low, std::sqrt(audio_metrics.low) * sense);
5851 }
5852 if (n.amp_mid != -1) {
5853 glUniform1f(n.amp_mid, std::sqrt(audio_metrics.mid) * sense);
5854 }
5855 if (n.amp_high != -1) {
5856 glUniform1f(n.amp_high, std::sqrt(audio_metrics.high) * sense);
5857 }
5858 }
5859 if (n.spectrum_loc != -1) {
5860 glUniform1i(n.spectrum_loc, SpectrumTexture::SPECTRUM_TEXTURE_UNIT);
5861 }
5862 if (n.spectrum_zero_loc != -1)
5863 glUniform1i(n.spectrum_zero_loc, SpectrumTexture::SPECTRUM_TEXTURE_UNIT);
5864 if (n.spectrum_history_loc != -1)
5865 glUniform1i(n.spectrum_history_loc, SpectrumHistory::TEXTURE_UNIT);
5866 if (n.spectrum_history_head_loc != -1)
5867 glUniform1i(n.spectrum_history_head_loc, spectrum_history_head);
5868 if (n.spectrum_history_size_loc != -1)
5869 glUniform1i(n.spectrum_history_size_loc, audio_buffer_count);
5870#endif
5871 // Re-assert the selected texture-cache sampler representation.
5872 if (use_history_array) {
5873 if (n.history_loc != -1)
5874 glUniform1i(n.history_loc, 1);
5875 if (n.history_head_loc != -1)
5876 glUniform1i(n.history_head_loc, history_head);
5877 } else {
5878 if (n.texture_array_base_loc != -1) {
5879 std::vector<GLint> units(static_cast<std::size_t>(cache_size), 0);
5880 for (int i = 0; i < cache_size; ++i) {
5881 units[static_cast<std::size_t>(i)] = i + 1;
5882 }
5883 glUniform1iv(n.texture_array_base_loc, cache_size, units.data());
5884 } else {
5885 for (int i = 0; i < static_cast<int>(n.texture_array_loc.size()); ++i) {
5886 if (n.texture_array_loc[i] != -1)
5887 glUniform1i(n.texture_array_loc[i], i + 1);
5888 }
5889 }
5890 for (int i = 0; i < 8 && i < cache_size; ++i) {
5891 if (n.texture_cache_loc[i] != -1)
5892 glUniform1i(n.texture_cache_loc[i], i + 1);
5893 }
5894 }
5895#ifdef MIDI_ENABLED
5896 for (int i = 0; i < 4; ++i) {
5897 if (n.slider_loc[i] != -1)
5898 glUniform1f(n.slider_loc[i], midi_slider[i]);
5899 }
5900#endif
5902 }
5903
5904 /**
5905 * @brief Per-frame update: advance time_f, upload all uniforms to the active shader.
5906 *
5907 * Called once per frame from ACView::draw(). This method:
5908 * 1. Computes delta time from SDL performance counters.
5909 * 2. Advances `time_f` either by a normalized fixed frame interval or by
5910 * wall-clock delta (scaled by time_speed), or by audio amplitude when
5911 * audio-reactive time is enabled.
5912 * 3. Uploads time_f, iTime, iFrame, iTimeDelta, iDate, iFrameRate,
5913 * iMouse (with Shadertoy click semantics), and iResolution.
5914 * 4. Steps and uploads the acidcamGL-compatible oscillator uniforms.
5915 * 5. Uploads all audio amplitude and frequency-band uniforms (when
5916 * AUDIO_ENABLED is defined and time_audio is true).
5917 *
5918 * @param win GL window providing resolution and drawable size.
5919 */
5920 void update(gl::GLWindow *win) {
5921 auto &names = is3d ? program_names_3d : program_names_2d;
5922 if (names.find(index()) == names.end()) {
5923 return;
5924 }
5925 static Uint64 start_time = SDL_GetPerformanceCounter();
5926 static Uint64 last_frame_time = start_time;
5927 static uint64_t frame_counter = 0;
5928 static double last_good_delta = 1.0 / 60.0;
5929
5930 Uint64 now_time = SDL_GetPerformanceCounter();
5931 // double elapsed_time = static_cast<double>(now_time - start_time) / SDL_GetPerformanceFrequency();
5932 double delta_time = static_cast<double>(now_time - last_frame_time) / SDL_GetPerformanceFrequency();
5933 if (delta_time <= 0.0)
5934 delta_time = last_good_delta;
5935 else
5936 last_good_delta = delta_time;
5937 last_frame_time = now_time;
5938 frame_counter++;
5939
5940 if (time_audio == false && time_active) {
5941 const double time_delta = normalized_time && video_fps > 0.0 ? 1.0 / video_fps : delta_time;
5942 const float step = static_cast<float>(time_delta) * time_speed;
5943 time_f += step;
5944 } else {
5945#ifdef AUDIO_ENABLED
5946 if (time_audio) {
5947 const double time_delta = normalized_time && video_fps > 0.0 ? 1.0 / video_fps : delta_time;
5948 float dt_scalex = audio_delta ? static_cast<float>(time_delta) : 1.0f;
5949 const auto audio_metrics = audio_analyzer != nullptr ? audio_analyzer->metrics() : acmx2::audio::AudioMetrics{};
5950 const float audio_sensitivity = audio_analyzer != nullptr ? audio_analyzer->sensitivity() : 1.0f;
5951 float new_ampx = audio_metrics.amplitude * audio_sensitivity * time_speed * dt_scalex;
5952 time_f += new_ampx;
5953 }
5954#endif
5955 }
5956
5957 // Wrap at a large multiple of 2*PI so cos/sin remain continuous across
5958 // the wrap (any integer-multiple-of-2*PI bound preserves trig phase) and
5959 // shaders using mod(time_f, N) with small N still see a freely-growing
5960 // time_f. 65536 * 2*PI keeps float32 precision high (well under 2^20).
5961 constexpr float TIME_F_WRAP = 65536.0f * 6.2831853f;
5962 if (time_f >= TIME_F_WRAP)
5963 time_f -= TIME_F_WRAP;
5964 else if (time_f < 0.0f)
5965 time_f += TIME_F_WRAP;
5966
5967 if (std::isnan(time_f) || std::isinf(time_f))
5968 time_f = 1.0;
5969
5970 GLuint time_f_loc = names[index()].time_f;
5971 glUniform1f(time_f_loc, time_f);
5972 GLint loc = names[index()].loc;
5973 glUniform1f(loc, alpha);
5974 GLint iTimeLoc = names[index()].iTime;
5975 double currentTime = static_cast<double>(SDL_GetTicks64()) / 1000.0f;
5976 glUniform1f(iTimeLoc, currentTime);
5977 GLint iFrameLoc = names[index()].iFrame;
5978 glUniform1i(iFrameLoc, static_cast<int>(frame_counter % INT_MAX));
5979 GLint iTimeDeltaLoc = names[index()].iTimeDelta;
5980 glUniform1f(iTimeDeltaLoc, static_cast<float>(delta_time));
5981 GLint iDateLoc = names[index()].iDate;
5982 auto now = std::chrono::system_clock::now();
5983 std::time_t now_c = std::chrono::system_clock::to_time_t(now);
5984 std::tm localTime_buf{};
5985#ifdef _WIN32
5986 localtime_s(&localTime_buf, &now_c);
5987#else
5988 localtime_r(&now_c, &localTime_buf);
5989#endif
5990 float year = static_cast<float>(localTime_buf.tm_year + 1900);
5991 float month = static_cast<float>(localTime_buf.tm_mon + 1);
5992 float day = static_cast<float>(localTime_buf.tm_mday);
5993 float seconds = static_cast<float>(localTime_buf.tm_hour * 3600 + localTime_buf.tm_min * 60 + localTime_buf.tm_sec);
5994 glUniform4f(iDateLoc, year, month, day, seconds);
5995
5996 GLint iFrameRateLoc = names[index()].iFrameRate;
5997 if (iFrameRateLoc != -1) {
5998 glUniform1f(iFrameRateLoc, 24.0f);
5999 }
6000
6001 GLint iMouseLoc = names[index()].iMouse;
6002 GLint iMouseClickLoc = names[index()].iMouseClick;
6003
6004 int mouseX = 0, mouseY = 0;
6005 Uint32 mouseState = SDL_GetMouseState(&mouseX, &mouseY);
6006 float currentY = static_cast<float>(win->h - mouseY);
6007 float currentX = static_cast<float>(mouseX);
6008 if (mouseState & SDL_BUTTON(SDL_BUTTON_LEFT)) {
6009 if (!isDraggingLeft) {
6010 clickStartX = currentX;
6011 clickStartY = currentY;
6012 lastClickX = currentX;
6013 lastClickY = currentY;
6014 isDraggingLeft = true;
6015 wasClicked = true;
6016 }
6017 } else {
6018 isDraggingLeft = false;
6019 }
6020 if (mouseState & SDL_BUTTON(SDL_BUTTON_RIGHT)) {
6021 if (!isDraggingRight) {
6022 isDraggingRight = true;
6023 }
6024 } else {
6025 isDraggingRight = false;
6026 }
6027 float leftClickFlag = isDraggingLeft ? 1.0f : 0.0f;
6028 float rightClickFlag = isDraggingRight ? 1.0f : 0.0f;
6029 glUniform4f(iMouseLoc, currentX, currentY, leftClickFlag, rightClickFlag);
6030
6031 if (wasClicked && iMouseClickLoc != -1) {
6032 glUniform2f(iMouseClickLoc, lastClickX, lastClickY);
6033 }
6034
6035 GLint iResolution = names[index()].iResolution;
6036 glUniform2f(iResolution, win->w, win->h);
6037 if (names[index()].time_speed_loc != -1) {
6038 glUniform1f(names[index()].time_speed_loc, time_speed);
6039 }
6040
6042 uploadAcidCamUniforms(names[index()], index());
6043
6044#ifdef AUDIO_ENABLED
6045 const auto audio_metrics = audio_analyzer != nullptr ? audio_analyzer->metrics() : acmx2::audio::AudioMetrics{};
6046 const float audio_sensitivity = audio_analyzer != nullptr ? audio_analyzer->sensitivity() : 1.0f;
6047 GLuint amp_i = names[index()].amp;
6048 float amplitude = 1.0f;
6049 float dt_scale = audio_delta ? static_cast<float>(delta_time) : 1.0f;
6050 float new_amp = audio_metrics.amplitude * audio_sensitivity * time_speed * dt_scale;
6051 if (std::isnan(new_amp) || std::isinf(new_amp) || new_amp > 1e6f) {
6052 amplitude = 1.0f;
6053 } else {
6054 amplitude = new_amp;
6055 }
6056 glUniform1f(amp_i, amplitude * audio_warmup_envelope);
6057 GLuint amp_u = names[index()].amp_untouched;
6058 glUniform1f(amp_u, audio_metrics.amplitude);
6059 GLint iSampleRateLoc = names[index()].iSampleRate;
6060 if (iSampleRateLoc != -1) {
6061 const float sample_rate = audio_analyzer != nullptr ? static_cast<float>(audio_analyzer->sample_rate()) : 44100.0f;
6062 glUniform1f(iSampleRateLoc, sample_rate);
6063 }
6064 if (names[index()].iamp != -1) {
6065 glUniform1f(names[index()].iamp, audio_metrics.frequency);
6066 }
6067 {
6068 float sense = audio_sensitivity * 4.0f * audio_warmup_envelope;
6069 auto &n = names[index()];
6070 if (n.amp_peak != -1) {
6071 glUniform1f(n.amp_peak, std::sqrt(audio_metrics.peak) * sense);
6072 }
6073 if (n.amp_rms != -1) {
6074 glUniform1f(n.amp_rms, std::sqrt(audio_metrics.rms) * sense);
6075 }
6076 if (n.amp_smooth != -1) {
6077 glUniform1f(n.amp_smooth, std::sqrt(audio_metrics.smooth) * sense);
6078 }
6079 if (n.amp_low != -1) {
6080 glUniform1f(n.amp_low, std::sqrt(audio_metrics.low) * sense);
6081 }
6082 if (n.amp_mid != -1) {
6083 glUniform1f(n.amp_mid, std::sqrt(audio_metrics.mid) * sense);
6084 }
6085 if (n.amp_high != -1) {
6086 glUniform1f(n.amp_high, std::sqrt(audio_metrics.high) * sense);
6087 }
6088 }
6089 if (names[index()].spectrum_loc != -1) {
6090 glUniform1i(names[index()].spectrum_loc, SpectrumTexture::SPECTRUM_TEXTURE_UNIT);
6091 }
6092 auto &spectrum_names = names[index()];
6093 if (spectrum_names.spectrum_zero_loc != -1)
6094 glUniform1i(spectrum_names.spectrum_zero_loc, SpectrumTexture::SPECTRUM_TEXTURE_UNIT);
6095 if (spectrum_names.spectrum_history_loc != -1)
6096 glUniform1i(spectrum_names.spectrum_history_loc, SpectrumHistory::TEXTURE_UNIT);
6097 if (spectrum_names.spectrum_history_head_loc != -1)
6098 glUniform1i(spectrum_names.spectrum_history_head_loc, spectrum_history_head);
6099 if (spectrum_names.spectrum_history_size_loc != -1)
6100 glUniform1i(spectrum_names.spectrum_history_size_loc, audio_buffer_count);
6101#endif
6102 if (use_history_array) {
6103 auto &n = names[index()];
6104 if (n.history_loc != -1)
6105 glUniform1i(n.history_loc, 1);
6106 if (n.history_head_loc != -1)
6107 glUniform1i(n.history_head_loc, history_head);
6108 }
6109#ifdef MIDI_ENABLED
6110 for (int i = 0; i < 4; ++i) {
6111 if (names[index()].slider_loc[i] != -1)
6112 glUniform1f(names[index()].slider_loc[i], midi_slider[i]);
6113 }
6114#endif
6115 uploadCustomUniforms(names[index()]);
6116 }
6117
6118 /**
6119 * @brief Advance acidcamGL-compatible colour-alpha oscillators and random values.
6120 *
6121 * Each colour channel (R, G, B) is incremented by a random 0–0.99
6122 * and reset to 0.1 when exceeding 1.5. The `alpha` value
6123 * oscillates between 1.0 and 6.0. `random_var` receives four
6124 * new random bytes. These values are consumed by legacy acidcamGL
6125 * shaders that expect per-frame animation in these uniforms.
6126 */
6128 color_alpha_r += (rand() % 100) * 0.01f;
6129 color_alpha_g += (rand() % 100) * 0.01f;
6130 color_alpha_b += (rand() % 100) * 0.01f;
6131 if (color_alpha_r > 1.5f)
6132 color_alpha_r = 0.1f;
6133 if (color_alpha_g > 1.5f)
6134 color_alpha_g = 0.1f;
6135 if (color_alpha_b > 1.5f)
6136 color_alpha_b = 0.1f;
6137
6138 if (alpha_dir) {
6139 alpha += 0.1f;
6140 if (alpha >= 6.0f)
6141 alpha_dir = false;
6142 } else {
6143 alpha -= 0.1f;
6144 if (alpha <= 1.0f)
6145 alpha_dir = true;
6146 }
6147
6148 random_var = glm::vec4(rand() % 255, rand() % 255, rand() % 255, rand() % 255);
6149 }
6150
6151 /**
6152 * @brief Upload configured custom uniforms to the GPU for the given program.
6153 *
6154 * Uploads each configured custom uniform to its location cached in
6155 * @p data. Each upload is skipped if the location is -1 (uniform not
6156 * declared in that shader).
6157 *
6158 * @param data ProgramData containing cached custom-uniform locations.
6159 */
6160 void uploadCustomUniforms(const ProgramData &data) const {
6161 const std::size_t count = std::min(custom_uniforms.size(), data.custom_uniform_locs.size());
6162 for (std::size_t i = 0; i < count; ++i) {
6163 if (data.custom_uniform_locs[i] != -1) {
6164 glUniform1f(data.custom_uniform_locs[i], static_cast<float>(custom_uniforms[i].value));
6165 }
6166 }
6167 }
6168
6169 void uploadAcidCamUniforms(const ProgramData &n, size_t idx) {
6170 if (n.value_alpha_r != -1)
6171 glUniform1f(n.value_alpha_r, color_alpha_r);
6172 if (n.value_alpha_g != -1)
6173 glUniform1f(n.value_alpha_g, color_alpha_g);
6174 if (n.value_alpha_b != -1)
6175 glUniform1f(n.value_alpha_b, color_alpha_b);
6176 if (n.alpha_r_loc != -1)
6177 glUniform1f(n.alpha_r_loc, color_alpha_r);
6178 if (n.alpha_g_loc != -1)
6179 glUniform1f(n.alpha_g_loc, color_alpha_g);
6180 if (n.alpha_b_loc != -1)
6181 glUniform1f(n.alpha_b_loc, color_alpha_b);
6182 if (n.alpha_value != -1)
6183 glUniform1f(n.alpha_value, alpha);
6184 if (n.index_value != -1)
6185 glUniform1f(n.index_value, static_cast<float>(idx));
6186 if (n.optx_loc != -1)
6187 glUniform4fv(n.optx_loc, 1, glm::value_ptr(optx));
6188 if (n.random_var_loc != -1)
6189 glUniform4fv(n.random_var_loc, 1, glm::value_ptr(random_var));
6190 if (n.restore_black_loc != -1)
6191 glUniform1f(n.restore_black_loc, restore_black ? 1.0f : 0.0f);
6192 if (n.inc_value_loc != -1)
6193 glUniform4fv(n.inc_value_loc, 1, glm::value_ptr(inc_value));
6194 if (n.inc_valuex_loc != -1)
6195 glUniform4fv(n.inc_valuex_loc, 1, glm::value_ptr(inc_valuex));
6196 }
6197
6198 /**
6199 * @brief Step time_f forward manually (when auto-time is paused).
6200 * @param value Amount to add to time_f.
6201 */
6202 void incTime(float value) {
6203 if (!time_active) {
6204 // Wrap at a large 2*PI multiple to preserve trig continuity while
6205 // letting time_f grow large enough for mod(time_f, N) shader usage.
6206 constexpr float TIME_F_WRAP = 65536.0f * 6.2831853f;
6207 time_f += value;
6208 if (time_f >= TIME_F_WRAP)
6209 time_f = std::fmod(time_f, TIME_F_WRAP);
6210 if (time_f < 0.0f)
6211 time_f += TIME_F_WRAP;
6212 mx::system_out << "acmx2: Time stepped forward: " << time_f << "\n";
6213 fflush(stdout);
6214 }
6215 }
6216
6217 /**
6218 * @brief Step time_f backward manually (when auto-time is paused).
6219 *
6220 * Clamps at 1.0 to avoid negative or zero time values.
6221 *
6222 * @param value Amount to subtract from time_f.
6223 */
6224 void decTime(float value) {
6225 if (!time_active) {
6226 constexpr float TWO_PI = 6.2831853f;
6227
6228 // Subtract the value and apply a true modulo wrap
6229 // The double fmod + addition ensures the result is always positive
6230 time_f = std::fmod(std::fmod(time_f - value, TWO_PI) + TWO_PI, TWO_PI);
6231
6232 // If you specifically need to avoid 0.0 (e.g. to prevent division by zero in shaders)
6233 if (time_f < 0.0001f)
6234 time_f = TWO_PI;
6235
6236 mx::system_out << "acmx2: Time stepped back (wrapped): " << time_f << "\n";
6237 fflush(stdout);
6238 }
6239 }
6240
6241 /**
6242 * @brief Enable or disable automatic wall-clock time advancement.
6243 *
6244 * When disabled, time_f only changes via manual incTime/decTime
6245 * or audio-reactive mode, allowing the user to freeze and scrub
6246 * the shader's temporal state.
6247 *
6248 * @param t True to enable, false to pause.
6249 */
6250 void activeTime(bool t) {
6251 time_active = t;
6252 std::string enabled = ((t == true) ? "on" : "off");
6253 mx::system_out << "acmx2: active time: " << enabled << "\n";
6254 fflush(stdout);
6255 }
6256
6257 /**
6258 * @brief Enable or disable audio-reactive time advancement.
6259 *
6260 * When enabled, time_f is driven by analyzer amplitude and sensitivity
6261 * instead of wall-clock delta, making the shader evolve in
6262 * sync with the audio input.
6263 *
6264 * @param t True to enable audio-reactive time.
6265 */
6266 void audioTime(bool t) {
6267 time_audio = t;
6268 std::string enabled = ((t == true) ? "on" : "off");
6269 mx::system_out << "acmx2: audio time: " << enabled << "\n";
6270 fflush(stdout);
6271 }
6272 /**
6273 * @brief Toggle delta-time scaling for audio-reactive mode.
6274 *
6275 * When on, the audio amplitude contribution to time_f is
6276 * multiplied by the frame delta time, smoothing the advance
6277 * rate. When off, each frame advances by the raw amplitude.
6278 */
6281 mx::system_out << "acmx2: audio delta time: " << (audio_delta ? "on" : "off") << "\n";
6282 fflush(stdout);
6283 }
6284 bool audioDelta() const { return audio_delta; }
6285#ifdef AUDIO_ENABLED
6286 bool timeActive() const { return time_active; }
6287 bool timeAudio() const { return time_audio; }
6288 float getAmp() const { return audio_analyzer != nullptr ? audio_analyzer->metrics().amplitude : 0.0f; }
6289 float getAmpUntouched() const { return audio_analyzer != nullptr ? audio_analyzer->sensitivity() : 1.0f; }
6290#endif
6291 /// @brief Reserved for future SDL event handling inside the library.
6292 void event(SDL_Event &e) {}
6293};
6294
6296
6297/**
6298 * @brief Read a video's coded dimensions without opening a decoder.
6299 *
6300 * This metadata-only probe runs before the SDL/OpenGL window is created so
6301 * Default-resolution video playback can construct the window at its native
6302 * size. Resizing an already-created OpenGL window is asynchronous on some
6303 * window managers and can leave its drawable at the constructor's fallback
6304 * size.
6305 *
6306 * @return Native video dimensions, or @c std::nullopt when metadata cannot be
6307 * read. The normal decoder path reports the definitive error later.
6308 */
6309static std::optional<cv::Size> probe_video_size(const std::string &filename) {
6310 AVFormatContext *format_context = nullptr;
6311 if (avformat_open_input(&format_context, filename.c_str(), nullptr, nullptr) < 0) {
6312 return std::nullopt;
6313 }
6314
6315 std::optional<cv::Size> dimensions;
6316 if (avformat_find_stream_info(format_context, nullptr) >= 0) {
6317 const int stream_index = av_find_best_stream(format_context, AVMEDIA_TYPE_VIDEO, -1, -1, nullptr, 0);
6318 if (stream_index >= 0) {
6319 const AVCodecParameters *parameters = format_context->streams[stream_index]->codecpar;
6320 if (parameters->width > 0 && parameters->height > 0) {
6321 dimensions = cv::Size(parameters->width, parameters->height);
6322 }
6323 }
6324 }
6325
6326 avformat_close_input(&format_context);
6327 return dimensions;
6328}
6329
6330/**
6331 * @struct MXArguments
6332 * @brief Parsed command-line arguments for the ACMX2 application.
6333 *
6334 * Populated by the Argz parser in main() and forwarded to ACView.
6335 */
6337 std::string path, filename, ofilename;
6338 std::string graphic_file;
6340 int tw = 1280, th = 720;
6341 std::string crf = "23";
6343 std::string library;
6344 std::string fragment;
6345 std::string shader_file;
6346 std::string prefix_path = ".";
6347 std::string model_file = "cube.mxmod.z";
6348 std::string human_model; ///< PPHS human-segmentation ONNX model path (--human). Empty when disabled.
6349 bool human_background_only = false; ///< When true (with --human), shaders apply only to the background; person composited on top.
6350 float human_black = 0.35f; ///< --black: mask black point (shadow crush threshold).
6351 float human_white = 0.75f; ///< --white: mask white point (opacity saturation threshold).
6352 std::string edge_model; ///< Dexined edge-detection ONNX model path (--edge). Empty when disabled.
6353 std::string onnx_model; ///< Generic OnnxWrapper YAML config path (--onnx). Empty when disabled.
6354 int mode = 0;
6356 std::optional<cv::Size> sizev = std::nullopt;
6357 std::optional<cv::Size> csize = std::nullopt;
6358 double fps_value = 24.0;
6359 bool repeat = false;
6360 std::tuple<int, std::string, int> slib;
6361 bool full = false;
6362 bool cache = false;
6363 bool cache_array = false;
6365 int cache_size = 8;
6366 bool copy_audio = false;
6367 bool is3d = false;
6368#ifdef AUDIO_ENABLED
6369 bool audio_enabled = false;
6371 unsigned int audio_channels = 2;
6372 float audio_sensitivty = 0.25f;
6373 float audio_warm_rate = 0.5f; ///< Startup warmup envelope rate (1/sec). 0.5 ~= 2s to full strength.
6375 float record_gain = 1.0f;
6376 std::string audio_file;
6377 bool audio_trunc = false; ///< When true, stop playback when file audio reaches the end.
6378 bool audio_repeat = false; ///< When true, restart file audio from the beginning at EOF.
6379 int audio_buffers = 0; ///< Requested spectrum-history array depth.
6380#endif
6381 bool silent = false;
6382#ifdef MIDI_ENABLED
6383 std::string midi_map_file;
6384 int midi_device = -1;
6385#endif
6387 std::vector<int> gpu_filter_indices;
6389 bool disable_counter = false;
6391 std::vector<int> shader_pass_list;
6392 std::vector<std::string> shader_pass_files;
6394 bool build_cache = false;
6396 bool remove_broken = false; ///< True when `--remove-broken <path>` was specified.
6397 std::string remove_broken_path; ///< Library path passed to `--remove-broken`.
6398#ifdef __APPLE__
6399 // The shader binary cache is unsupported on macOS (no usable
6400 // glProgramBinary path under the system OpenGL framework), so it
6401 // is permanently disabled on Apple platforms.
6402 bool use_shader_cache = false;
6403#else
6404 bool use_shader_cache = true;
6405#endif
6406 float time_speed = 1.0f;
6407 bool normalized_time = false;
6408 std::string playlist_file;
6409 int autopilot_frames = 0; ///< Frames between random shader switches in autopilot mode (0 = disabled).
6410 bool autopilot_random_interval = false; ///< When true, randomize autopilot frame interval after each switch.
6411 int autopilot_random_timeout = 0; ///< Upper bound (inclusive) for randomized autopilot interval.
6412 double duration = 0.0;
6413 double max_size_mb = 0.0;
6414 float cross_fade_duration = 0.5f; ///< Crossfade duration in seconds when switching playlist shaders (default: 0.5).
6415 bool use_yuv = false;
6416 bool flip_output = false; ///< Vertical flip output frames when set (e.g., for HDR correction).
6417 FrameRotation frame_rotation = FrameRotation::None; ///< Optional input-frame rotation.
6418 bool png_output = false; ///< Video-file mode only: write PNG frames to a subdirectory instead of encoding video.
6419 int generate_interval = 0; ///< Save a PNG frame every N frames to a subdirectory (video or camera mode, 0 = disabled).
6420 bool no_drop = false; ///< In video mode, pace frame production to encoder throughput.
6421 bool display_filter = false; ///< Display current shader/stack and GPU filter overlay in upper-left.
6422 std::string watermark_text; ///< User watermark text (--use-watermark). When non-empty, watermark is enabled.
6423 int watermark_r = 255; ///< Watermark red channel (0-255), default magenta-pink.
6424 int watermark_g = 0; ///< Watermark green channel (0-255).
6425 int watermark_b = 150; ///< Watermark blue channel (0-255).
6426 bool interface_shm = false; ///< Enable interface shared-memory control channel (Qt launcher use).
6427 // User-configurable encoder quality (see EncodeOptions in mxwrite.hpp).
6428 EncodeOptions encode_opts{};
6429};
6430
6431/**
6432 * @struct FrameData
6433 * @brief A captured RGBA pixel buffer queued for the writer thread.
6434 *
6435 * Holds a vertically-flipped copy of the framebuffer contents
6436 * plus metadata for the async writer (dimensions, snapshot flag).
6437 */
6439 std::vector<unsigned char> pixels; ///< RGBA pixel data (SDR 8-bit RGBA, or HDR 16-bit RGBA when @c isHdr).
6440 int width = 0; ///< Frame width in pixels.
6441 int height = 0; ///< Frame height in pixels.
6442 bool isSnapshot = false; ///< True if this frame should be saved as a PNG snapshot.
6443 bool isWebPSnapshot = false; ///< True if this frame should be saved as a WebP snapshot.
6444 bool isRawSnapshot = false; ///< True if this frame should be saved as a raw RGBA file.
6445 bool isTiffSnapshot = false; ///< True if this frame should be saved as a TIFF snapshot (16-bit HDR, 8-bit SDR).
6446 bool isHdr = false; ///< True when @c pixels holds 16-bit PQ/HLG-encoded BT.2020 RGBA (8 bytes/pixel).
6447 int hdrTrc = 0; ///< AVColorTransferCharacteristic (PQ=16, HLG=18) when @c isHdr.
6448 bool usesTimelineClock = false; ///< Frame timing follows an external real-time clock.
6449 uint64_t timelineFrame = 0; ///< Clock position expressed in nominal video frames.
6450};
6451
6452/**
6453 * @class ACView
6454 * @brief Core rendering object—drives the capture → filter → shade → record pipeline.
6455 *
6456 * Inherits gl::GLObject (libmx2) so it can be hosted inside a GLWindow.
6457 * Manages:
6458 * - Video/camera/image capture (OpenCV, with a dedicated capture thread).
6459 * - GPU filtering via CUDA kernels (ac_gpu).
6460 * - GLSL shader application (ShaderLibrary), including multipass chains.
6461 * - 3D model rendering with camera controls (GLM).
6462 * - PBO-based async frame readback for recording and snapshots.
6463 * - Audio reactivity (RtAudio) and MIDI controller input (RtMidi).
6464 * - On-screen overlay (FPS, timer, shader name, MIDI status).
6465 *
6466 * @section threading Threading Model
6467 *
6468 * ACMX2 uses four concurrency domains. The diagram below shows data flow
6469 * and the synchronisation primitives that connect them.
6470 *
6471 * @verbatim
6472 *
6473 * ┌─────────────────────┐
6474 * │ Capture Thread │ (camera mode only)
6475 * │ cap.read(frame) │
6476 * │ cv::flip → push │
6477 * │ │
6478 * │ captureQueue │ std::queue<cv::Mat>, max 4 entries
6479 * │ captureQueueMutex │ protects the queue
6480 * │ captureQueueCondVar│ signals new frame available
6481 * │ captureRunning │ std::atomic<bool> for shutdown
6482 * └────────┬────────────┘
6483 * │
6484 * │ (main thread pops from captureQueue)
6485 * ▼
6486 * ┌─────────────────────────────────────────────────────────────┐
6487 * │ Main / GL Thread │
6488 * │ │
6489 * │ draw() ──── 1. Read frame from captureQueue (camera) or │
6490 * │ │ cap.read() (video file) or clone (image) │
6491 * │ │ 2. GPU-filter (CUDA kernels, TextureUploader) │
6492 * │ │ 3. Upload to GL texture │
6493 * │ │ 4. Render through ShaderLibrary (FBO) │
6494 * │ │ 5. 3D model compositing (if enabled) │
6495 * │ │ 6. PBO async readback (double-buffered) │
6496 * │ │ 7. Push FrameData into frameQueue │
6497 * │ └──── 8. Draw overlay (FPS, timer, shader name) │
6498 * │ │
6499 * │ frameQueue std::queue<FrameData> │
6500 * │ queueMutex protects the queue │
6501 * │ queueCondVar signals new frame / backpressure wait │
6502 * │ • camera mode: drops oldest if >30 frames │
6503 * │ • file mode: waits until queue <30 frames │
6504 * └──────────────┬──────────────────────────────────────────────┘
6505 * │
6506 * │ (writer thread pops from frameQueue)
6507 * ▼
6508 * ┌─────────────────────────────────┐
6509 * │ Writer Thread │
6510 * │ Dequeue FrameData │
6511 * │ • If isSnapshot: dispatch to │
6512 * │ SnapshotThreadPool (async │
6513 * │ PNG write via enqueue()) │
6514 * │ • Else: writer.write() / │
6515 * │ writer.write_at_pts() │
6516 * │ │
6517 * │ writerRunning atomic<bool> │
6518 * │ Starts only after the first │
6519 * │ valid source frame is ready │
6520 * └────────┬────────────────────────┘
6521 * │
6522 * │ (on shutdown, if audio was recorded)
6523 * ▼
6524 * ┌─────────────────────────────────┐
6525 * │ Mux Thread │
6526 * │ beginMuxing() launches this │
6527 * │ 1. Join capture + writer │
6528 * │ 2. Close the Writer │
6529 * │ 3. runMuxSync(): ffmpeg mux │
6530 * │ audio WAV + video MP4 │
6531 * │ 4. Set muxComplete = true │
6532 * │ │
6533 * │ isMuxing atomic<bool> │
6534 * │ muxComplete atomic<bool> │
6535 * └─────────────────────────────────┘
6536 *
6537 * **Synchronisation summary:**
6538 * - `captureQueueMutex` + `captureQueueCondVar` guard the camera queue.
6539 * Max size = 4; oldest frame is dropped when full (non-blocking producer).
6540 * - `queueMutex` + `queueCondVar` guard the writer queue.
6541 * In file mode the main thread *waits* if the queue exceeds 30 frames
6542 * (back-pressure). In camera mode it drops the oldest frame instead.
6543 * - `running`, `captureRunning`, `writerRunning` are `std::atomic<bool>`
6544 * flags tested every iteration to coordinate graceful shutdown.
6545 * - `isMuxing` and `muxComplete` coordinate the post-recording audio
6546 * mux step; the main draw loop shows a "Muxing audio…" overlay while
6547 * the mux thread runs ffmpeg.
6548 * - The SnapshotThreadPool is orthogonal—its own internal mutex/condvar
6549 * manage the PNG-writing worker threads. Tasks are fire-and-forget
6550 * from the writer thread's perspective.
6551 *
6552 * @endverbatim
6553 */
6554class ACView : public gl::GLObject {
6555#ifdef AUDIO_ENABLED
6556 bool audio_is_enabled = false;
6561 SpectrumTexture spectrumTex{audio_engine.analyzer()}; ///< 1D FFT magnitude texture for shaders.
6562 SpectrumHistory spectrumHistory{audio_engine.analyzer()}; ///< Runtime-sized FFT history array.
6563 int audio_buffer_count = 0; ///< Allocated history-array layer count.
6564 float audio_warmup_envelope = 0.0f; ///< Startup fade for audio-driven uniforms/textures.
6565 float audio_warmup_rate = 0.5f; ///< Warmup envelope slope in 1/sec (higher = faster ramp).
6566 std::chrono::steady_clock::time_point audio_warmup_last_tick = std::chrono::steady_clock::now();
6567 bool spectrum_scale_by_sense = false; ///< When true, scale spectrum 1D buffer by audio sensitivity.
6568 bool file_audio_mode = false; ///< True when audio comes from a file instead of RtAudio.
6569 std::string audio_file_path; ///< Path to the audio file used for file_audio_mode.
6570 bool audio_trunc_mode = false; ///< When true, stop playback when file audio samples are exhausted.
6571 bool audio_repeat_mode = false; ///< When true, restart file audio when its samples are exhausted.
6572
6573 /// Reset the startup envelope used to tame initial audio intensity.
6575 audio_warmup_envelope = 0.0f;
6576 audio_warmup_last_tick = std::chrono::steady_clock::now();
6577 }
6578
6579 /// Advance and return the startup warmup envelope in [0,1].
6581 if (!audio_is_enabled)
6582 return 1.0f;
6583 if (audio_warmup_rate <= 0.0f) {
6584 audio_warmup_envelope = 1.0f;
6585 return audio_warmup_envelope;
6586 }
6587 auto now = std::chrono::steady_clock::now();
6588 float delta_time = std::chrono::duration<float>(now - audio_warmup_last_tick).count();
6590 if (delta_time < 0.0f)
6591 delta_time = 0.0f;
6593 if (audio_warmup_envelope > 1.0f)
6594 audio_warmup_envelope = 1.0f;
6595 return audio_warmup_envelope;
6596 }
6597#endif
6598#ifdef MIDI_ENABLED
6599 RtMidiIn *midiIn = nullptr;
6600 bool midiOpen = false;
6601 struct MidiCode {
6602 int key1;
6603 int key2;
6604 unsigned char b0, b1, b2;
6605 };
6606 std::vector<MidiCode> midiCodes;
6607 // Track last knob CC values for continuous firing
6608 std::map<std::pair<unsigned char, unsigned char>, unsigned char> knobState;
6609 // Track previous knob values for delta-based direction (Pitch/Yaw)
6610 std::map<std::pair<unsigned char, unsigned char>, unsigned char> knobPrevValue;
6611 // Frame counter for velocity-sensitive knob rate
6612 std::map<std::pair<unsigned char, unsigned char>, int> knobFrameCount;
6613 // Last button action string for overlay display
6614 std::string lastMidiButton;
6615 std::chrono::steady_clock::time_point lastMidiButtonTime;
6616
6617 /**
6618 * @brief Map a virtual key code to a human-readable short name.
6619 *
6620 * Virtual codes 262–267 are arrow/page keys, 32–87 are ASCII,
6621 * and 500–513 are ACMX2 virtual codes for time/speed/rotation
6622 * knob actions.
6623 *
6624 * @param code Virtual key code from the MIDI map file.
6625 * @return Short label string (e.g. "Right", "SpdUp", "PitchDn").
6626 */
6627 static const char *midiKeyName(int code) {
6628 switch (code) {
6629 case 262:
6630 return "Right";
6631 case 263:
6632 return "Left";
6633 case 264:
6634 return "Down";
6635 case 265:
6636 return "Up";
6637 case 266:
6638 return "PgUp";
6639 case 267:
6640 return "PgDn";
6641 case 269:
6642 return "End";
6643 case 268:
6644 return "Home";
6645 case 260:
6646 return "Insert";
6647 case 261:
6648 return "Delete";
6649 case 298:
6650 return "F9";
6651 case 32:
6652 return "Space";
6653 case 44:
6654 return "Comma";
6655 case 45:
6656 return "Minus";
6657 case 46:
6658 return "Period";
6659 case 47:
6660 return "Slash";
6661 case 61:
6662 return "Plus/Eq";
6663 case 91:
6664 return "[";
6665 case 93:
6666 return "]";
6667 case 51:
6668 return "3";
6669 case 52:
6670 return "4";
6671 case 53:
6672 return "5";
6673 case 54:
6674 return "6";
6675 case 65:
6676 return "A";
6677 case 66:
6678 return "B";
6679 case 67:
6680 return "C";
6681 case 68:
6682 return "D";
6683 case 69:
6684 return "E";
6685 case 71:
6686 return "G";
6687 case 70:
6688 return "F";
6689 case 72:
6690 return "H";
6691 case 73:
6692 return "I";
6693 case 74:
6694 return "J";
6695 case 75:
6696 return "K";
6697 case 76:
6698 return "L";
6699 case 77:
6700 return "M";
6701 case 78:
6702 return "N";
6703 case 79:
6704 return "O";
6705 case 80:
6706 return "P";
6707 case 81:
6708 return "Q";
6709 case 82:
6710 return "R";
6711 case 83:
6712 return "S";
6713 case 84:
6714 return "T";
6715 case 85:
6716 return "U";
6717 case 86:
6718 return "V";
6719 case 87:
6720 return "W";
6721 case 88:
6722 return "X";
6723 case 89:
6724 return "Y";
6725 case 90:
6726 return "Z";
6727 case 500:
6728 return "TimeFwd";
6729 case 501:
6730 return "TimeBack";
6731 case 502:
6732 return "TimePause";
6733 case 503:
6734 return "TimeToggle";
6735 case 504:
6736 return "SpdUp";
6737 case 505:
6738 return "SpdDn";
6739 case 506:
6740 return "PitchUp";
6741 case 507:
6742 return "PitchDn";
6743 case 508:
6744 return "YawR";
6745 case 509:
6746 return "YawL";
6747 case 510:
6748 return "RotSpdUp";
6749 case 511:
6750 return "RotSpdDn";
6751 case 512:
6752 return "RollR";
6753 case 513:
6754 return "RollL";
6755 case 514:
6756 return "ScaleUp";
6757 case 515:
6758 return "ScaleDn";
6759 case 600:
6760 return "Slider1";
6761 case 601:
6762 return "Slider1";
6763 case 602:
6764 return "Slider2";
6765 case 603:
6766 return "Slider2";
6767 case 604:
6768 return "Slider3";
6769 case 605:
6770 return "Slider3";
6771 case 606:
6772 return "Slider4";
6773 case 607:
6774 return "Slider4";
6775 default:
6776 return "?";
6777 }
6778 }
6779
6780 /**
6781 * @brief Open a MIDI input port and load the key→action mapping file.
6782 *
6783 * Creates an RtMidiIn instance, opens the given device port (or
6784 * port 0 as fallback), then parses the mapping file line by line.
6785 * Each line maps a `key1:key2` pair to a MIDI triplet `{b0 b1 b2}`,
6786 * where key2 != 0 denotes a continuous knob (key1 = CW direction,
6787 * key2 = CCW direction).
6788 *
6789 * @param mapFile Path to the `.midi_cfg` mapping file.
6790 * @param deviceIndex MIDI input port index (-1 for default / port 0).
6791 */
6792 void initMidi(const std::string &mapFile, int deviceIndex) {
6793 try {
6794 midiIn = new RtMidiIn();
6795 midiIn->ignoreTypes(false, false, false);
6796 unsigned int ports = midiIn->getPortCount();
6797 if (ports == 0) {
6798 mx::system_out << "acmx2: No MIDI input devices found\n";
6799 delete midiIn;
6800 midiIn = nullptr;
6801 return;
6802 }
6803 unsigned int port = (deviceIndex >= 0 && deviceIndex < static_cast<int>(ports)) ? static_cast<unsigned int>(deviceIndex) : 0;
6804 mx::system_out << "acmx2: Opening MIDI port " << port << ": " << midiIn->getPortName(port) << "\n";
6805 midiIn->openPort(port);
6806 midiOpen = true;
6807
6808 std::ifstream file(mapFile);
6809 if (!file.is_open()) {
6810 mx::system_err << "acmx2: Could not open MIDI map file: " << mapFile << "\n";
6811 return;
6812 }
6813 std::string line;
6814 while (std::getline(file, line)) {
6815 std::istringstream iss(line);
6816 std::string keyPair;
6817 if (!(iss >> keyPair))
6818 continue;
6819 auto colonPos = keyPair.find(':');
6820 if (colonPos == std::string::npos)
6821 continue;
6822 int k1 = std::stoi(keyPair.substr(0, colonPos));
6823 int k2 = std::stoi(keyPair.substr(colonPos + 1));
6824 char brace;
6825 if (!(iss >> brace) || brace != '{')
6826 continue;
6827 int b0, b1, b2;
6828 if (!(iss >> b0 >> b1 >> b2))
6829 continue;
6830 midiCodes.push_back({k1, k2, static_cast<unsigned char>(b0), static_cast<unsigned char>(b1), static_cast<unsigned char>(b2)});
6831 }
6832 mx::system_out << "acmx2: Loaded " << midiCodes.size() << " MIDI mapping(s)\n";
6833 fflush(stdout);
6834 } catch (RtMidiError &e) {
6835 mx::system_err << "acmx2: MIDI error: " << e.getMessage() << "\n";
6836 if (midiIn) {
6837 delete midiIn;
6838 midiIn = nullptr;
6839 }
6840 }
6841 }
6842
6843 /**
6844 * @brief Convert an ACMX2 virtual key code to an SDL_Keycode.
6845 *
6846 * Direct keyboard codes (e.g. 262 → SDLK_RIGHT) are mapped;
6847 * virtual knob codes 504–513 return SDLK_UNKNOWN because they
6848 * are handled directly in pollMidi().
6849 *
6850 * @param code Virtual key code from the MIDI map.
6851 * @return Corresponding SDL_Keycode, or SDLK_UNKNOWN.
6852 */
6853 SDL_Keycode midiKeyToSDL(int code) {
6854 switch (code) {
6855 case 262:
6856 return SDLK_RIGHT;
6857 case 263:
6858 return SDLK_LEFT;
6859 case 264:
6860 return SDLK_DOWN;
6861 case 265:
6862 return SDLK_UP;
6863 case 266:
6864 return SDLK_PAGEUP;
6865 case 267:
6866 return SDLK_PAGEDOWN;
6867 case 268:
6868 return SDLK_HOME;
6869 case 269:
6870 return SDLK_END;
6871 case 260:
6872 return SDLK_INSERT;
6873 case 261:
6874 return SDLK_DELETE;
6875 case 298:
6876 return SDLK_F9;
6877 case 32:
6878 return SDLK_SPACE;
6879 case 44:
6880 return SDLK_COMMA;
6881 case 45:
6882 return SDLK_MINUS;
6883 case 46:
6884 return SDLK_PERIOD;
6885 case 47:
6886 return SDLK_SLASH;
6887 case 61:
6888 return SDLK_EQUALS;
6889 case 91:
6890 return SDLK_LEFTBRACKET;
6891 case 93:
6892 return SDLK_RIGHTBRACKET;
6893 case 51:
6894 return SDLK_3;
6895 case 52:
6896 return SDLK_4;
6897 case 53:
6898 return SDLK_5;
6899 case 54:
6900 return SDLK_6;
6901 case 65:
6902 return SDLK_a;
6903 case 66:
6904 return SDLK_b;
6905 case 67:
6906 return SDLK_c;
6907 case 68:
6908 return SDLK_d;
6909 case 69:
6910 return SDLK_e;
6911 case 71:
6912 return SDLK_g;
6913 case 70:
6914 return SDLK_f;
6915 case 72:
6916 return SDLK_h;
6917 case 73:
6918 return SDLK_i;
6919 case 74:
6920 return SDLK_j;
6921 case 76:
6922 return SDLK_l;
6923 case 77:
6924 return SDLK_m;
6925 case 78:
6926 return SDLK_n;
6927 case 79:
6928 return SDLK_o;
6929 case 80:
6930 return SDLK_p;
6931 case 81:
6932 return SDLK_q;
6933 case 82:
6934 return SDLK_r;
6935 case 83:
6936 return SDLK_s;
6937 case 84:
6938 return SDLK_t;
6939 case 85:
6940 return SDLK_u;
6941 case 75:
6942 return SDLK_k;
6943 case 86:
6944 return SDLK_v;
6945 case 87:
6946 return SDLK_w;
6947 case 88:
6948 return SDLK_x;
6949 case 89:
6950 return SDLK_y;
6951 case 90:
6952 return SDLK_z;
6953 // Virtual codes 504-513 handled directly in pollMidi
6954 case 504:
6955 case 505:
6956 case 506:
6957 case 507:
6958 case 508:
6959 case 509:
6960 case 510:
6961 case 511:
6962 case 512:
6963 case 513:
6964 return SDLK_UNKNOWN;
6965 default:
6966 return SDLK_UNKNOWN;
6967 }
6968 }
6969
6970 /**
6971 * @brief Synthesise an SDL_KEYUP event and dispatch it through event().
6972 * @param key The SDL_Keycode to inject.
6973 * @param win The GL window that receives the event.
6974 */
6975 void injectKey(SDL_Keycode key, gl::GLWindow *win) {
6976 SDL_Event ev{};
6977 ev.type = SDL_KEYUP;
6978 ev.key.keysym.sym = key;
6979 ev.key.state = SDL_RELEASED;
6980 event(win, ev);
6981 }
6982
6983 /**
6984 * @brief Drain all pending MIDI messages and dispatch mapped actions.
6985 *
6986 * Called at the top of every draw() frame. Button presses fire
6987 * immediately via injectKey(); continuous knobs update `knobState`
6988 * and are rate-limited by a velocity-sensitive frame-skip counter.
6989 * Knob distance from centre (64) controls speed: far = every frame,
6990 * near-centre = every ~16 frames. Pitch/Yaw/Roll knobs use delta
6991 * from the previous value to determine direction.
6992 *
6993 * @param win GL window for injecting key events.
6994 */
6995 void pollMidi(gl::GLWindow *win) {
6996 if (!midiIn || !midiOpen)
6997 return;
6998 // Drain all pending MIDI messages and update knob state
6999 std::vector<unsigned char> msg;
7000 while (true) {
7001 midiIn->getMessage(&msg);
7002 if (msg.size() < 3)
7003 break;
7004 for (const auto &mc : midiCodes) {
7005 if (msg[0] == mc.b0 && msg[1] == mc.b1) {
7006 if (mc.key2 != 0) {
7007 // Knob: store latest value, handled below
7008 knobState[{mc.b0, mc.b1}] = msg[2];
7009 } else if (msg[2] == mc.b2) {
7010 // Button: fire immediately on exact match
7011 SDL_Keycode k = midiKeyToSDL(mc.key1);
7012 if (k != SDLK_UNKNOWN) {
7013 injectKey(k, win);
7014 lastMidiButton = midiKeyName(mc.key1);
7015 lastMidiButtonTime = std::chrono::steady_clock::now();
7016 } else if (mc.key1 == 510) {
7017 cameraRotationSpeed += 0.5f;
7018 if (cameraRotationSpeed > 50.0f)
7019 cameraRotationSpeed = 50.0f;
7020 mx::system_out << "acmx2: Camera rotation speed: " << cameraRotationSpeed << "\n";
7021 fflush(stdout);
7022 lastMidiButton = "RotSpdUp";
7023 lastMidiButtonTime = std::chrono::steady_clock::now();
7024 } else if (mc.key1 == 511) {
7025 cameraRotationSpeed -= 0.5f;
7026 if (cameraRotationSpeed < 0.5f)
7027 cameraRotationSpeed = 0.5f;
7028 mx::system_out << "acmx2: Camera rotation speed: " << cameraRotationSpeed << "\n";
7029 fflush(stdout);
7030 lastMidiButton = "RotSpdDn";
7031 lastMidiButtonTime = std::chrono::steady_clock::now();
7032 }
7033 }
7034 }
7035 }
7036 }
7037 // Fire keys for knobs with velocity-sensitive rate.
7038 // Distance from center (64) controls speed:
7039 // dist 1-5 -> fire every 16 frames (slowest)
7040 // dist 63 -> fire every frame (fastest)
7041 for (const auto &mc : midiCodes) {
7042 if (mc.key2 == 0)
7043 continue;
7044 auto key = std::make_pair(mc.b0, mc.b1);
7045 auto it = knobState.find(key);
7046 if (it == knobState.end())
7047 continue;
7048 unsigned char val = it->second;
7049 // Slider knobs: map CC value (0-127) directly to 0.0-1.0
7050 if (mc.key1 >= 600 && mc.key1 <= 606 && (mc.key1 % 2 == 0)) {
7051 int idx = (mc.key1 - 600) / 2;
7052 library.setMidiSlider(idx, static_cast<float>(val) / 127.0f);
7053 continue;
7054 }
7055 if (val == 64)
7056 continue; // dead zone at center
7057 int dist = (val > 64) ? (val - 64) : (64 - val); // 1..64
7058 // Map distance to frame skip: max dist (63-64) = 1 (every frame),
7059 // min dist (1) = 16 (every 16th frame)
7060 int skipFrames = std::max(1, 17 - (dist * 16 / 63));
7061 int &counter = knobFrameCount[key];
7062 if (++counter >= skipFrames) {
7063 counter = 0;
7064 // For Pitch/Yaw knobs, use delta from previous value
7065 // so turning the physical knob one direction always
7066 // rotates the same way (no ping-pong at end-stops).
7067 bool useDelta = (mc.key1 == 506 || mc.key1 == 508 || mc.key1 == 512);
7068 int activeKey;
7069 if (useDelta) {
7070 auto prevIt = knobPrevValue.find(key);
7071 unsigned char prev = (prevIt != knobPrevValue.end()) ? prevIt->second : 64;
7072 activeKey = (val >= prev) ? mc.key1 : mc.key2;
7073 knobPrevValue[key] = val;
7074 } else {
7075 activeKey = (val > 64) ? mc.key1 : mc.key2;
7076 }
7077 // Handle direct-action virtual codes
7078 if (activeKey == 504) {
7079 library.incTimeSpeed(0.1f);
7080 } else if (activeKey == 505) {
7081 library.decTimeSpeed(0.1f);
7082 } else if (activeKey == 506) {
7084 modelRotX = fmod(modelRotX, 360.0f);
7085 mx::system_out << "acmx2: Model RotX: " << modelRotX << "\n";
7086 fflush(stdout);
7087 } else if (activeKey == 507) {
7088 modelRotX -= cameraRotationSpeed * 0.33f;
7089 modelRotX = fmod(modelRotX + 360.0f, 360.0f);
7090 mx::system_out << "acmx2: Model RotX: " << modelRotX << "\n";
7091 fflush(stdout);
7092 } else if (activeKey == 508) {
7094 modelRotY = fmod(modelRotY, 360.0f);
7095 mx::system_out << "acmx2: Model RotY: " << modelRotY << "\n";
7096 fflush(stdout);
7097 } else if (activeKey == 509) {
7099 modelRotY = fmod(modelRotY + 360.0f, 360.0f);
7100 mx::system_out << "acmx2: Model RotY: " << modelRotY << "\n";
7101 fflush(stdout);
7102 } else if (activeKey == 512) {
7104 modelRotZ = fmod(modelRotZ, 360.0f);
7105 mx::system_out << "acmx2: Model RotZ: " << modelRotZ << "\n";
7106 fflush(stdout);
7107 } else if (activeKey == 513) {
7109 modelRotZ = fmod(modelRotZ + 360.0f, 360.0f);
7110 mx::system_out << "acmx2: Model RotZ: " << modelRotZ << "\n";
7111 fflush(stdout);
7112 } else if (activeKey == 514) {
7113 modelRenderScale += 0.05f;
7114 mx::system_out << "acmx2: Model scale increased to " << modelRenderScale << "\n";
7115 fflush(stdout);
7116 } else if (activeKey == 515) {
7117 modelRenderScale -= 0.05f;
7118 if (modelRenderScale < 0.05f)
7119 modelRenderScale = 0.05f;
7120 mx::system_out << "acmx2: Model scale decreased to " << modelRenderScale << "\n";
7121 fflush(stdout);
7122 } else {
7123 SDL_Keycode k = (val > 64) ? midiKeyToSDL(mc.key1) : midiKeyToSDL(mc.key2);
7124 if (k != SDLK_UNKNOWN)
7125 injectKey(k, win);
7126 }
7127 }
7128 }
7129 }
7130
7131 /// @brief Close the MIDI input port and free the RtMidiIn instance.
7133 if (midiIn) {
7134 if (midiOpen)
7135 midiIn->closePort();
7136 delete midiIn;
7137 midiIn = nullptr;
7138 midiOpen = false;
7139 }
7140 }
7141
7142 /**
7143 * @brief Draw the MIDI status overlay (active indicator, knob bars, last button).
7144 *
7145 * Shows a green "MIDI Active" header, a bar graph for each mapped
7146 * knob with its current value and direction label, and the last
7147 * pressed button name with a 2-second fade-out.
7148 *
7149 * @param win GL window (text rendering context).
7150 * @param font Font for overlay text.
7151 * @param startY Y-coordinate to begin drawing.
7152 */
7153 void drawMidiOverlay(gl::GLWindow *win, mx::Font &font, int startY) {
7154 if (!midiOpen || midiCodes.empty())
7155 return;
7156 int y = startY;
7157 win->text.setColor({0, 255, 0, 255});
7158 win->text.printText_Blended(font, 10, y, "MIDI Active");
7159 y += 25;
7160 // Find max label width for alignment
7161 size_t maxLabelLen = 0;
7162 for (const auto &mc : midiCodes) {
7163 if (mc.key2 == 0)
7164 continue;
7165 size_t len = std::string(midiKeyName(mc.key1)).size() + 1 + std::string(midiKeyName(mc.key2)).size();
7166 if (len > maxLabelLen)
7167 maxLabelLen = len;
7168 }
7169 // Show knob states
7170 win->text.setColor({0, 255, 0, 255});
7171 for (const auto &mc : midiCodes) {
7172 if (mc.key2 == 0)
7173 continue;
7174 auto it = knobState.find({mc.b0, mc.b1});
7175 unsigned char val = (it != knobState.end()) ? it->second : 64;
7176 const char *dir = (val == 64) ? "--" : (val > 64) ? midiKeyName(mc.key1) : midiKeyName(mc.key2);
7177 int barLen = 20;
7178 int pos = (val * barLen) / 127;
7179 std::string bar(barLen, '-');
7180 bar[barLen / 2] = '|';
7181 if (pos < barLen)
7182 bar[pos] = '#';
7183 std::string label = std::string(midiKeyName(mc.key1)) + "/" + midiKeyName(mc.key2);
7184 // Pad label to align bars
7185 while (label.size() < maxLabelLen)
7186 label += ' ';
7187 std::ostringstream oss;
7188 oss << label << " [" << bar << "] " << std::setw(3) << static_cast<int>(val) << " " << dir;
7189 win->text.printText_Blended(font, 10, y, oss.str());
7190 y += 22;
7191 }
7192 // Show last button press (fade after 2 seconds)
7193 auto elapsed = std::chrono::duration_cast<std::chrono::milliseconds>(std::chrono::steady_clock::now() - lastMidiButtonTime).count();
7194 if (!lastMidiButton.empty() && elapsed < 2000) {
7195 int alpha = (elapsed < 1500) ? 255 : 255 - static_cast<int>((elapsed - 1500) * 255 / 500);
7196 win->text.setColor({0, 255, 0, static_cast<unsigned char>(std::max(0, alpha))});
7197 win->text.printText_Blended(font, 10, y, "Button: " + lastMidiButton);
7198 y += 22;
7199 }
7200 }
7201#endif
7202 bool isPaused = false;
7203 bool isFrozen = false;
7204 bool shaderLocked = false;
7205 GLuint pboIds[2] = {0, 0};
7206#ifdef ACMX2_WITH_CUDA
7207 cudaGraphicsResource *recordCudaPboResources[2] = {nullptr, nullptr};
7208#endif
7209 int pboIndex = 0;
7211 bool recording_pbo_uses_timeline_clock[2] = {false, false};
7212 uint64_t recording_pbo_timeline_frame[2] = {0, 0};
7215
7216 public:
7217 void requestStop() { running = false; }
7220 running = false;
7221 }
7223
7224 // --- HDR pipeline helpers ------------------------------------------------
7225 //
7226 // These functions are no-ops when @ref input_is_hdr is false, so the
7227 // SDR frame path remains untouched. When HDR is active, they build and
7228 // run the decode/encode fullscreen passes that wrap the existing user
7229 // shader chain.
7230 // -------------------------------------------------------------------------
7231
7232 /**
7233 * @brief Lazily allocate the HDR decode/encode textures, FBOs and shader
7234 * programs. Idempotent.
7235 * @param w Target width (matches shader pass width).
7236 * @param h Target height.
7237 */
7238 void ensureHdrResources(int w, int h) {
7239 if (!input_is_hdr) {
7240 return;
7241 }
7242 const bool resize_needed = (hdr_resource_w != w || hdr_resource_h != h);
7243 if (hdr_linear_video_texture == 0) {
7244 glGenTextures(1, &hdr_linear_video_texture);
7245 glBindTexture(GL_TEXTURE_2D, hdr_linear_video_texture);
7246 glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA16F, w, h, 0, GL_RGBA, GL_HALF_FLOAT, nullptr);
7247 glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
7248 glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
7249 glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
7250 glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
7251 } else if (resize_needed) {
7252 glBindTexture(GL_TEXTURE_2D, hdr_linear_video_texture);
7253 glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA16F, w, h, 0, GL_RGBA, GL_HALF_FLOAT, nullptr);
7254 glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
7255 glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
7256 glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
7257 glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
7258 }
7259 if (hdr_linear_video_fbo == 0) {
7260 glGenFramebuffers(1, &hdr_linear_video_fbo);
7261 glBindFramebuffer(GL_FRAMEBUFFER, hdr_linear_video_fbo);
7262 glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, hdr_linear_video_texture, 0);
7263 if (glCheckFramebufferStatus(GL_FRAMEBUFFER) != GL_FRAMEBUFFER_COMPLETE) {
7264 mx::system_err << "acmx2: HDR linear-video FBO incomplete\n";
7265 }
7266 glBindFramebuffer(GL_FRAMEBUFFER, 0);
7267 }
7268 if (hdr_encoded_texture == 0) {
7269 glGenTextures(1, &hdr_encoded_texture);
7270 glBindTexture(GL_TEXTURE_2D, hdr_encoded_texture);
7271 // GL_RGBA16 = 16-bit unsigned normalised per channel. Readback
7272 // as GL_UNSIGNED_SHORT gives us 16-bit PQ code values that the
7273 // writer quantises to 10-bit for P010.
7274 glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA16, w, h, 0, GL_RGBA, GL_UNSIGNED_SHORT, nullptr);
7275 glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
7276 glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
7277 glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
7278 glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
7279 } else if (resize_needed) {
7280 glBindTexture(GL_TEXTURE_2D, hdr_encoded_texture);
7281 glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA16, w, h, 0, GL_RGBA, GL_UNSIGNED_SHORT, nullptr);
7282 glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
7283 glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
7284 glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
7285 glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
7286 }
7287 if (hdr_encoded_fbo == 0) {
7288 glGenFramebuffers(1, &hdr_encoded_fbo);
7289 glBindFramebuffer(GL_FRAMEBUFFER, hdr_encoded_fbo);
7290 glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, hdr_encoded_texture, 0);
7291 if (glCheckFramebufferStatus(GL_FRAMEBUFFER) != GL_FRAMEBUFFER_COMPLETE) {
7292 mx::system_err << "acmx2: HDR encoded FBO incomplete\n";
7293 }
7294 glBindFramebuffer(GL_FRAMEBUFFER, 0);
7295 }
7296 if (hdr_decode_shader.id() == 0) {
7297 if (!hdr_decode_shader.loadProgramFromText(kHdrVertPassthrough, kHdrDecodeFrag)) {
7298 mx::system_err << "acmx2: failed to compile HDR decode shader\n";
7299 }
7300 }
7301 if (hdr_encode_shader.id() == 0) {
7302 if (!hdr_encode_shader.loadProgramFromText(kHdrVertPassthrough, kHdrEncodeFrag)) {
7303 mx::system_err << "acmx2: failed to compile HDR encode shader\n";
7304 }
7305 }
7306 if (display_flip_shader.id() == 0) {
7308 mx::system_err << "acmx2: failed to compile display flip shader\n";
7309 }
7310 }
7311 hdr_resource_w = w;
7312 hdr_resource_h = h;
7313 }
7314
7315 /**
7316 * @brief Run the PQ/HLG -> linear BT.2020 decode pass, sampling
7317 * @c camera_texture and writing into @c hdr_linear_video_texture.
7318 *
7319 * The sprite quad is re-used so the pixel coverage matches the rest of
7320 * the pipeline. After this call, user shaders should sample
7321 * @c hdr_linear_video_texture (via @c sampleSourceTextureForHdr()).
7322 */
7323 void runHdrDecodePass(int w, int h) {
7324 if (!input_is_hdr || hdr_decode_shader.id() == 0) {
7325 return;
7326 }
7327 const int transfer_mode = (input_hdr_trc == AVCOL_TRC_ARIB_STD_B67) ? 2 : 1;
7328
7329 GLint prev_fbo = 0;
7330 glGetIntegerv(GL_FRAMEBUFFER_BINDING, &prev_fbo);
7331 GLint prev_viewport[4] = {0};
7332 glGetIntegerv(GL_VIEWPORT, prev_viewport);
7333
7334 glBindFramebuffer(GL_FRAMEBUFFER, hdr_linear_video_fbo);
7335 glViewport(0, 0, w, h);
7336 glDisable(GL_DEPTH_TEST);
7337 glDisable(GL_BLEND);
7338 glClear(GL_COLOR_BUFFER_BIT);
7339
7340 hdr_decode_shader.useProgram();
7341 hdr_decode_shader.setUniform("mv_matrix", glm::mat4(1.0f));
7342 hdr_decode_shader.setUniform("proj_matrix", glm::mat4(1.0f));
7343 glUniform1i(glGetUniformLocation(hdr_decode_shader.id(), "samp"), 0);
7344 glUniform1i(glGetUniformLocation(hdr_decode_shader.id(), "transfer"), transfer_mode);
7345 sprite.setShader(&hdr_decode_shader);
7346 sprite.setName("samp");
7347 sprite.draw(camera_texture, 0, 0, w, h);
7348
7349 glBindFramebuffer(GL_FRAMEBUFFER, prev_fbo);
7350 glViewport(prev_viewport[0], prev_viewport[1], prev_viewport[2], prev_viewport[3]);
7351 }
7352
7353 /**
7354 * @brief Run the linear BT.2020 -> PQ/HLG encode pass from @p src_tex into
7355 * @c hdr_encoded_texture. Call this just before the PBO readback.
7356 */
7357 void runHdrEncodePass(GLuint src_tex, int w, int h) {
7358 if (!input_is_hdr || hdr_encode_shader.id() == 0) {
7359 return;
7360 }
7361 const int transfer_mode = (input_hdr_trc == AVCOL_TRC_ARIB_STD_B67) ? 2 : 1;
7362
7363 GLint prev_fbo = 0;
7364 glGetIntegerv(GL_FRAMEBUFFER_BINDING, &prev_fbo);
7365 GLint prev_viewport[4] = {0};
7366 glGetIntegerv(GL_VIEWPORT, prev_viewport);
7367
7368 glBindFramebuffer(GL_FRAMEBUFFER, hdr_encoded_fbo);
7369 glViewport(0, 0, w, h);
7370 glDisable(GL_DEPTH_TEST);
7371 glDisable(GL_BLEND);
7372 glClear(GL_COLOR_BUFFER_BIT);
7373
7374 hdr_encode_shader.useProgram();
7375 hdr_encode_shader.setUniform("mv_matrix", glm::mat4(1.0f));
7376 hdr_encode_shader.setUniform("proj_matrix", glm::mat4(1.0f));
7377 glUniform1i(glGetUniformLocation(hdr_encode_shader.id(), "samp"), 0);
7378 glUniform1i(glGetUniformLocation(hdr_encode_shader.id(), "transfer"), transfer_mode);
7379 sprite.setShader(&hdr_encode_shader);
7380 sprite.setName("samp");
7381 sprite.draw(src_tex, 0, 0, w, h);
7382
7383 glBindFramebuffer(GL_FRAMEBUFFER, prev_fbo);
7384 glViewport(prev_viewport[0], prev_viewport[1], prev_viewport[2], prev_viewport[3]);
7385 }
7386
7387 /**
7388 * @brief Upload a 16-bit-per-channel RGBA frame (from @c FFMpegVideoReader::readHdr())
7389 * into @c camera_texture. The texture must have been allocated as
7390 * GL_RGBA16 (done during HDR resource init).
7391 */
7392 void uploadHdrFrame(const cv::Mat &rgba16) {
7393 if (rgba16.empty())
7394 return;
7395 glBindTexture(GL_TEXTURE_2D, camera_texture);
7396 if (hdr_upload_tex_w != rgba16.cols || hdr_upload_tex_h != rgba16.rows) {
7397 // The decoded HDR frame can differ from the output resolution
7398 // when --resolution is used. Ensure camera_texture matches the
7399 // source frame dimensions and 16-bit normalized format before
7400 // sub-image upload; otherwise drivers may crash on invalid
7401 // glTexSubImage2D parameters.
7402 glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA16, rgba16.cols, rgba16.rows, 0, GL_RGBA, GL_UNSIGNED_SHORT, nullptr);
7403 glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
7404 glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
7405 glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
7406 glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
7407 hdr_upload_tex_w = rgba16.cols;
7408 hdr_upload_tex_h = rgba16.rows;
7409 }
7410 glPixelStorei(GL_UNPACK_ALIGNMENT, 2);
7411 glPixelStorei(GL_UNPACK_ROW_LENGTH, static_cast<GLint>(rgba16.step / (4 * sizeof(uint16_t))));
7412 glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, rgba16.cols, rgba16.rows, GL_RGBA, GL_UNSIGNED_SHORT, rgba16.ptr());
7413 glPixelStorei(GL_UNPACK_ROW_LENGTH, 0);
7414 glPixelStorei(GL_UNPACK_ALIGNMENT, 4);
7415 glBindTexture(GL_TEXTURE_2D, 0);
7416 }
7417
7418 /** Allocate the shared fragment/compute ping-pong targets on demand. */
7419 void ensurePassTargets(gl::GLWindow *win) {
7420 if (passFBO[0] != 0 && pass_target_width == win->w && pass_target_height == win->h) {
7421 return;
7422 }
7423 for (int pass = 0; pass < 2; ++pass) {
7424 if (passFBO[pass] != 0)
7425 glDeleteFramebuffers(1, &passFBO[pass]);
7426 if (passTexture[pass] != 0)
7427 glDeleteTextures(1, &passTexture[pass]);
7428 passFBO[pass] = 0;
7429 passTexture[pass] = 0;
7430 }
7431 for (int pass = 0; pass < 2; ++pass) {
7432 glGenFramebuffers(1, &passFBO[pass]);
7433 glGenTextures(1, &passTexture[pass]);
7434 glBindTexture(GL_TEXTURE_2D, passTexture[pass]);
7435 glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA16F, win->w, win->h, 0, GL_RGBA, GL_HALF_FLOAT, nullptr);
7436 glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
7437 glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
7438 glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
7439 glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
7440 glBindFramebuffer(GL_FRAMEBUFFER, passFBO[pass]);
7441 glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, passTexture[pass], 0);
7442 if (glCheckFramebufferStatus(GL_FRAMEBUFFER) != GL_FRAMEBUFFER_COMPLETE) {
7443 throw mx::Exception("acmx2: shader pass framebuffer is not complete");
7444 }
7445 }
7446 pass_target_width = win->w;
7447 pass_target_height = win->h;
7448 }
7449
7450 /** Bind history textures required by a cache-aware pass. */
7451 void bindPassHistoryTextures(size_t shader_index) {
7452 if (!texture_cache || !frame_cache.isFull() || !library.isCache2D(shader_index)) {
7453 return;
7454 }
7455 if (texture_cache_array) {
7456 glActiveTexture(GL_TEXTURE1);
7457 glBindTexture(GL_TEXTURE_2D_ARRAY, frame_cache.historyTexture());
7458 } else {
7459 for (int cache_index = 0; cache_index < library.cacheSize(); ++cache_index) {
7460 glActiveTexture(GL_TEXTURE1 + cache_index);
7461 glBindTexture(GL_TEXTURE_2D, frame_cache.textureAt(cache_index));
7462 }
7463 }
7464 glActiveTexture(GL_TEXTURE0);
7465 }
7466
7467 bool runComputePass(gl::GLWindow *win, size_t shader_index, GLuint input_texture, GLuint output_texture) {
7468 bindPassHistoryTextures(shader_index);
7469 return library.dispatchCompute2D(win, shader_index, input_texture, output_texture);
7470 }
7471
7472 /**
7473 * @brief One-time conversion of existing GL resources from SDR (8-bit)
7474 * to HDR (16-bit) formats. Called the first time a video is
7475 * detected as HDR — after @c load() / @c setupCaptureFBO() have
7476 * already allocated SDR-format textures. Re-specifies the
7477 * existing texture objects so no IDs change and no callers break.
7478 *
7479 * - @c camera_texture : GL_RGBA16 (PQ/HLG-encoded source)
7480 * - @c fboTexture : GL_RGBA16F (linear BT.2020 intermediate)
7481 * - @c crossfadeTexture : GL_RGBA16F (if already created)
7482 * - @c crossfadePrevTexture : GL_RGBA16F (if already created)
7483 * - @c passTexture[0..1] : deferred; allocated on demand by the
7484 * shader-pass branch, which checks
7485 * @ref input_is_hdr and chooses the
7486 * correct internal format at that time.
7487 * - HDR decode/encode programs + linear/encoded textures: allocated
7488 * via @ref ensureHdrResources().
7489 */
7490 void convertResourcesToHdr(int w, int h) {
7491 if (!input_is_hdr)
7492 return;
7493
7494 // Re-spec camera_texture as 16-bit normalised. Keep the same GL
7495 // name so callers/sprites that already cached the ID stay valid.
7496 if (camera_texture != 0) {
7497 glBindTexture(GL_TEXTURE_2D, camera_texture);
7498 glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA16, w, h, 0, GL_RGBA, GL_UNSIGNED_SHORT, nullptr);
7499 glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
7500 glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
7501 glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
7502 glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
7503 glBindTexture(GL_TEXTURE_2D, 0);
7504 hdr_upload_tex_w = w;
7505 hdr_upload_tex_h = h;
7506 }
7507
7508 // Re-spec fboTexture (colour attachment of captureFBO) as 16F.
7509 if (fboTexture != 0) {
7510 glBindTexture(GL_TEXTURE_2D, fboTexture);
7511 glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA16F, w, h, 0, GL_RGBA, GL_HALF_FLOAT, nullptr);
7512 glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
7513 glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
7514 glBindTexture(GL_TEXTURE_2D, 0);
7515 }
7516
7517 // crossfade textures may or may not have been allocated yet.
7518 auto respecFloat = [&](GLuint tex) {
7519 if (tex == 0)
7520 return;
7521 glBindTexture(GL_TEXTURE_2D, tex);
7522 glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA16F, w, h, 0, GL_RGBA, GL_HALF_FLOAT, nullptr);
7523 glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
7524 glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
7525 glBindTexture(GL_TEXTURE_2D, 0);
7526 };
7527 respecFloat(crossfadeTexture);
7528 respecFloat(crossfadePrevTexture);
7529
7530 // If pass textures were already allocated (unusual at this point,
7531 // but possible on re-entry), re-spec them too.
7532 for (int p = 0; p < 2; ++p) {
7533 respecFloat(passTexture[p]);
7534 }
7535
7536 ensureHdrResources(w, h);
7537 }
7538
7539 /**
7540 * @brief HDR readback: run the encode pass and read 16-bit PQ-encoded
7541 * RGB pixels back into @p out. Output size is 8*w*h bytes
7542 * (uint16_t per channel, RGBA).
7543 *
7544 * @param src_linear_tex Texture holding the final linear BT.2020
7545 * result (typically @c fboTexture after the
7546 * shader chain).
7547 * @param w,h Frame dimensions.
7548 * @param out Output byte vector, resized to 8*w*h.
7549 */
7550 void hdrReadback(GLuint src_linear_tex, int w, int h, std::vector<unsigned char> &out) {
7551 runHdrEncodePass(src_linear_tex, w, h);
7552 const size_t bytes = static_cast<size_t>(w) * h * 8;
7553 out.resize(bytes);
7554 glBindTexture(GL_TEXTURE_2D, hdr_encoded_texture);
7555 glPixelStorei(GL_PACK_ALIGNMENT, 2);
7556 glGetTexImage(GL_TEXTURE_2D, 0, GL_RGBA, GL_UNSIGNED_SHORT, out.data());
7557 glPixelStorei(GL_PACK_ALIGNMENT, 4);
7558 glBindTexture(GL_TEXTURE_2D, 0);
7559 }
7560
7561 /**
7562 * @brief Construct the rendering object from parsed CLI arguments.
7563 *
7564 * Copies capture parameters, opens the audio subsystem (optional),
7565 * initialises GPU filter state (CUDA malloc for pointer/filter lists),
7566 * sets up the shader pass list, loads the MIDI map file, and applies
7567 * the playlist / duration settings. No OpenGL calls are made here
7568 * because the GL context is not yet current—those are deferred to load().
7569 *
7570 * @param args Parsed command-line arguments.
7571 */
7572 ACView(const MXArguments &args) : crf{args.crf}, encode_opts{args.encode_opts}, prefix_path{args.prefix_path}, filename{args.filename}, ofilename{args.ofilename}, graphic{args.graphic_file}, camera_index{args.camera_device}, flib{args.slib}, sizev{args.sizev}, sizec{args.csize}, fps{args.fps_value}, repeat{args.repeat}, full{args.full}, frame_cache{static_cast<std::size_t>(args.cache_size > 0 ? args.cache_size : 8), args.cache_array}, texture_cache{args.cache}, texture_cache_array{args.cache_array}, cache_delay{args.cache_delay}, copy_audio{args.copy_audio}, gpu_cuda_device{args.cuda_device}, silent_mode{args.silent}, no_drop_mode{args.no_drop}, use_shader_cache_flag{args.use_shader_cache}, flip_output{args.flip_output}, frame_rotation{args.frame_rotation}, png_video_mode{args.png_output && !args.filename.empty()}, generate_mode{args.generate_interval > 0}, generate_interval{args.generate_interval}, display_filter{args.display_filter} {
7573 if (!args.watermark_text.empty()) {
7574 enableWatermark = true;
7575 watermark_text = args.watermark_text;
7576 }
7577 watermark_r = std::clamp(args.watermark_r, 0, 255);
7578 watermark_g = std::clamp(args.watermark_g, 0, 255);
7579 watermark_b = std::clamp(args.watermark_b, 0, 255);
7580#ifdef AUDIO_ENABLED
7583 audio_warmup_rate = std::max(args.audio_warm_rate, 0.0f);
7585 if (!args.audio_file.empty()) {
7586 if (file_audio_open(args.audio_file)) {
7587 audio_is_enabled = true;
7588 file_audio_mode = true;
7589 audio_file_path = args.audio_file;
7590 audio_trunc_mode = args.audio_trunc;
7591 audio_repeat_mode = args.audio_repeat;
7592 file_audio_set_repeat(audio_repeat_mode);
7593 audio_engine.analyzer().reset();
7594 audio_engine.analyzer().set_sample_rate(44100);
7595 audio_engine.analyzer().set_sensitivity(args.audio_sensitivty);
7596 if (args.audio_pass_through) {
7597 if (!file_audio_enable_output(audio_output_device)) {
7598 mx::system_err << "acmx2: File audio playback could not be opened; "
7599 "continuing with visual reactivity only\n";
7600 } else {
7601 mx::system_out << "acmx2: File audio output is the master clock; "
7602 "late video frames will be dropped\n";
7603 }
7604 }
7605 resetAudioWarmupEnvelope();
7606 spectrumTex.init();
7607 audio_buffer_count = spectrumHistory.init(std::max(args.audio_buffers, 0));
7608 if (audio_buffer_count > 0) {
7609 library.setAudioBufferCount(audio_buffer_count);
7610 }
7611 mx::system_out << "acmx2: File audio enabled from: " << args.audio_file << "\n";
7612 mx::system_out << "acmx2: FFT spectrum texture initialised (" << acmx2::audio::AudioAnalyzer::spectrum_bin_count() << " bins on GL_TEXTURE" << SpectrumTexture::SPECTRUM_TEXTURE_UNIT << ")\n";
7613 if (audio_buffer_count > 0) {
7614 mx::system_out << "acmx2: Audio spectrum history array enabled (" << audio_buffer_count << " layers in one GL_TEXTURE_1D_ARRAY on GL_TEXTURE" << SpectrumHistory::TEXTURE_UNIT << ")\n";
7615 }
7616 } else {
7617 mx::system_err << "acmx2: Error could not open audio file: " << args.audio_file << "\n";
7618 }
7619 } else if (args.audio_enabled) {
7620 const acmx2::audio::AudioStreamConfig audio_config{
7621 args.audio_channels,
7622 args.audio_sensitivty,
7623 audio_input_device,
7624 audio_output_device,
7625 args.audio_pass_through,
7626 };
7627 if (!audio_engine.open(audio_config)) {
7628 mx::system_err << "acmx2: Error could not initialize audio\n";
7629 } else {
7630 audio_is_enabled = true;
7631 audio_engine.recorder().set_gain(args.record_gain);
7632 resetAudioWarmupEnvelope();
7633 spectrumTex.init();
7634 audio_buffer_count = spectrumHistory.init(std::max(args.audio_buffers, 0));
7635 if (audio_buffer_count > 0) {
7636 library.setAudioBufferCount(audio_buffer_count);
7637 }
7638 mx::system_out << "acmx2: FFT spectrum texture initialised (" << acmx2::audio::AudioAnalyzer::spectrum_bin_count() << " bins on GL_TEXTURE" << SpectrumTexture::SPECTRUM_TEXTURE_UNIT << ")\n";
7639 if (audio_buffer_count > 0) {
7640 mx::system_out << "acmx2: Audio spectrum history array enabled (" << audio_buffer_count << " layers in one GL_TEXTURE_1D_ARRAY on GL_TEXTURE" << SpectrumHistory::TEXTURE_UNIT << ")\n";
7641 }
7642 }
7643 }
7644
7645#endif
7646 library.is3D(args.is3d);
7647 library.setTimeSpeed(args.time_speed);
7648 library.setVideoFPS(args.fps_value);
7649 library.setNormalizedTime(args.normalized_time);
7650 is3d_enabled = args.is3d;
7651 m_file = args.model_file;
7652
7653#ifdef ACMX2_WITH_DNN
7654 if (!args.human_model.empty()) {
7655 try {
7656 human_model_path = args.human_model;
7657 human_background_only = args.human_background_only;
7658 human_black_point = args.human_black;
7659 human_white_point = args.human_white;
7660 human_seg_model = std::make_unique<ac_dnn::PPHS>(human_model_path);
7661 mx::system_out << "acmx2: Human segmentation (PPHS) enabled with model: " << human_model_path << " [automatic CPU/CUDA selection]" << (human_background_only ? " [background-only shader mode]" : "") << "\n";
7662 } catch (const std::exception &e) {
7663 mx::system_err << "acmx2: Failed to load human segmentation model '" << args.human_model << "': " << e.what() << "\n";
7664 human_seg_model.reset();
7665 }
7666 } else if (args.human_background_only) {
7667 mx::system_err << "acmx2: --background was specified without --human; ignoring.\n";
7668 }
7669 if (!args.edge_model.empty()) {
7670 try {
7671 edge_det_model = std::make_unique<ac_dnn::Dexined>(args.edge_model);
7672 mx::system_out << "acmx2: Edge detection (Dexined) enabled with model: " << args.edge_model << " [automatic CPU/CUDA selection]\n";
7673 } catch (const std::exception &e) {
7674 mx::system_err << "acmx2: Failed to load edge detection model '" << args.edge_model << "': " << e.what() << "\n";
7675 edge_det_model.reset();
7676 }
7677 }
7678 if (!args.onnx_model.empty()) {
7679 try {
7680 onnx_proc_model = std::make_unique<ac_dnn::OnnxWrapper>(args.onnx_model);
7681 mx::system_out << "acmx2: Generic ONNX model enabled from YAML: " << args.onnx_model << "\n";
7682 } catch (const std::exception &e) {
7683 mx::system_err << "acmx2: Failed to load ONNX model '" << args.onnx_model << "': " << e.what() << "\n";
7684 onnx_proc_model.reset();
7685 }
7686 }
7687#else
7688 if (!args.human_model.empty()) {
7689 mx::system_err << "acmx2: --human requested but this build has no OpenCV DNN support (configure with -DWITH_OPENCV_DNN=ON).\n";
7690 }
7691 if (args.human_background_only) {
7692 mx::system_err << "acmx2: --background requested but this build has no OpenCV DNN support.\n";
7693 }
7694 if (!args.edge_model.empty()) {
7695 mx::system_err << "acmx2: --edge requested but this build has no OpenCV DNN support (configure with -DWITH_OPENCV_DNN=ON).\n";
7696 }
7697 if (!args.onnx_model.empty()) {
7698 mx::system_err << "acmx2: --onnx requested but this build has no OpenCV DNN support (configure with -DWITH_OPENCV_DNN=ON).\n";
7699 }
7700#endif
7701
7702 gpu_filter_enabled = args.gpu_filter_enabled;
7703#ifdef ACMX2_WITH_CUDA
7704 if (gpu_filter_enabled && !args.gpu_filter_indices.empty()) {
7705 for (int idx : args.gpu_filter_indices) {
7706 if (idx >= 0 && idx < ac_gpu::AC_FILTER_MAX) {
7707 gpu_filters.push_back({idx, ac_gpu::filters[idx].name});
7708 mx::system_out << "acmx2: GPU filter added: " << ac_gpu::filters[idx].name << " (index " << idx << ")\n";
7709 }
7710 }
7711 gpu_current_filter_index = args.gpu_filter_indices[0];
7712 gpu_frame_buffer = std::make_unique<ac_gpu::DynamicFrameBuffer>(args.gpu_frame_buffer_size);
7713 CHECK_CUDA(cudaMalloc(&d_ptrList, args.gpu_frame_buffer_size * sizeof(unsigned char *)));
7714 mx::system_out << "acmx2: GPU filtering enabled with " << gpu_filters.size() << " filter(s)\n";
7715 }
7716#else
7717 if (gpu_filter_enabled) {
7718 mx::system_err << "acmx2: GPU filters requested but this build has no CUDA support; disabling.\n";
7719 gpu_filter_enabled = false;
7720 }
7721#endif
7722 counter_disabled = args.disable_counter;
7723 use_yuv = args.use_yuv;
7724
7725 if (args.shader_pass_enabled && !args.shader_pass_list.empty()) {
7726 shader_pass_list = args.shader_pass_list;
7727 shader_pass_enabled = true;
7728 mx::system_out << "acmx2: Shader pass list enabled with " << shader_pass_list.size() << " shader(s)\n";
7729 for (int idx : shader_pass_list) {
7730 mx::system_out << " - Shader index: " << idx << "\n";
7731 }
7732 fflush(stdout);
7733 }
7734 playlist_file = args.playlist_file;
7735 autopilot_frames = args.autopilot_frames;
7736 autopilot_random_interval = args.autopilot_random_interval;
7737 autopilot_random_timeout = args.autopilot_random_timeout;
7738 resetAutopilotInterval();
7739 duration_limit = args.duration;
7740 max_size_limit_mb = args.max_size_mb;
7741 if (max_size_limit_mb > 0.0) {
7742 max_size_limit_bytes = max_size_limit_mb * 1024.0 * 1024.0;
7743 }
7744 crossfadeDuration = args.cross_fade_duration;
7745#ifdef MIDI_ENABLED
7746 if (!args.midi_map_file.empty()) {
7747 initMidi(args.midi_map_file, args.midi_device);
7748 }
7749#endif
7750#if defined(__linux__) || defined(__APPLE__) || defined(_WIN32)
7751 if (args.interface_shm) {
7752 initShaderSelectionSharedMemory();
7753 }
7754#endif
7755 }
7756
7757 bool is3d_enabled = false;
7758
7759#ifdef ACMX2_WITH_DNN
7760 std::unique_ptr<ac_dnn::PPHS> human_seg_model;
7761 std::string human_model_path;
7762 bool human_background_only = false;
7763 float human_black_point = 0.35f;
7764 float human_white_point = 0.75f;
7765 std::unique_ptr<ac_dnn::Dexined> edge_det_model;
7766 std::unique_ptr<ac_dnn::OnnxWrapper> onnx_proc_model;
7767 GLuint human_overlay_tex = 0;
7768 int human_overlay_w = 0;
7769 int human_overlay_h = 0;
7770 bool human_overlay_ready = false; ///< True once a mask has been produced for the current frame.
7771#endif
7772
7774 std::vector<ac_gpu::Filter> gpu_filters;
7776 std::unique_ptr<ac_gpu::DynamicFrameBuffer> gpu_frame_buffer;
7777#ifdef ACMX2_WITH_CUDA
7778 cv::cuda::GpuMat gpuWorkingBuffer;
7779 cv::cuda::GpuMat gpu_rotation_input;
7780 cv::cuda::GpuMat gpu_rotation_output;
7781 cv::cuda::GpuMat onnxGpuOutput;
7782#endif
7784 unsigned char **d_ptrList = nullptr;
7787 float gpu_alpha = 1.0f;
7792 std::vector<int> shader_pass_list;
7795
7797 std::string name;
7798 std::vector<int> shader_indices;
7799 };
7800 std::vector<PlaylistNode> playlist_tree;
7801 std::vector<int> playlist_indices;
7803 bool playlist_enabled = false;
7804 std::string playlist_file;
7805 int autopilot_frames = 0; ///< Frames between random switches in autopilot mode (0 = unset).
7806 bool autopilot_enabled = false; ///< Toggle autopilot via SDLK_j when playlist is enabled.
7807 bool autopilot_sequential = false; ///< When true, autopilot advances through the playlist in order instead of randomly (toggle via SDLK_y).
7808 int autopilot_counter = 0; ///< Frames elapsed since last autopilot switch.
7809 bool autopilot_random_interval = false; ///< When true, choose a new interval from [4, autopilot_random_timeout] after each switch.
7810 int autopilot_random_timeout = 0; ///< Inclusive upper bound for random autopilot interval.
7811 int autopilot_interval_frames = 0; ///< Active interval currently used by autopilot tick.
7812 bool autopilot_random_crossfade = false; ///< When true, autopilot picks a random crossfade shader on each switch.
7813 std::mt19937 autopilot_rng{std::random_device{}()};
7814 std::vector<int> saved_pass_list;
7816 double duration_limit = 0.0;
7817 size_t frames_proc = 0;
7818 double max_size_limit_mb = 0.0;
7820
7823 const int lower = 4;
7824 const int upper = std::max(lower, autopilot_random_timeout);
7825 std::uniform_int_distribution<int> dist(lower, upper);
7827 } else {
7829 }
7830 }
7831
7833 if (!playlist_tree.empty()) {
7834 return static_cast<int>(playlist_tree.size());
7835 }
7836 if (!playlist_indices.empty()) {
7837 return static_cast<int>(playlist_indices.size());
7838 }
7839 return 0;
7840 }
7841
7844 return;
7845 const int n = static_cast<int>(crossfadeShaders.size());
7846 std::uniform_int_distribution<int> dist(0, n - 1);
7847 int next = dist(autopilot_rng);
7848 if (n > 1 && next == crossfadeShaderIndex)
7849 next = (next + 1) % n;
7850 crossfadeShaderIndex = next;
7851 }
7852
7857
7858#if defined(__linux__) || defined(__APPLE__) || defined(_WIN32)
7859#if defined(__linux__) || defined(__APPLE__)
7860 int shaderSelectionShmFd = -1;
7861#else
7862 HANDLE shaderSelectionMapping = nullptr;
7863#endif
7864 acmx2::ipc::ShaderSelectionShmData *shaderSelectionShm = nullptr;
7865#if defined(__linux__) || defined(__APPLE__)
7866 sem_t *shaderSelectionSemaphore = SEM_FAILED;
7867#else
7868 HANDLE shaderSelectionSemaphore = nullptr;
7869#endif
7870 uint32_t shaderSelectionLastSequence = 0;
7871 uint32_t shaderReloadLastSequence = 0;
7872 uint32_t audioFileLastSequence = 0;
7873
7874 std::vector<ShaderManifestData::CustomUniform> customUniformsFromSharedMemory(const acmx2::ipc::ShaderSelectionShmData &selection) const {
7875 std::vector<ShaderManifestData::CustomUniform> uniforms;
7876 const uint32_t count = std::min<uint32_t>(selection.custom_uniform_count, acmx2::ipc::kShaderSelectionMaxCustomUniforms);
7877 uniforms.reserve(count);
7878 for (uint32_t i = 0; i < count; ++i) {
7879 const char *nameData = selection.custom_uniform_names[i];
7880 std::size_t length = 0;
7881 while (length < acmx2::ipc::kShaderSelectionMaxUniformName && nameData[length] != '\0') {
7882 ++length;
7883 }
7885 uniform.name.assign(nameData, length);
7886 if (!isValidCustomUniformName(uniform.name))
7887 continue;
7888 uniform.minimum = -std::numeric_limits<double>::max();
7889 uniform.maximum = std::numeric_limits<double>::max();
7890 uniform.step = 1.0;
7891 uniform.value = selection.custom_uniform_values[i];
7892 if (!std::isfinite(uniform.value))
7893 uniform.value = 0.0;
7894 uniforms.push_back(std::move(uniform));
7895 }
7896 return uniforms;
7897 }
7898
7899 bool copyShaderSelectionSnapshot(acmx2::ipc::ShaderSelectionShmData &snapshot) const {
7900#if defined(__linux__) || defined(__APPLE__)
7901 if (!shaderSelectionShm || shaderSelectionSemaphore == SEM_FAILED)
7902 return false;
7903#else
7904 if (!shaderSelectionShm || shaderSelectionSemaphore == nullptr)
7905 return false;
7906#endif
7907 acmx2::ipc::ShaderSelectionLock lock(shaderSelectionSemaphore);
7908 if (!lock)
7909 return false;
7910 if (shaderSelectionShm->magic != acmx2::ipc::kShaderSelectionMagic || shaderSelectionShm->version != acmx2::ipc::kShaderSelectionVersion) {
7911 return false;
7912 }
7913 snapshot = *shaderSelectionShm;
7914 return true;
7915 }
7916
7917 void initShaderSelectionSharedMemory() {
7918 if (shaderSelectionShm)
7919 return;
7920#if defined(__linux__) || defined(__APPLE__)
7921 shaderSelectionSemaphore = ::sem_open(acmx2::ipc::kShaderSelectionSemaphoreName, 0);
7922 if (shaderSelectionSemaphore == SEM_FAILED) {
7923 std::cerr << "acmx2: interface control unavailable: sem_open(" << acmx2::ipc::kShaderSelectionSemaphoreName << ") failed: " << std::strerror(errno) << '\n';
7924 return;
7925 }
7926 shaderSelectionShmFd = ::shm_open(acmx2::ipc::kShaderSelectionShmName, O_RDWR, 0666);
7927 if (shaderSelectionShmFd < 0) {
7928 ::sem_close(shaderSelectionSemaphore);
7929 shaderSelectionSemaphore = SEM_FAILED;
7930 return;
7931 }
7932
7933 void *mapped = ::mmap(nullptr, sizeof(acmx2::ipc::ShaderSelectionShmData), PROT_READ | PROT_WRITE, MAP_SHARED, shaderSelectionShmFd, 0);
7934 if (mapped == MAP_FAILED) {
7935 ::close(shaderSelectionShmFd);
7936 shaderSelectionShmFd = -1;
7937 ::sem_close(shaderSelectionSemaphore);
7938 shaderSelectionSemaphore = SEM_FAILED;
7939 return;
7940 }
7941
7942 shaderSelectionShm = static_cast<acmx2::ipc::ShaderSelectionShmData *>(mapped);
7943#else
7944 shaderSelectionSemaphore = ::OpenMutexW(SYNCHRONIZE | MUTEX_MODIFY_STATE, FALSE, acmx2::ipc::kShaderSelectionMutexNameWindows);
7945 if (shaderSelectionSemaphore == nullptr) {
7946 std::cerr << "acmx2: interface control unavailable: OpenMutexW failed "
7947 "with Windows error "
7948 << ::GetLastError() << '\n';
7949 return;
7950 }
7951
7952 shaderSelectionMapping = ::OpenFileMappingW(FILE_MAP_READ, FALSE, acmx2::ipc::kShaderSelectionMappingNameWindows);
7953 if (shaderSelectionMapping == nullptr) {
7954 std::cerr << "acmx2: interface control unavailable: "
7955 "OpenFileMappingW failed with Windows error "
7956 << ::GetLastError() << '\n';
7957 cleanupShaderSelectionSharedMemory();
7958 return;
7959 }
7960
7961 void *mapped = ::MapViewOfFile(shaderSelectionMapping, FILE_MAP_READ, 0, 0, sizeof(acmx2::ipc::ShaderSelectionShmData));
7962 if (mapped == nullptr) {
7963 std::cerr << "acmx2: interface control unavailable: MapViewOfFile "
7964 "failed with Windows error "
7965 << ::GetLastError() << '\n';
7966 cleanupShaderSelectionSharedMemory();
7967 return;
7968 }
7969 shaderSelectionShm = static_cast<acmx2::ipc::ShaderSelectionShmData *>(mapped);
7970#endif
7972 if (!copyShaderSelectionSnapshot(snapshot)) {
7973 cleanupShaderSelectionSharedMemory();
7974 return;
7975 }
7976
7977 shaderSelectionLastSequence = snapshot.sequence;
7978 shaderReloadLastSequence = snapshot.reload_sequence;
7979 audioFileLastSequence = snapshot.audio_file_sequence;
7980 library.setCustomUniformValues(customUniformsFromSharedMemory(snapshot));
7981 }
7982
7983 void cleanupShaderSelectionSharedMemory() {
7984 if (shaderSelectionShm) {
7985#if defined(__linux__) || defined(__APPLE__)
7986 ::munmap(shaderSelectionShm, sizeof(acmx2::ipc::ShaderSelectionShmData));
7987#else
7988 ::UnmapViewOfFile(shaderSelectionShm);
7989#endif
7990 shaderSelectionShm = nullptr;
7991 }
7992#if defined(__linux__) || defined(__APPLE__)
7993 if (shaderSelectionShmFd >= 0) {
7994 ::close(shaderSelectionShmFd);
7995 shaderSelectionShmFd = -1;
7996 }
7997 if (shaderSelectionSemaphore != SEM_FAILED) {
7998 ::sem_close(shaderSelectionSemaphore);
7999 shaderSelectionSemaphore = SEM_FAILED;
8000 }
8001#else
8002 if (shaderSelectionMapping != nullptr) {
8003 ::CloseHandle(shaderSelectionMapping);
8004 shaderSelectionMapping = nullptr;
8005 }
8006 if (shaderSelectionSemaphore != nullptr) {
8007 ::CloseHandle(shaderSelectionSemaphore);
8008 shaderSelectionSemaphore = nullptr;
8009 }
8010#endif
8011 }
8012
8013 void syncShaderSelectionFromInterface(gl::GLWindow *win) {
8015 if (!copyShaderSelectionSnapshot(snapshot))
8016 return;
8017 const acmx2::ipc::ShaderSelectionShmData *selection = &snapshot;
8018 if (selection->sequence == shaderSelectionLastSequence)
8019 return;
8020
8021 shaderSelectionLastSequence = selection->sequence;
8022 const auto readBoundedText = [](const char *buf, std::size_t cap) {
8023 std::size_t len = 0;
8024 while (len < cap && buf[len] != '\0') {
8025 ++len;
8026 }
8027 return std::string(buf, len);
8028 };
8029 const std::vector<std::string> interfaceShaderFiles = std::get<0>(flib) == 1 ? sortedShaderLibraryEntries(std::get<1>(flib)) : std::vector<std::string>{};
8030
8031 library.setCustomUniformValues(customUniformsFromSharedMemory(snapshot));
8032
8033 if (selection->audio_file_sequence != audioFileLastSequence) {
8034 audioFileLastSequence = selection->audio_file_sequence;
8035#ifdef AUDIO_ENABLED
8036 const std::string requestedAudioPath = readBoundedText(selection->audio_file_path, acmx2::ipc::kShaderSelectionMaxAudioFilePath);
8037 if (!file_audio_mode) {
8038 mx::system_err << "acmx2: Ignoring live audio-file change because this process "
8039 "was not started in audio-file mode\n";
8040 } else if (requestedAudioPath.empty()) {
8041 mx::system_err << "acmx2: Ignoring empty live audio-file request\n";
8042 } else if (file_audio_open(requestedAudioPath)) {
8043 audio_file_path = requestedAudioPath;
8044 audio_output_device = selection->audio_output_device;
8045 audio_trunc_mode = selection->audio_trunc != 0;
8046 audio_repeat_mode = selection->audio_repeat != 0;
8047 file_audio_set_repeat(audio_repeat_mode);
8048 audio_engine.analyzer().reset();
8049 audio_engine.analyzer().set_sample_rate(44100);
8050 resetAudioWarmupEnvelope();
8051 if (selection->audio_pass_through != 0 && !file_audio_enable_output(audio_output_device)) {
8052 mx::system_err << "acmx2: Live audio-file output could not be opened; "
8053 "continuing with visual reactivity only\n";
8054 }
8055 mx::system_out << "acmx2: Switched file audio to: " << requestedAudioPath << "\n";
8056 } else {
8057 mx::system_err << "acmx2: Could not switch file audio to: " << requestedAudioPath << "\n";
8058 }
8059 mx::system_out.flush();
8060 mx::system_err.flush();
8061#endif
8062 }
8063
8064 if (selection->reload_sequence != shaderReloadLastSequence) {
8065 shaderReloadLastSequence = selection->reload_sequence;
8066 const int requestedReloadIndex = selection->reload_shader_index;
8067 const std::string requestedPath = readBoundedText(selection->reload_shader_path, acmx2::ipc::kShaderSelectionMaxReloadPath);
8068 std::string reloadPath;
8069 size_t reloadIndex = 0;
8070 std::string reloadError;
8071
8072 if (requestedReloadIndex < 0 || requestedPath.empty()) {
8073 reloadError = "Invalid shader reload request from interface";
8074 } else if (std::get<0>(flib) == 1) {
8075 const auto shaderFiles = sortedShaderLibraryEntries(std::get<1>(flib));
8076 if (static_cast<size_t>(requestedReloadIndex) >= shaderFiles.size()) {
8077 reloadError = "Shader reload index is outside the shader manifest: " + std::to_string(requestedReloadIndex);
8078 } else if (!resolveShaderPathInLibrary(std::get<1>(flib), shaderFiles[static_cast<size_t>(requestedReloadIndex)], reloadPath)) {
8079 reloadError = "Could not resolve shader reload path from the manifest";
8080 } else {
8081 std::error_code requestedError;
8082 const auto canonicalRequested = std::filesystem::weakly_canonical(std::filesystem::path(requestedPath), requestedError);
8083 std::error_code expectedError;
8084 const auto canonicalExpected = std::filesystem::weakly_canonical(std::filesystem::path(reloadPath), expectedError);
8085 std::error_code equivalentError;
8086 const bool isExpected = !requestedError && !expectedError && std::filesystem::equivalent(canonicalRequested, canonicalExpected, equivalentError) && !equivalentError;
8087 const bool isPreview = !requestedError && !expectedError && std::filesystem::is_regular_file(canonicalRequested) && isComputeShaderFile(canonicalRequested.string()) == isComputeShaderFile(canonicalExpected.string()) && isEditorPreviewPath(std::filesystem::path(std::get<1>(flib)), canonicalRequested);
8088 if (!isExpected && !isPreview) {
8089 reloadError = "Shader reload path does not match the requested library index";
8090 } else {
8091 reloadIndex = static_cast<size_t>(requestedReloadIndex);
8092 reloadPath = canonicalRequested.string();
8093 }
8094 }
8095 } else {
8096 std::error_code requestedError;
8097 const auto canonicalRequested = std::filesystem::weakly_canonical(std::filesystem::path(requestedPath), requestedError);
8098 std::error_code loadedError;
8099 const auto canonicalLoaded = std::filesystem::weakly_canonical(std::filesystem::path(std::get<1>(flib)), loadedError);
8100 std::error_code equivalentError;
8101 const bool isExpected = !requestedError && !loadedError && std::filesystem::equivalent(canonicalRequested, canonicalLoaded, equivalentError) && !equivalentError;
8102 const bool isPreview = !requestedError && !loadedError && std::filesystem::is_regular_file(canonicalRequested) && isComputeShaderFile(canonicalRequested.string()) == isComputeShaderFile(canonicalLoaded.string()) && isEditorPreviewForShader(canonicalLoaded, canonicalRequested);
8103 if (!isExpected && !isPreview) {
8104 reloadError = "Saved shader is not the shader loaded by this process";
8105 } else {
8106 reloadPath = canonicalRequested.string();
8107 }
8108 }
8109
8110 if (reloadError.empty() && library.reloadProgram(win, reloadIndex, reloadPath, reloadError)) {
8111 if (is3d_enabled)
8112 cube.setShaderProgram(library.shader());
8113 sprite.setShader(library.shader());
8114 updateShaderNameCache();
8115 mx::system_out << "acmx2: Live reloaded shader " << reloadPath << "\n";
8116 mx::system_out.flush();
8117 fflush(stdout);
8118 } else {
8119 mx::system_err << "acmx2: Live shader reload failed for " << requestedPath << ":\n" << reloadError << "\n";
8120 mx::system_err.flush();
8121 fflush(stderr);
8122 }
8123 }
8124
8125 std::vector<int> requestedPassList;
8126 requestedPassList.reserve(selection->shader_pass_count);
8127 const uint32_t clampedPassCount = std::min<uint32_t>(selection->shader_pass_count, acmx2::ipc::kShaderSelectionMaxPassCount);
8128 for (uint32_t i = 0; i < clampedPassCount; ++i) {
8129 int passIndex = selection->shader_pass_indices[i];
8130 if (std::get<0>(flib) == 1) {
8131 const std::string passName = readBoundedText(selection->shader_pass_names[i], acmx2::ipc::kShaderSelectionMaxShaderName);
8132 if (!passName.empty())
8133 passIndex = shaderIndexForFile(interfaceShaderFiles, passName);
8134 }
8135 if (passIndex < 0)
8136 continue;
8137 if (static_cast<size_t>(passIndex) >= library.size())
8138 continue;
8139 requestedPassList.push_back(passIndex);
8140 }
8141 const bool requestedPassEnabled = selection->shader_pass_enabled != 0 && !requestedPassList.empty();
8142 const bool multipassChanged = (shader_pass_enabled != requestedPassEnabled) || (shader_pass_list != requestedPassList);
8143 // Playlist mode owns the active pass list. A right-click shader
8144 // selection from the interface should change only the post/main
8145 // shader, just like Shift+Up/Down, without replacing the playlist
8146 // node with the interface's standalone multipass settings.
8147 if (!random_multipass_mode && !playlist_enabled) {
8148 if (multipassChanged && requestedPassEnabled)
8149 beginCrossfade(win);
8150 shader_pass_list = requestedPassList;
8151 shader_pass_enabled = requestedPassEnabled;
8152 updateShaderNameCache();
8153 }
8154
8155 repeat = (selection->repeat_enabled != 0);
8156 display_filter = (selection->display_filter_enabled != 0);
8157 library.setNormalizedTime(selection->normalized_time_enabled != 0);
8158
8159 const std::string requestedWatermark = readBoundedText(selection->watermark_text, acmx2::ipc::kShaderSelectionMaxWatermarkText);
8160 const bool requestedWatermarkEnabled = (selection->watermark_enabled != 0) && !requestedWatermark.empty();
8161 enableWatermark = requestedWatermarkEnabled;
8162 watermark_text = requestedWatermark;
8163 watermark_r = std::clamp<int>(selection->watermark_r, 0, 255);
8164 watermark_g = std::clamp<int>(selection->watermark_g, 0, 255);
8165 watermark_b = std::clamp<int>(selection->watermark_b, 0, 255);
8166
8167#ifdef ACMX2_WITH_CUDA
8168 std::vector<int> requestedGpuFilters;
8169 const uint32_t clampedGpuCount = std::min<uint32_t>(selection->gpu_filter_count, acmx2::ipc::kShaderSelectionMaxGpuFilterCount);
8170 requestedGpuFilters.reserve(clampedGpuCount);
8171 for (uint32_t i = 0; i < clampedGpuCount; ++i) {
8172 const int idx = selection->gpu_filter_indices[i];
8173 if (idx < 0 || idx >= ac_gpu::AC_FILTER_MAX)
8174 continue;
8175 requestedGpuFilters.push_back(idx);
8176 }
8177
8178 const bool requestedGpuEnabled = (selection->gpu_filter_enabled != 0) && !requestedGpuFilters.empty();
8179 const int requestedGpuBufferSize = std::clamp<int>(static_cast<int>(selection->gpu_buffer_size), 4, 32);
8180
8181 std::vector<int> currentGpuFilters;
8182 currentGpuFilters.reserve(gpu_filters.size());
8183 for (const auto &f : gpu_filters) {
8184 currentGpuFilters.push_back(f.index);
8185 }
8186
8187 const bool gpuBufferChanged = requestedGpuEnabled && (!gpu_frame_buffer || gpu_frame_buffer->arraySize != requestedGpuBufferSize);
8188 const bool gpuConfigChanged = (gpu_filter_enabled != requestedGpuEnabled) || (currentGpuFilters != requestedGpuFilters) || gpuBufferChanged;
8189
8190 if (gpuConfigChanged) {
8191 if (requestedGpuEnabled) {
8192 gpu_filters.clear();
8193 for (int idx : requestedGpuFilters) {
8194 gpu_filters.push_back({idx, ac_gpu::filters[idx].name});
8195 }
8196 gpu_filter_enabled = !gpu_filters.empty();
8197 if (gpu_filter_enabled) {
8198 gpu_current_filter_index = gpu_filters.front().index;
8199 if (!gpu_frame_buffer || gpu_frame_buffer->arraySize != requestedGpuBufferSize) {
8200 gpu_frame_buffer = std::make_unique<ac_gpu::DynamicFrameBuffer>(requestedGpuBufferSize);
8201 if (d_ptrList) {
8202 CHECK_CUDA(cudaFree(d_ptrList));
8203 d_ptrList = nullptr;
8204 }
8205 CHECK_CUDA(cudaMalloc(&d_ptrList, requestedGpuBufferSize * sizeof(unsigned char *)));
8206 gpu_frame_index = 0;
8207 gpu_frame_dir = 1;
8208 }
8209 gpu_filtersChanged = true;
8210 } else {
8211 gpu_filter_enabled = false;
8212 }
8213 } else {
8214 gpu_filter_enabled = false;
8215 gpu_filters.clear();
8216 gpu_current_filter_index = 0;
8217 gpu_filtersChanged = true;
8218 }
8219 }
8220#endif
8221
8222 int requestedIndex = selection->selected_index;
8223 if (std::get<0>(flib) == 1) {
8224 const std::string requestedName = readBoundedText(selection->selected_shader_name, acmx2::ipc::kShaderSelectionMaxShaderName);
8225 if (!requestedName.empty())
8226 requestedIndex = shaderIndexForFile(interfaceShaderFiles, requestedName);
8227 }
8228 if (requestedIndex < 0)
8229 return;
8230 if (static_cast<size_t>(requestedIndex) >= library.size())
8231 return;
8232 if (shaderLocked || random_multipass_mode)
8233 return;
8234 if (static_cast<size_t>(requestedIndex) == library.index())
8235 return;
8236
8237 beginCrossfade(win);
8238 library.setIndex(static_cast<size_t>(requestedIndex));
8239 if (is3d_enabled)
8240 cube.setShaderProgram(library.shader());
8241 sprite.setShader(library.shader());
8242 updateShaderNameCache();
8243 }
8244#endif
8245
8246 void generateRandomMultipass(gl::GLWindow *win) {
8247 static std::mt19937 rng(std::random_device{}());
8248 size_t shader_count = library.size();
8249 if (shader_count == 0)
8250 return;
8251 std::uniform_int_distribution<int> count_dist(1, 5);
8252 std::uniform_int_distribution<int> shader_dist(0, static_cast<int>(shader_count) - 1);
8253 int chain_len = count_dist(rng);
8254 beginCrossfade(win);
8255 shader_pass_list.clear();
8256 for (int i = 0; i < chain_len; ++i) {
8257 shader_pass_list.push_back(shader_dist(rng));
8258 }
8259 shader_pass_enabled = true;
8260 if (is3d_enabled)
8261 cube.setShaderProgram(library.shader());
8262 sprite.setShader(library.shader());
8264 mx::system_out << "acmx2: Random multipass [";
8265 for (size_t i = 0; i < shader_pass_list.size(); ++i) {
8266 mx::system_out << library.getShaderNameByIndex(shader_pass_list[i]);
8267 if (i + 1 < shader_pass_list.size())
8268 mx::system_out << ", ";
8269 }
8270 mx::system_out << "]\n";
8271 fflush(stdout);
8272 }
8273
8274 void generateRandomMultipassShort(gl::GLWindow *win) {
8275 static std::mt19937 rng(std::random_device{}());
8276 size_t shader_count = library.size();
8277 if (shader_count == 0)
8278 return;
8279 std::uniform_int_distribution<int> shader_dist(0, static_cast<int>(shader_count) - 1);
8280 beginCrossfade(win);
8281 shader_pass_list.clear();
8282 for (int i = 0; i < 2; ++i) {
8283 shader_pass_list.push_back(shader_dist(rng));
8284 }
8285 shader_pass_enabled = true;
8286 if (is3d_enabled)
8287 cube.setShaderProgram(library.shader());
8288 sprite.setShader(library.shader());
8290 mx::system_out << "acmx2: Short random multipass [";
8291 for (size_t i = 0; i < shader_pass_list.size(); ++i) {
8292 mx::system_out << library.getShaderNameByIndex(shader_pass_list[i]);
8293 if (i + 1 < shader_pass_list.size())
8294 mx::system_out << ", ";
8295 }
8296 mx::system_out << "]\n";
8297 fflush(stdout);
8298 }
8299
8300 void generateRandomMultipassLong(gl::GLWindow *win) {
8301 static std::mt19937 rng(std::random_device{}());
8302 size_t shader_count = library.size();
8303 if (shader_count == 0)
8304 return;
8305 std::uniform_int_distribution<int> count_dist(1, 10);
8306 std::uniform_int_distribution<int> shader_dist(0, static_cast<int>(shader_count) - 1);
8307 int chain_len = count_dist(rng);
8308 beginCrossfade(win);
8309 shader_pass_list.clear();
8310 for (int i = 0; i < chain_len; ++i) {
8311 shader_pass_list.push_back(shader_dist(rng));
8312 }
8313 shader_pass_enabled = true;
8314 if (is3d_enabled)
8315 cube.setShaderProgram(library.shader());
8316 sprite.setShader(library.shader());
8318 mx::system_out << "acmx2: Long random multipass [";
8319 for (size_t i = 0; i < shader_pass_list.size(); ++i) {
8320 mx::system_out << library.getShaderNameByIndex(shader_pass_list[i]);
8321 if (i + 1 < shader_pass_list.size())
8322 mx::system_out << ", ";
8323 }
8324 mx::system_out << "]\n";
8325 fflush(stdout);
8326 }
8327
8328 /**
8329 * @brief Refresh the cached shader name string for the HUD overlay.
8330 *
8331 * Called whenever the active shader or pass list changes so that
8332 * the overlay does not need to rebuild the string every frame.
8333 */
8334 void updateShaderNameCache() { cached_shader_name = shader_pass_enabled ? library.getFullShaderName(shader_pass_list) : library.getFullShaderName(); }
8335
8336 /**
8337 * @brief Pick a random entry from the active playlist and apply it.
8338 *
8339 * Used by autopilot mode. Mirrors the right-arrow advance logic but
8340 * selects a uniformly random index. No-op unless playlist mode is
8341 * active and at least one entry exists.
8342 *
8343 * @param win Active window (used for crossfade animation).
8344 */
8345 void autopilotRandomSwitch(gl::GLWindow *win) {
8346 if (!playlist_enabled)
8347 return;
8348 if (shaderLocked)
8349 return;
8350 if (!playlist_tree.empty()) {
8351 const int n = static_cast<int>(playlist_tree.size());
8352 if (n <= 0)
8353 return;
8354 std::uniform_int_distribution<int> dist(0, n - 1);
8355 int r = dist(autopilot_rng);
8356 if (n > 1 && r == playlist_index)
8357 r = (r + 1) % n;
8359 beginCrossfade(win);
8360 playlist_index = r;
8361 const auto &node = playlist_tree[playlist_index];
8362 shader_pass_list = node.shader_indices;
8364 if (is3d_enabled)
8365 cube.setShaderProgram(library.shader());
8366 sprite.setShader(library.shader());
8368 mx::system_out << "acmx2: Autopilot -> Node: " << node.name << " [" << node.shader_indices.size() << " shaders] (" << (playlist_index + 1) << "/" << n << ")\n";
8369 fflush(stdout);
8370 } else if (!playlist_indices.empty()) {
8371 const int n = static_cast<int>(playlist_indices.size());
8372 std::uniform_int_distribution<int> dist(0, n - 1);
8373 int r = dist(autopilot_rng);
8374 if (n > 1 && r == playlist_index)
8375 r = (r + 1) % n;
8377 beginCrossfade(win);
8378 playlist_index = r;
8380 if (is3d_enabled)
8381 cube.setShaderProgram(library.shader());
8382 sprite.setShader(library.shader());
8384 mx::system_out << "acmx2: Autopilot -> Playlist [" << (playlist_index + 1) << "/" << n << "]\n";
8385 fflush(stdout);
8386 }
8387 }
8388
8389 /**
8390 * @brief Advance to the next entry in the active playlist (wrapping).
8391 *
8392 * Used by sequential autopilot mode. Steps @c playlist_index forward by
8393 * one and wraps to zero when the end is reached, applying the same
8394 * crossfade and shader-pass updates as @ref autopilotRandomSwitch.
8395 *
8396 * @param win Active window (used for crossfade animation).
8397 */
8398 void autopilotSequentialAdvance(gl::GLWindow *win) {
8399 if (!playlist_enabled)
8400 return;
8401 if (shaderLocked)
8402 return;
8403 if (!playlist_tree.empty()) {
8404 const int n = static_cast<int>(playlist_tree.size());
8405 if (n <= 0)
8406 return;
8408 beginCrossfade(win);
8409 playlist_index = (playlist_index + 1) % n;
8410 const auto &node = playlist_tree[playlist_index];
8411 shader_pass_list = node.shader_indices;
8413 if (is3d_enabled)
8414 cube.setShaderProgram(library.shader());
8415 sprite.setShader(library.shader());
8417 mx::system_out << "acmx2: Autopilot (sequential) -> Node: " << node.name << " [" << node.shader_indices.size() << " shaders] (" << (playlist_index + 1) << "/" << n << ")\n";
8418 fflush(stdout);
8419 } else if (!playlist_indices.empty()) {
8420 const int n = static_cast<int>(playlist_indices.size());
8422 beginCrossfade(win);
8423 playlist_index = (playlist_index + 1) % n;
8425 if (is3d_enabled)
8426 cube.setShaderProgram(library.shader());
8427 sprite.setShader(library.shader());
8429 mx::system_out << "acmx2: Autopilot (sequential) -> Playlist [" << (playlist_index + 1) << "/" << n << "]\n";
8430 fflush(stdout);
8431 }
8432 }
8433
8434 /**
8435 * @brief Lazily create the crossfade FBO and its two textures.
8436 *
8437 * Allocates a framebuffer object (@c crossfadeFBO) with an RGBA colour
8438 * attachment (@c crossfadeTexture) and a second texture
8439 * (@c crossfadePrevTexture) used to store the previous frame.
8440 * Subsequent calls are no-ops once the FBO has been created.
8441 *
8442 * @param width Framebuffer width in pixels.
8443 * @param height Framebuffer height in pixels.
8444 * @throws mx::Exception if the framebuffer is incomplete.
8445 */
8446 void ensureCrossfadeFBO(int width, int height) {
8447 if (crossfadeFBO)
8448 return;
8449 // In HDR mode the pipeline stores LINEAR BT.2020 light in RGBA16F
8450 // (values frequently exceed 1.0). Allocating an 8-bit UNORM target
8451 // here would clamp HDR highlights to 1.0 and crush the crossfade
8452 // result. Match the rest of the HDR intermediate chain.
8453 const GLint cf_internal = input_is_hdr ? GL_RGBA16F : GL_RGBA;
8454 const GLenum cf_type = input_is_hdr ? GL_HALF_FLOAT : GL_UNSIGNED_BYTE;
8455 glGenFramebuffers(1, &crossfadeFBO);
8456 glGenTextures(1, &crossfadeTexture);
8457 glBindTexture(GL_TEXTURE_2D, crossfadeTexture);
8458 glTexImage2D(GL_TEXTURE_2D, 0, cf_internal, width, height, 0, GL_RGBA, cf_type, nullptr);
8459 glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
8460 glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
8461 glBindFramebuffer(GL_FRAMEBUFFER, crossfadeFBO);
8462 glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, crossfadeTexture, 0);
8463 if (glCheckFramebufferStatus(GL_FRAMEBUFFER) != GL_FRAMEBUFFER_COMPLETE) {
8464 throw mx::Exception("acmx2: crossfade framebuffer is not complete");
8465 }
8466 glGenTextures(1, &crossfadePrevTexture);
8467 glBindTexture(GL_TEXTURE_2D, crossfadePrevTexture);
8468 glTexImage2D(GL_TEXTURE_2D, 0, cf_internal, width, height, 0, GL_RGBA, cf_type, nullptr);
8469 glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
8470 glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
8471 glBindFramebuffer(GL_FRAMEBUFFER, 0);
8472 }
8473
8474 /**
8475 * @brief Start a crossfade transition from the current frame.
8476 *
8477 * Snapshots the current capture FBO contents into @c crossfadePrevTexture,
8478 * resets @c crossfadeAlpha to zero, and records the start time so that
8479 * applyCrossfade() can linearly interpolate over @c crossfadeDuration
8480 * seconds.
8481 *
8482 * @param win The GL window whose dimensions define the framebuffer size.
8483 */
8484 void beginCrossfade(gl::GLWindow *win) {
8485 ensureCrossfadeFBO(win->w, win->h);
8486 glBindFramebuffer(GL_READ_FRAMEBUFFER, captureFBO);
8487 glBindTexture(GL_TEXTURE_2D, crossfadePrevTexture);
8488 glCopyTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, 0, 0, win->w, win->h);
8489 glBindTexture(GL_TEXTURE_2D, 0);
8490 glBindFramebuffer(GL_READ_FRAMEBUFFER, 0);
8491 crossfadeAlpha = 0.0f;
8492 crossfadeActive = true;
8493 crossfadeStartTime = std::chrono::steady_clock::now();
8494 }
8495
8496 /**
8497 * @brief Render the crossfade blend for the current frame.
8498 *
8499 * Computes a linear alpha from elapsed time and @c crossfadeDuration,
8500 * then draws a full-screen quad into @c crossfadeFBO using the
8501 * @c crossfadeShader. The shader mixes @c crossfadePrevTexture
8502 * (the old frame) with @p currentTexture (the new frame) via
8503 * the @c fade_alpha uniform. When the transition completes
8504 * (@c crossfadeAlpha >= 1.0), @c crossfadeActive is set to false.
8505 *
8506 * @param win The GL window (provides viewport dimensions).
8507 * @param currentTexture The texture containing the newly rendered frame.
8508 */
8509 void applyCrossfade(gl::GLWindow *win, GLuint currentTexture) {
8510 if (!crossfadeActive)
8511 return;
8512 auto elapsed = std::chrono::duration<float>(std::chrono::steady_clock::now() - crossfadeStartTime).count();
8514 if (crossfadeAlpha >= 1.0f) {
8515 crossfadeAlpha = 1.0f;
8516 crossfadeActive = false;
8517 return;
8518 }
8519 glBindFramebuffer(GL_FRAMEBUFFER, crossfadeFBO);
8520 glViewport(0, 0, win->w, win->h);
8521 glClear(GL_COLOR_BUFFER_BIT);
8522 if (crossfadeShaders.empty()) {
8523 crossfadeActive = false;
8524 return;
8525 }
8526 if (crossfadeShaderIndex < 0 || crossfadeShaderIndex >= static_cast<int>(crossfadeShaders.size()))
8528 gl::ShaderProgram &activeCrossfade = crossfadeShaders[crossfadeShaderIndex];
8529 activeCrossfade.useProgram();
8530 activeCrossfade.setUniform("mv_matrix", glm::mat4(1.0f));
8531 activeCrossfade.setUniform("proj_matrix", glm::mat4(1.0f));
8532 activeCrossfade.setUniform("fade_alpha", crossfadeAlpha);
8533 glActiveTexture(GL_TEXTURE0);
8534 glBindTexture(GL_TEXTURE_2D, currentTexture);
8535 activeCrossfade.setUniform("samp", 0);
8536 glActiveTexture(GL_TEXTURE1);
8537 glBindTexture(GL_TEXTURE_2D, crossfadePrevTexture);
8538 activeCrossfade.setUniform("prev_samp", 1);
8539 sprite.setShader(&activeCrossfade);
8540 sprite.setName("samp");
8541 sprite.draw(currentTexture, 0, 0, win->w, win->h);
8542 glActiveTexture(GL_TEXTURE0);
8543 }
8544
8545 mx::Font overlayFont;
8546 mx::Font waterFont;
8547 gl::ShaderProgram muxOverlayShader;
8548 gl::GLSprite muxOverlaySprite;
8549 GLuint muxDummyTex = 0;
8550 float mux_time_f = 0.0f;
8551 std::chrono::steady_clock::time_point sessionStartTime;
8552 double displayFPS = 0.0;
8554 std::chrono::steady_clock::time_point fpsLastTime;
8555 bool counter_disabled = false;
8556
8557 /**
8558 * @brief Destroy the rendering object: stop threads, release GPU resources.
8559 *
8560 * Destruction order:
8561 * 1. Clean up MIDI (if enabled).
8562 * 2. Release TextureUploader (CUDA↔OpenGL PBO).
8563 * 3. Free CUDA filter arrays.
8564 * 4. Stop the capture thread (join).
8565 * 5. Flush any remaining PBO frames into the writer queue.
8566 * 6. Stop the writer thread (join), close the Writer, mux audio
8567 * if applicable.
8568 * 7. Join the mux thread if it was started.
8569 * 8. Close the audio subsystem.
8570 * 9. Delete PBOs, FBOs, textures, depth buffer.
8571 * 10.Release the VideoCapture.
8572 */
8573 ~ACView() override {
8574#if defined(__linux__) || defined(__APPLE__) || defined(_WIN32)
8575 cleanupShaderSelectionSharedMemory();
8576#endif
8577#ifdef MIDI_ENABLED
8578 cleanupMidi();
8579#endif
8580#ifdef ACMX2_WITH_DNN
8581 if (human_overlay_tex != 0) {
8582 glDeleteTextures(1, &human_overlay_tex);
8583 human_overlay_tex = 0;
8584 }
8585#endif
8587#ifdef ACMX2_WITH_CUDA
8588 if (d_ptrList) {
8589 cudaFree(d_ptrList);
8590 d_ptrList = nullptr;
8591 }
8592 if (d_filterList) {
8593 cudaFree(d_filterList);
8594 d_filterList = nullptr;
8595 }
8596#endif
8597 gpu_frame_buffer.reset();
8598
8600
8601 if (pboIds[0] && (writer.is_open() || png_video_mode) && recording_pbo_primed && win_w > 0 && win_h > 0) {
8602 // Double-buffered readback always leaves exactly one completed
8603 // recording frame pending. Flushing both PBOs duplicated the
8604 // preceding frame and could make a duration-limited silent render
8605 // exceed its configured maximum by one frame.
8606 const int pending_pbo_index = pboNextIndex;
8607 glBindBuffer(GL_PIXEL_PACK_BUFFER, pboIds[pending_pbo_index]);
8608 GLubyte *src = static_cast<GLubyte *>(glMapBuffer(GL_PIXEL_PACK_BUFFER, GL_READ_ONLY));
8609
8610 if (src) {
8611 std::vector<unsigned char> pixels(win_w * win_h * 4);
8612 std::memcpy(pixels.data(), src, pixels.size());
8613 glUnmapBuffer(GL_PIXEL_PACK_BUFFER);
8614
8615 std::vector<unsigned char> flipped_pixels(win_w * win_h * 4);
8616 for (int y = 0; y < win_h; ++y) {
8617 int src_row_start = y * win_w * 4;
8618 int dest_row_start = (win_h - 1 - y) * win_w * 4;
8619 std::copy(pixels.begin() + src_row_start, pixels.begin() + src_row_start + (win_w * 4), flipped_pixels.begin() + dest_row_start);
8620 }
8621
8622 FrameData fd;
8623 fd.pixels = std::move(flipped_pixels);
8624 fd.width = win_w;
8625 fd.height = win_h;
8626 fd.isSnapshot = false;
8628 fd.timelineFrame = recording_pbo_timeline_frame[pending_pbo_index];
8629
8630 {
8631 std::lock_guard<std::mutex> lock(queueMutex);
8632 frameQueue.push(std::move(fd));
8633 }
8634 queueCondVar.notify_one();
8635 }
8636 glBindBuffer(GL_PIXEL_PACK_BUFFER, 0);
8637
8638 std::this_thread::sleep_for(std::chrono::milliseconds(100));
8639 }
8640
8641 if (!isMuxing.load()) {
8642 bool shouldMux = !skip_audio_mux_on_exit.load() && needsMux() && writer.is_open();
8643 bool shouldFileAudioMux = !skip_audio_mux_on_exit.load() && needsFileAudioMux() && writer.is_open();
8645 if (shouldMux) {
8646 runMuxSync();
8647 }
8648 if (shouldFileAudioMux) {
8650 }
8651 }
8652 if (muxThread.joinable()) {
8653 muxThread.join();
8654 }
8655
8656#ifdef AUDIO_ENABLED
8657 if (audio_is_enabled) {
8660 if (file_audio_mode)
8662 else
8666 }
8667#endif
8668
8669 for (int i = 0; i < 2; ++i) {
8670#ifdef ACMX2_WITH_CUDA
8671 if (recordCudaPboResources[i]) {
8672 CHECK_CUDA(cudaGraphicsUnregisterResource(recordCudaPboResources[i]));
8673 recordCudaPboResources[i] = nullptr;
8674 }
8675#else
8676 static_cast<void>(i);
8677#endif
8678 }
8679
8680 if (pboIds[0]) {
8681 glDeleteBuffers(2, pboIds);
8682 pboIds[0] = pboIds[1] = 0;
8683 }
8684
8685 if (captureFBO) {
8686 glDeleteFramebuffers(1, &captureFBO);
8687 captureFBO = 0;
8688 }
8689 if (fboTexture) {
8690 glDeleteTextures(1, &fboTexture);
8691 fboTexture = 0;
8692 }
8693 if (preOverlayTexture) {
8694 glDeleteTextures(1, &preOverlayTexture);
8696 }
8697 if (preOverlayFBO) {
8698 glDeleteFramebuffers(1, &preOverlayFBO);
8699 preOverlayFBO = 0;
8700 }
8701 for (int p = 0; p < 2; ++p) {
8702 if (passFBO[p]) {
8703 glDeleteFramebuffers(1, &passFBO[p]);
8704 passFBO[p] = 0;
8705 }
8706 if (passTexture[p]) {
8707 glDeleteTextures(1, &passTexture[p]);
8708 passTexture[p] = 0;
8709 }
8710 }
8713 if (crossfadeFBO) {
8714 glDeleteFramebuffers(1, &crossfadeFBO);
8715 crossfadeFBO = 0;
8716 }
8717 if (crossfadeTexture) {
8718 glDeleteTextures(1, &crossfadeTexture);
8719 crossfadeTexture = 0;
8720 }
8722 glDeleteTextures(1, &crossfadePrevTexture);
8724 }
8725 if (depthBuffer) {
8726 glDeleteRenderbuffers(1, &depthBuffer);
8727 depthBuffer = 0;
8728 }
8729 if (camera_texture) {
8730 glDeleteTextures(1, &camera_texture);
8731 camera_texture = 0;
8732 }
8733
8734 if (texture_cache) {
8735 frame_cache.cleanup();
8736 }
8737
8738 if (cap.isOpened())
8739 cap.release();
8740 }
8741
8742 mx::Model cube;
8743 gl::ShaderProgram fshader, fshader3d;
8744 std::string m_file;
8745
8746 /**
8747 * @brief Called once by the GLWindow to initialise capture, shaders, FBOs, and recording.
8748 *
8749 * Performs the full OpenGL-dependent setup:
8750 * 1. Set the CUDA device.
8751 * 2. Load fonts.
8752 * 3. Open the input source (image file, camera, or video file);
8753 * set resolution, FPS, and window size.
8754 * 4. Open the output Writer (FFmpeg pipe) if `-o` was specified.
8755 * 5. Load and compile the shader library (from cache if available).
8756 * 6. Load the 3D model (if --enable-3d).
8757 * 7. Initialise framebuffer shader, texture cache, sprites.
8758 * 8. Set up the capture FBO and double-buffered PBOs.
8759 * 9. Start the writer thread (always) and the capture thread
8760 * (camera mode only).
8761 *
8762 * @param win Pointer to the hosting GLWindow.
8763 */
8764 virtual void load(gl::GLWindow *win) override {
8766 mx::system_out.flush();
8767
8768#ifdef ACMX2_WITH_CUDA
8769 cudaError_t cuda_err = cudaSetDevice(gpu_cuda_device);
8770 if (cuda_err != cudaSuccess) {
8771 throw mx::Exception("Failed to set CUDA device " + std::to_string(gpu_cuda_device) + ": " + std::string(cudaGetErrorString(cuda_err)));
8772 }
8773 mx::system_out << "acmx2: Using CUDA device: " << gpu_cuda_device << "\n";
8774 fflush(stdout);
8775#else
8776 mx::system_out << "acmx2: CUDA support disabled (compiled without ACMX2_WITH_CUDA)\n";
8777 fflush(stdout);
8778#endif
8779
8780 frame_counter = 0;
8781 sessionStartTime = std::chrono::steady_clock::now();
8783 fpsFrameCount = 0;
8784 displayFPS = 0.0;
8785
8786 overlayFont.tryLoadFont(win->util.getFilePath("data/font.ttf"), 24);
8787
8788 int w = 1280, h = 720;
8789 int frame_w = w, frame_h = h;
8790
8791 if (!graphic.empty()) {
8792 graphic_frame = cv::imread(graphic);
8793 if (graphic_frame.empty()) {
8794 throw mx::Exception("Graphics file not found: " + graphic);
8795 }
8796
8797 w = graphic_frame.cols;
8798 h = graphic_frame.rows;
8799 frame_w = w;
8800 frame_h = h;
8801 mx::system_out << "acmx2: Graphics file loaded: " << w << "x" << h << " at FPS: " << fps << "\n";
8802 fflush(stdout);
8803 fflush(stderr);
8805 std::swap(w, h);
8806 std::swap(frame_w, frame_h);
8807 }
8808 if (sizev.has_value()) {
8809 w = sizev.value().width;
8810 h = sizev.value().height;
8811 mx::system_out << "acmx2: Resolution stretched to: " << w << "x" << h << "\n";
8812 fflush(stdout);
8813 fflush(stderr);
8814 }
8815
8816 win->setWindowSize(w, h);
8817 SDL_PumpEvents();
8818 SDL_Delay(50);
8819 SDL_PumpEvents();
8820 SDL_GL_GetDrawableSize(win->getWindow(), &win->w, &win->h);
8821 if (win->w != w || win->h != h) {
8822 win->w = w;
8823 win->h = h;
8824 }
8825 glViewport(0, 0, win->w, win->h);
8826 SDL_SetWindowPosition(win->getWindow(), SDL_WINDOWPOS_CENTERED, SDL_WINDOWPOS_CENTERED);
8827 if (!ofilename.empty()) {
8828 if (writer.open(ofilename, w, h, fps, encode_opts)) {
8829 if (silent_mode || no_drop_mode) {
8830 // Pace the producer to encoder capacity so no encoded
8831 // frames are dropped and no large queue burst forms.
8832 writer.set_block_when_full(true);
8833 }
8834 mx::system_out << "acmx2: Opened: " << ofilename << " for writing at: CRF: " << encode_opts.crf << " preset: " << encode_opts.preset << " tune: " << (encode_opts.tune.empty() ? "none" : encode_opts.tune) << " codec: " << encode_opts.codec << (encode_opts.realtime ? " [realtime]" : "") << " FPS: " << fps << "\n";
8835 mx::system_out << "acmx2: Pipeline mode => decode: graphic/image, encode: " << (writer.is_hardware_encode() ? "h264_nvenc (hardware)" : "h264 (software)") << "\n";
8836
8837 fflush(stdout);
8838 fflush(stderr);
8839 } else {
8840 throw mx::Exception("Could not open output video file: " + ofilename);
8841 }
8842 }
8843 } else if (filename.empty()) {
8844 if (no_drop_mode) {
8845 mx::system_out << "acmx2: --no-drop is ignored in webcam mode; "
8846 "wall-clock timestamps and late-frame dropping remain active\n";
8847 no_drop_mode = false;
8848 }
8849#ifdef __linux__
8850 const bool loopback_device = isV4l2LoopbackDevice(camera_index);
8851#else
8852 const bool loopback_device = false;
8853#endif
8854#ifdef _WIN32
8855 cap.open(camera_index, cv::CAP_DSHOW);
8856#elif defined(__linux__)
8857 cap.open(camera_index, cv::CAP_V4L2);
8858#else
8859 cap.open(camera_index);
8860#endif
8861 if (!cap.isOpened()) {
8862 throw mx::Exception("Could not open camera index: " + std::to_string(camera_index));
8863 }
8864 cap.set(cv::CAP_PROP_BUFFERSIZE, 1);
8865 if (sizec.has_value()) {
8866 cap.set(cv::CAP_PROP_FRAME_WIDTH, sizec.value().width);
8867 cap.set(cv::CAP_PROP_FRAME_HEIGHT, sizec.value().height);
8868 } else {
8869 cap.set(cv::CAP_PROP_FRAME_WIDTH, win->w);
8870 cap.set(cv::CAP_PROP_FRAME_HEIGHT, win->h);
8871 }
8872 if (use_yuv)
8873 cap.set(cv::CAP_PROP_FOURCC, cv::VideoWriter::fourcc('Y', 'U', 'Y', 'V'));
8874 else
8875 cap.set(cv::CAP_PROP_FOURCC, cv::VideoWriter::fourcc('M', 'J', 'P', 'G'));
8876 const double requested_fps = fps;
8877 const bool fps_configured = cap.set(cv::CAP_PROP_FPS, requested_fps);
8878 w = static_cast<int>(cap.get(cv::CAP_PROP_FRAME_WIDTH));
8879 h = static_cast<int>(cap.get(cv::CAP_PROP_FRAME_HEIGHT));
8880 const double reported_fps = cap.get(cv::CAP_PROP_FPS);
8881 // v4l2loopback may continue to report the producer's initial
8882 // 30 fps interval even after accepting a high-speed consumer
8883 // interval. Keep the explicitly requested rate in that case so
8884 // the render loop does not impose an artificial 30 fps cap.
8885 if (loopback_device && requested_fps > 0.0) {
8886 fps = requested_fps;
8887 } else if (reported_fps > 0.0) {
8888 fps = reported_fps;
8889 } else {
8890 fps = requested_fps;
8891 }
8892 frame_w = w;
8893 frame_h = h;
8894 mx::system_out << "acmx2: Camera opened: " << w << "x" << h << " at FPS: " << fps;
8895 if (loopback_device && reported_fps > 0.0 && std::abs(reported_fps - requested_fps) > 0.05) {
8896 mx::system_out << " (loopback reports " << reported_fps << ", requested " << requested_fps << ")";
8897 } else if (!fps_configured && requested_fps > 0.0) {
8898 mx::system_out << " (driver rejected requested " << requested_fps << ")";
8899 }
8900 mx::system_out << "\n";
8901 fflush(stderr);
8902 fflush(stdout);
8903
8905 std::swap(w, h);
8906 std::swap(frame_w, frame_h);
8907 }
8908
8909 if (sizev.has_value()) {
8910 w = sizev.value().width;
8911 h = sizev.value().height;
8912 mx::system_out << "acmx2: Resolution stretched to: " << w << "x" << h << "\n";
8913 }
8914 win->setWindowSize(w, h);
8915 SDL_GL_GetDrawableSize(win->getWindow(), &win->w, &win->h);
8916 if (win->w != w || win->h != h) {
8917 win->w = w;
8918 win->h = h;
8919 }
8920 glViewport(0, 0, w, h);
8921
8922 SDL_SetWindowPosition(win->getWindow(), SDL_WINDOWPOS_CENTERED, SDL_WINDOWPOS_CENTERED);
8923
8924 if (!ofilename.empty()) {
8925 // Camera capture path — force realtime semantics to avoid
8926 // encoder stalls on live input regardless of user preset.
8927 EncodeOptions cam_opts = encode_opts;
8928 cam_opts.realtime = true;
8929 if (writer.open_ts(ofilename, w, h, fps, cam_opts)) {
8930 mx::system_out << "acmx2: Opened: " << ofilename << " for writing at: CRF: " << cam_opts.crf << " preset: " << cam_opts.preset << " codec: " << cam_opts.codec << " [realtime]"
8931 << " FPS: " << fps << "\n";
8932 mx::system_out << "acmx2: Pipeline mode => decode: camera, encode: " << (writer.is_hardware_encode() ? "(hardware)" : "(software)") << "\n";
8933 if (!no_drop_mode && fps > 0.0) {
8934 mx::system_out << "acmx2: Webcam recording follows wall-clock timestamps; "
8935 "late frames will be dropped\n";
8936 }
8937 } else {
8938 throw mx::Exception("Could not open output video file: " + ofilename);
8939 }
8940 }
8941 } else if (!filename.empty() && graphic.empty()) {
8943 const char *decode_mode = nullptr;
8944 if (use_ffmpeg_reader) {
8945 decode_mode = ffmpeg_reader.isHwDecodeEnabled() ? "ffmpeg-cuda" : "ffmpeg-software";
8946 w = ffmpeg_reader.getWidth();
8947 h = ffmpeg_reader.getHeight();
8948 fps = ffmpeg_reader.getFps();
8949 totalFrames = ffmpeg_reader.getFrameCount();
8950 library.setVideoFPS(fps);
8951
8952 frame_w = w;
8953 frame_h = h;
8954
8955 mx::system_out << "acmx2: Video opened (FFmpeg decode): " << w << "x" << h << " at FPS: " << fps << " Total Frames: " << totalFrames << "\n";
8956 mx::system_out << "acmx2: FFmpeg CUDA decode: " << (ffmpeg_reader.isHwDecodeEnabled() ? "enabled" : "unavailable/fallback") << "\n";
8957
8958 // If the input video carries HDR metadata (BT.2020 primaries /
8959 // PQ or HLG transfer / >=10-bit depth), the CUDA glitch filters
8960 // are not colour-correct on that data (they assume 8-bit sRGB).
8961 // Force-disable the CUDA filter stage and run shaders only.
8962 if (ffmpeg_reader.isHdr()) {
8963 // Record HDR state so the GL pipeline switches to the
8964 // 16-bit linear BT.2020 path (allocated further below).
8965 input_is_hdr = true;
8966 input_hdr_trc = ffmpeg_reader.getHdrTransfer();
8967 const int trc = input_hdr_trc;
8968 const char *trc_label = (trc == AVCOL_TRC_SMPTE2084) ? "PQ (SMPTE2084)" : (trc == AVCOL_TRC_ARIB_STD_B67) ? "HLG (ARIB STD-B67)" : (trc == AVCOL_TRC_BT2020_10) ? "BT.2020 10-bit" : (trc == AVCOL_TRC_BT2020_12) ? "BT.2020 12-bit" : "unknown";
8969 mx::system_out << "acmx2: ============================================================\n"
8970 << "acmx2: *** PROCESSING IN HDR MODE ***\n"
8971 << "acmx2: Source is HDR: " << trc_label << ", " << ffmpeg_reader.getHdrBitDepth() << "-bit,"
8972 << " BT.2020 primaries\n"
8973 << "acmx2: ============================================================\n";
8974 mx::system_out << "acmx2: HDR input detected (primaries=" << ffmpeg_reader.getHdrPrimaries() << ", transfer=" << trc << ", bit_depth=" << ffmpeg_reader.getHdrBitDepth() << ")\n";
8975 if (gpu_filter_enabled) {
8976 mx::system_out << "acmx2: *** CUDA GPU filters DISABLED for HDR input "
8977 "(colour math assumes 8-bit sRGB). Shader processing only. ***\n";
8978 gpu_filter_enabled = false;
8979 gpu_filters.clear();
8980 } else {
8981 mx::system_out << "acmx2: CUDA GPU filters not in use; shader processing only for HDR input.\n";
8982 }
8983
8984 // Populate encode_opts.hdr so the writer switches to the
8985 // HEVC Main10 + BT.2020 output path for both write paths
8986 // below. We force libx265 + Main10 regardless of the
8987 // user's codec preference when the input is HDR.
8988 encode_opts.hdr.enabled = true;
8989 encode_opts.hdr.color_primaries = ffmpeg_reader.getHdrPrimaries();
8990 encode_opts.hdr.color_trc = ffmpeg_reader.getHdrTransfer();
8991 encode_opts.hdr.color_space = ffmpeg_reader.getHdrColorspace();
8992 encode_opts.hdr.color_range = ffmpeg_reader.getHdrColorRange();
8993 encode_opts.hdr.mastering_display = ffmpeg_reader.getHdrMasteringDisplay();
8994 encode_opts.hdr.content_light = ffmpeg_reader.getHdrContentLight();
8995 mx::system_out << "acmx2: HDR output mode enabled: HEVC Main10 + BT.2020 + " << (encode_opts.hdr.color_trc == AVCOL_TRC_ARIB_STD_B67 ? "HLG" : "PQ") << " (mastering side-data bytes=" << encode_opts.hdr.mastering_display.size() << ", content-light bytes=" << encode_opts.hdr.content_light.size() << ")\n";
8996
8997 // Upgrade existing SDR GL resources (camera_texture,
8998 // fboTexture, crossfade textures) to 16-bit formats
8999 // and lazily create the HDR decode/encode machinery.
9001 mx::system_out << "acmx2: HDR GL pipeline: camera_texture=GL_RGBA16, "
9002 "fboTexture=GL_RGBA16F, pass textures=GL_RGBA16F, "
9003 "decode/encode shaders ready.\n";
9004 }
9005 } else {
9006 decode_mode = "opencv-ffmpeg";
9007#if CV_VERSION_MAJOR > 4 || (CV_VERSION_MAJOR == 4 && CV_VERSION_MINOR >= 5)
9008 std::vector<int> file_params = {cv::CAP_PROP_HW_ACCELERATION, cv::VIDEO_ACCELERATION_ANY};
9009 cap.open(filename, cv::CAP_FFMPEG, file_params);
9010#else
9011 cap.open(filename, cv::CAP_FFMPEG);
9012#endif
9013 if (!cap.isOpened()) {
9014 throw mx::Exception("Could not open video file: " + filename);
9015 }
9016 w = static_cast<int>(cap.get(cv::CAP_PROP_FRAME_WIDTH));
9017 h = static_cast<int>(cap.get(cv::CAP_PROP_FRAME_HEIGHT));
9018 fps = cap.get(cv::CAP_PROP_FPS);
9019 totalFrames = cap.get(cv::CAP_PROP_FRAME_COUNT);
9020 library.setVideoFPS(fps);
9021
9022 frame_w = w;
9023 frame_h = h;
9024
9025 mx::system_out << "acmx2: Video opened (OpenCV/FFmpeg fallback): " << w << "x" << h << " at FPS: " << fps << " Total Frames: " << totalFrames << "\n";
9026
9027#if CV_VERSION_MAJOR > 4 || (CV_VERSION_MAJOR == 4 && CV_VERSION_MINOR >= 5)
9028 int hw_accel = static_cast<int>(cap.get(cv::CAP_PROP_HW_ACCELERATION));
9029 mx::system_out << "acmx2: HW Acceleration result: " << hw_accel << (hw_accel == cv::VIDEO_ACCELERATION_NONE ? " (software/fallback)" : hw_accel == cv::VIDEO_ACCELERATION_ANY ? " (auto preference)" : hw_accel == cv::VIDEO_ACCELERATION_VAAPI ? " (VAAPI)" : hw_accel == cv::VIDEO_ACCELERATION_D3D11 ? " (D3D11)" : hw_accel == cv::VIDEO_ACCELERATION_MFX ? " (MFX)" : hw_accel == cv::VIDEO_ACCELERATION_DRM ? " (DRM)" : " (other)") << "\n";
9030#else
9031 mx::system_out << "acmx2: OpenCV hardware-decoder reporting unavailable "
9032 "with OpenCV "
9033 << CV_VERSION << "\n";
9034#endif
9035 }
9036
9037 if (fps > 60.0) {
9038 if (SDL_GL_SetSwapInterval(0) == 0) {
9039 mx::system_out << "acmx2: VSync disabled for high-frame-rate video input (" << fps << " FPS)\n";
9040 } else {
9041 mx::system_err << "acmx2: Could not disable VSync for " << fps << " FPS video input: " << SDL_GetError() << "\n";
9042 }
9043 }
9044
9046 std::swap(w, h);
9047 std::swap(frame_w, frame_h);
9048 }
9049
9050 fflush(stdout);
9051 fflush(stderr);
9052
9053 if (sizev.has_value()) {
9054 w = sizev.value().width;
9055 h = sizev.value().height;
9056 mx::system_out << "acmx2: Resolution stretched to: " << w << "x" << h << "\n";
9057 fflush(stdout);
9058 fflush(stderr);
9059 }
9060
9061 win->setWindowSize(w, h);
9062 SDL_GL_GetDrawableSize(win->getWindow(), &win->w, &win->h);
9063 if (win->w != w || win->h != h) {
9064 win->w = w;
9065 win->h = h;
9066 }
9067 glViewport(0, 0, w, h);
9068 SDL_SetWindowPosition(win->getWindow(), SDL_WINDOWPOS_CENTERED, SDL_WINDOWPOS_CENTERED);
9069
9070 if (!ofilename.empty()) {
9071 if (png_video_mode) {
9072 std::filesystem::path out_path(ofilename);
9073 std::filesystem::path out_parent = out_path.parent_path();
9074 if (out_parent.empty()) {
9075 out_parent = ".";
9076 }
9077 std::string out_name = out_path.filename().string();
9078 if (out_name.empty()) {
9079 out_name = "output";
9080 }
9081 std::filesystem::path frame_dir = out_parent / ("video_file-" + out_name + "-png");
9082 std::error_code mk_err;
9083 if (!std::filesystem::exists(frame_dir, mk_err) && !std::filesystem::create_directories(frame_dir, mk_err)) {
9084 throw mx::Exception("Could not create PNG output directory: " + frame_dir.string());
9085 }
9086 png_video_dir = frame_dir.string();
9087 mx::system_out << "acmx2: --png enabled: writing video frames to " << png_video_dir << "\n";
9088 } else {
9089 if (writer.open(ofilename, w, h, fps, encode_opts)) {
9090 bool block_encoder_queue = silent_mode || no_drop_mode;
9091 if (block_encoder_queue) {
9092 // Batch transcoding or --no-drop: pace the producer
9093 // to encoder capacity instead of filling a large
9094 // queue or dropping frames.
9095 writer.set_block_when_full(true);
9096 }
9097 mx::system_out << "acmx2: Opened: " << ofilename << " for writing at: CRF: " << encode_opts.crf << " preset: " << encode_opts.preset << " tune: " << (encode_opts.tune.empty() ? "none" : encode_opts.tune) << " codec: " << encode_opts.codec << (encode_opts.realtime ? " [realtime]" : "") << "\n";
9098 if (no_drop_mode) {
9099 mx::system_out << "acmx2: --no-drop active (video mode): frame processing paced to encoder throughput\n";
9100 }
9101 if (encode_opts.hdr.enabled) {
9102 mx::system_out << "acmx2: *** HDR OUTPUT ENABLED: writing HEVC Main10 + BT.2020 " << (encode_opts.hdr.color_trc == AVCOL_TRC_ARIB_STD_B67 ? "HLG" : "PQ") << " ***\n";
9103 }
9104 mx::system_out << "acmx2: Pipeline mode => decode: " << decode_mode << ", encode: " << (writer.is_hardware_encode() ? "(hardware)" : "(software)") << "\n";
9105 fflush(stdout);
9106 fflush(stderr);
9107 } else {
9108 throw mx::Exception("Could not open output video file: " + ofilename);
9109 }
9110 }
9111 }
9112 } else if (graphic.empty() && filename.empty()) {
9113 throw mx::Exception("Requires input from a file, or camera.");
9114 }
9115
9116 if (generate_mode) {
9117 std::filesystem::path gen_dir;
9118 if (!ofilename.empty()) {
9119 std::filesystem::path out_path(ofilename);
9120 std::filesystem::path out_parent = out_path.parent_path();
9121 if (out_parent.empty())
9122 out_parent = ".";
9123 std::string out_name = out_path.filename().string();
9124 if (out_name.empty())
9125 out_name = "output";
9126 gen_dir = out_parent / ("video_file-" + out_name + "-generate");
9127 } else if (!filename.empty()) {
9128 std::filesystem::path in_path(filename);
9129 std::filesystem::path in_parent = in_path.parent_path();
9130 if (in_parent.empty())
9131 in_parent = ".";
9132 std::string in_name = in_path.filename().string();
9133 if (in_name.empty())
9134 in_name = "input";
9135 gen_dir = in_parent / ("video_file-" + in_name + "-generate");
9136 } else {
9137 gen_dir = std::filesystem::path("camera-generate");
9138 }
9139 std::error_code mk_err;
9140 if (!std::filesystem::exists(gen_dir, mk_err) && !std::filesystem::create_directories(gen_dir, mk_err)) {
9141 throw mx::Exception("Could not create generate output directory: " + gen_dir.string());
9142 }
9143 generate_dir = gen_dir.string();
9144 mx::system_out << "acmx2: --generate " << generate_interval << ": saving PNG frames every " << generate_interval << " frames to " << generate_dir << "\n";
9145 fflush(stdout);
9146 }
9147
9148 library.is3D(is3d_enabled);
9149 library.enableDualMode(is3d_enabled);
9150 if (overlayFont.handle().has_value()) {
9151 win->text.init(win->w, win->h);
9152 win->text.setColor({255, 255, 255, 255});
9153 }
9154 waterFontSize = std::max(12, static_cast<int>(win->h / 40.0f));
9155 waterFont.tryLoadFont(win->util.getFilePath("data/font.ttf"), waterFontSize);
9156 mx::system_out << "acmx2: Watermark font loaded at size: " << waterFontSize << " for " << win->w << "x" << win->h << "\n";
9157 fflush(stdout);
9158
9159 if (muxOverlayShader.loadProgramFromText(kHdrVertPassthrough, kMuxOverlayFrag)) {
9160 static constexpr unsigned char kWhite[4] = {255, 255, 255, 255};
9161 glGenTextures(1, &muxDummyTex);
9162 glBindTexture(GL_TEXTURE_2D, muxDummyTex);
9163 glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, 1, 1, 0, GL_RGBA, GL_UNSIGNED_BYTE, kWhite);
9164 glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
9165 glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
9166 glBindTexture(GL_TEXTURE_2D, 0);
9167 muxOverlaySprite.initSize(win->w, win->h);
9168 muxOverlaySprite.setName("samp");
9169 muxOverlaySprite.initWithTexture(&muxOverlayShader, muxDummyTex, 0.0f, 0.0f, win->w, win->h);
9170 }
9171
9172 library.enableCache(use_shader_cache_flag);
9173 // Tell the library how many cache textures we'll bind so it can
9174 // (a) inject `#define SIZE N` into fragment sources before compile,
9175 // (b) size the per-program `texture_array_loc` lookup vector,
9176 // (c) keep the on-disk binary cache keyed per-size to avoid
9177 // reusing a binary compiled for a different SIZE.
9178 library.setCacheSize(static_cast<int>(frame_cache.capacity()));
9179 library.setHistoryTextureArray(texture_cache_array);
9180 if (std::get<0>(flib) == 1) {
9182 library.loadProgramsWithCache(win, std::get<1>(flib), overlayFont);
9183 else
9184 library.loadPrograms(win, std::get<1>(flib), overlayFont);
9185 } else {
9186 library.loadProgram(win, std::get<1>(flib));
9187 }
9188#if defined(__linux__) || defined(__APPLE__) || defined(_WIN32)
9189 if (shaderSelectionShm) {
9191 if (copyShaderSelectionSnapshot(snapshot))
9192 library.setCustomUniformValues(customUniformsFromSharedMemory(snapshot));
9193 }
9194#endif
9195 library.setIndex(std::get<2>(flib));
9196 if (!playlist_file.empty()) {
9197 std::ifstream pfile(playlist_file);
9198 if (!pfile.is_open()) {
9199 mx::system_err << "acmx2: Error could not open playlist: " << playlist_file << "\n";
9200 } else {
9201 std::string line;
9202 PlaylistNode *currentNode = nullptr;
9203 const auto shaderFiles = sortedShaderLibraryEntries(std::get<1>(flib));
9204 while (std::getline(pfile, line)) {
9205 if (line.empty())
9206 continue;
9207 if (line.front() == '[' && line.back() == ']') {
9208 playlist_tree.push_back({line.substr(1, line.size() - 2), {}});
9209 currentNode = &playlist_tree.back();
9210 continue;
9211 }
9212 int idx = shaderIndexForFile(shaderFiles, line);
9213 if (idx < 0) {
9214 const std::string name = std::filesystem::path(line).stem().string();
9215 idx = library.findShaderByName(name);
9216 }
9217 if (idx >= 0) {
9218 playlist_indices.push_back(idx);
9219 if (currentNode) {
9220 currentNode->shader_indices.push_back(idx);
9221 }
9222 } else {
9223 mx::system_err << "acmx2: Playlist shader not found: " << line << "\n";
9224 }
9225 }
9226 if (playlist_tree.empty() && !playlist_indices.empty()) {
9227 playlist_tree.push_back({"Default", playlist_indices});
9228 }
9229 mx::system_out << "acmx2: Playlist loaded [" << playlist_indices.size() << "] shaders in [" << playlist_tree.size() << "] nodes from: " << playlist_file << "\n";
9230 for (const auto &node : playlist_tree) {
9231 mx::system_out << " Node: " << node.name << " [" << node.shader_indices.size() << " shaders]\n";
9232 }
9233 fflush(stdout);
9234 }
9235 }
9237
9238 std::string m_file_path;
9239 if (std::filesystem::exists(m_file)) {
9240 m_file_path = m_file;
9241 } else {
9242 std::string m_file_base = m_file;
9243 if (m_file_base.size() > 5 && m_file_base.substr(0, 5) == "data/")
9244 m_file_base = m_file_base.substr(5);
9245 m_file_path = win->util.getFilePath("data/" + m_file_base);
9246 }
9247
9248 if (is3d_enabled && !cube.openModel(m_file_path)) {
9249 throw mx::Exception("Could not open model: cube.mxmod.z");
9250 }
9251 cube.setShaderProgram(library.shader(), "samp");
9252 cube.saveOriginal();
9253
9254 if (is3d_enabled && !cube.meshes.empty()) {
9255 float minX = std::numeric_limits<float>::max();
9256 float minY = std::numeric_limits<float>::max();
9257 float minZ = std::numeric_limits<float>::max();
9258 float maxX = std::numeric_limits<float>::lowest();
9259 float maxY = std::numeric_limits<float>::lowest();
9260 float maxZ = std::numeric_limits<float>::lowest();
9261 for (const auto &mesh : cube.meshes) {
9262 for (size_t i = 0; i + 2 < mesh.vert.size(); i += 3) {
9263 float x = mesh.vert[i];
9264 float y = mesh.vert[i + 1];
9265 float z = mesh.vert[i + 2];
9266 minX = std::min(minX, x);
9267 minY = std::min(minY, y);
9268 minZ = std::min(minZ, z);
9269 maxX = std::max(maxX, x);
9270 maxY = std::max(maxY, y);
9271 maxZ = std::max(maxZ, z);
9272 }
9273 }
9274 float dx = maxX - minX;
9275 float dy = maxY - minY;
9276 float dz = maxZ - minZ;
9277 modelSize = std::sqrt(dx * dx + dy * dy + dz * dz);
9278 if (modelSize < 0.001f)
9279 modelSize = 1.0f;
9280 glm::vec3 modelCenter = glm::vec3((minX + maxX) * 0.5f, (minY + maxY) * 0.5f, (minZ + maxZ) * 0.5f);
9281 modelCenterOffset = -modelCenter;
9282 float maxExtent = std::max(dx, std::max(dy, dz));
9283 const float targetSize = 2.5f;
9284 if (maxExtent > 1e-6f)
9285 modelRenderScale = targetSize / maxExtent;
9286 mx::system_out << "acmx2: Model bounding diagonal: " << modelSize << "\n";
9287 fflush(stdout);
9288 }
9289
9290 if (!fshader.loadProgram(win->util.getFilePath("data/vert.glsl"), win->util.getFilePath("data/framebuffer.glsl"))) {
9291 throw mx::Exception("Error loading shader");
9292 }
9293 if (!fshader3d.loadProgram(win->util.getFilePath("data/vertex.glsl"), win->util.getFilePath("data/framebuffer.glsl"))) {
9294 throw mx::Exception("Error loading shader");
9295 }
9296 {
9297 static const char *kCrossfadeFiles[] = {
9298 "data/xfade_01_linear.glsl", "data/xfade_02_block.glsl", "data/xfade_03_wipe.glsl", "data/xfade_04_radial.glsl", "data/xfade_05_pixelate.glsl", "data/xfade_06_dissolve.glsl", "data/xfade_07_swirl.glsl", "data/xfade_08_glitch.glsl", "data/xfade_09_diamond.glsl", "data/xfade_10_burn.glsl", "data/xfade_11_fade_black.glsl", "data/xfade_12_fade_white.glsl", "data/xfade_13_slide_left.glsl", "data/xfade_14_slide_right.glsl", "data/xfade_15_slide_up.glsl", "data/xfade_16_slide_down.glsl", "data/xfade_17_diagonal_wipe.glsl", "data/xfade_18_iris_open.glsl",
9299 "data/xfade_19_iris_close.glsl", "data/xfade_20_checker.glsl", "data/xfade_21_blinds_h.glsl", "data/xfade_22_blinds_v.glsl", "data/xfade_23_zoom_in.glsl", "data/xfade_24_zoom_out.glsl", "data/xfade_25_rotate.glsl", "data/xfade_26_ripple.glsl", "data/xfade_27_wave.glsl", "data/xfade_28_chroma.glsl", "data/xfade_29_invert.glsl", "data/xfade_30_flash.glsl", "data/xfade_31_explode.glsl", "data/xfade_32_mosaic.glsl", "data/xfade_33_shutter.glsl", "data/xfade_34_luma.glsl", "data/xfade_35_noise.glsl",
9300 };
9301 crossfadeShaders.clear();
9302 crossfadeShaderNames.clear();
9303 const std::string vertPath = win->util.getFilePath("data/vert.glsl");
9304 for (const char *frag : kCrossfadeFiles) {
9305 gl::ShaderProgram prog;
9306 if (!prog.loadProgram(vertPath, win->util.getFilePath(frag))) {
9307 throw mx::Exception(std::string("Error loading crossfade shader: ") + frag);
9308 }
9309 crossfadeShaders.push_back(prog);
9310 std::string nm = frag;
9311 auto slash = nm.find_last_of('/');
9312 if (slash != std::string::npos)
9313 nm = nm.substr(slash + 1);
9314 auto dot = nm.find_last_of('.');
9315 if (dot != std::string::npos)
9316 nm = nm.substr(0, dot);
9317 crossfadeShaderNames.push_back(nm);
9318 }
9319 if (crossfadeShaderIndex < 0 || crossfadeShaderIndex >= static_cast<int>(crossfadeShaders.size()))
9321 }
9322 GLenum error = glGetError();
9323 if (error != GL_NO_ERROR) {
9324 throw mx::Exception("OpenGL error occurred: GL Error: " + std::to_string(error));
9325 }
9326
9327 library.useProgram();
9328 if (texture_cache) {
9329 if (input_is_hdr) {
9330 // HDR cache rides on the post-decode linear BT.2020 RGBA16F
9331 // texture, which is allocated at win->w x win->h by
9332 // ensureHdrResources(). The ring uses the same dimensions
9333 // and format so glCopyTexSubImage2D can copy GPU->GPU.
9334 frame_cache.init(win->w, win->h, true);
9335 } else {
9336 frame_cache.init(frame_w, frame_h);
9337 }
9338 mx::system_out << "acmx2: Texture cache initalized.\n";
9339 fflush(stdout);
9340 }
9341 sprite.initSize(win->w, win->h);
9342 // Initialise the uploader at the SOURCE frame dimensions, not the
9343 // output (win->w/h) dimensions. When --resolution differs from the
9344 // camera/video source size, the per-frame TextureUploader::update()
9345 // would otherwise hit its size-mismatch branch on the very first
9346 // frame, call init() again, delete the original GL texture and
9347 // create a new textureID. `camera_texture` (captured below) would
9348 // then point at a deleted name and every subsequent sample would
9349 // produce black. Initialising at the source size keeps the
9350 // CUDA<->GL registration stable for the common case.
9351 {
9352 const int up_w = (frame_w > 0) ? frame_w : win->w;
9353 const int up_h = (frame_h > 0) ? frame_h : win->h;
9354 tex_uploader.init(up_w, up_h);
9355 }
9357 if (input_is_hdr) {
9358 // HDR path uploads decoded frames directly via glTex(Sub)Image2D.
9359 // Do not reuse TextureUploader's texture object here: when
9360 // --resolution differs from source size, re-specifying that
9361 // texture has caused driver crashes in headless EGL on NVIDIA.
9362 // Keep an independent texture dedicated to HDR uploads.
9363 glGenTextures(1, &camera_texture);
9364 glBindTexture(GL_TEXTURE_2D, camera_texture);
9365 const int upload_w = (frame_w > 0) ? frame_w : win->w;
9366 const int upload_h = (frame_h > 0) ? frame_h : win->h;
9367 glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA16, upload_w, upload_h, 0, GL_RGBA, GL_UNSIGNED_SHORT, nullptr);
9368 glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
9369 glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
9370 glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
9371 glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
9372 glBindTexture(GL_TEXTURE_2D, 0);
9373 hdr_upload_tex_w = upload_w;
9374 hdr_upload_tex_h = upload_h;
9375 }
9376 sprite.setName("samp");
9377 sprite.initWithTexture(library.shader(), camera_texture, 0, 0, win->w, win->h);
9378 setupCaptureFBO(win->w, win->h);
9379 if (input_is_hdr) {
9380 // At HDR detection time, GL resources may not exist yet; run the
9381 // conversion now with actual allocated textures/FBOs and the
9382 // final output size.
9383 convertResourcesToHdr(win->w, win->h);
9384 }
9385 glGenBuffers(2, pboIds);
9386 size_t pboSize = win->w * win->h * 4;
9387 for (int i = 0; i < 2; i++) {
9388 glBindBuffer(GL_PIXEL_PACK_BUFFER, pboIds[i]);
9389 glBufferData(GL_PIXEL_PACK_BUFFER, pboSize, nullptr, GL_STREAM_READ);
9390#ifdef ACMX2_WITH_CUDA
9391 CHECK_CUDA(cudaGraphicsGLRegisterBuffer(&recordCudaPboResources[i], pboIds[i], cudaGraphicsMapFlagsReadOnly));
9392#endif
9393 }
9394 glBindBuffer(GL_PIXEL_PACK_BUFFER, 0);
9395
9396 if (!graphic.empty())
9397 win->setWindowTitle("ACMX2 - Graphics Input");
9398 else if (filename.empty())
9399 win->setWindowTitle("ACMX2 - Capture Input");
9400 else
9401 win->setWindowTitle("ACMX2 - [" + filename + "]");
9402
9403 if (full) {
9404 win->setFullScreen(true);
9405 }
9406 running = true;
9407 if (writer.is_open() || true /* snapshots possible */) {
9409 }
9410
9411 if (filename.empty() && cap.isOpened()) {
9413 // Give the camera ~2 seconds to stabilize after loading before
9414 // pushing real content into the frame cache. This ensures that
9415 // any loading-screen remnants are not captured into samp1-samp8.
9417 }
9418 }
9419
9420 cv::Mat newFrame;
9421
9423
9424 void rotate_frame(cv::Mat &frame) {
9425 if (frame.empty() || frame_rotation == FrameRotation::None) {
9426 return;
9427 }
9428
9429 // SDR frames are vertically corrected before upload and again during
9430 // GL readback. Conjugating a 90-degree rotation by those flips
9431 // reverses its visible direction, so use the opposite source-space
9432 // transform to preserve the direction selected by the user.
9433 FrameRotation source_rotation = frame_rotation;
9434 if (!input_is_hdr) {
9435 if (source_rotation == FrameRotation::Clockwise90) {
9436 source_rotation = FrameRotation::Counterclockwise90;
9437 } else if (source_rotation == FrameRotation::Counterclockwise90) {
9438 source_rotation = FrameRotation::Clockwise90;
9439 }
9440 }
9441
9442#ifdef ACMX2_WITH_CUDA
9443 gpu_rotation_input.upload(frame);
9444 cv::Size destination_size = frame.size();
9445 double angle = 180.0;
9446 double shift_x = static_cast<double>(frame.cols - 1);
9447 double shift_y = static_cast<double>(frame.rows - 1);
9448
9449 if (source_rotation == FrameRotation::Clockwise90) {
9450 destination_size = cv::Size(frame.rows, frame.cols);
9451 angle = -90.0;
9452 shift_x = static_cast<double>(frame.rows - 1);
9453 shift_y = 0.0;
9454 } else if (source_rotation == FrameRotation::Counterclockwise90) {
9455 destination_size = cv::Size(frame.rows, frame.cols);
9456 angle = 90.0;
9457 shift_x = 0.0;
9458 shift_y = static_cast<double>(frame.cols - 1);
9459 }
9460
9461 cv::cuda::rotate(gpu_rotation_input, gpu_rotation_output, destination_size, angle, shift_x, shift_y, cv::INTER_NEAREST);
9462 gpu_rotation_output.download(frame);
9463#else
9464 int rotation_code = cv::ROTATE_180;
9465 if (source_rotation == FrameRotation::Clockwise90) {
9466 rotation_code = cv::ROTATE_90_CLOCKWISE;
9467 } else if (source_rotation == FrameRotation::Counterclockwise90) {
9468 rotation_code = cv::ROTATE_90_COUNTERCLOCKWISE;
9469 }
9470 cv::rotate(frame, frame, rotation_code);
9471#endif
9472 }
9473
9474 float movementSpeed = 0.1f;
9475
9476 /**
9477 * @brief Called every frame: capture, GPU-filter, shade, composite, record, display overlay.
9478 *
9479 * This is the main rendering loop body. On each invocation:
9480 * 1. Rate-limit to the configured FPS.
9481 * 2. Poll MIDI (if enabled).
9482 * 3. If muxing, draw the overlay and return early.
9483 * 4. Check the duration limit.
9484 * 5. Read a new frame: from capture queue (camera), cap.read()
9485 * (video), or clone (image).
9486 * 6. Apply GPU CUDA filters (if enabled) via TextureUploader /
9487 * launch_filter(), otherwise upload via glTexSubImage2D.
9488 * 7. Bind the capture FBO, activate the shader, upload uniforms.
9489 * 8. Render in 2D (sprite quad) or 3D (model with camera/wave),
9490 * including multi-pass chains through ping-pong FBOs.
9491 * 9. Blit the FBO result to the default framebuffer.
9492 * 10. PBO double-buffered readback into FrameData, push to writer queue.
9493 * 11. Draw the HUD overlay (shader name, FPS, timer).
9494 * 12. Update the window title with progress info.
9495 *
9496 * @param win Pointer to the hosting GLWindow.
9497 */
9498 virtual void draw(gl::GLWindow *win) override {
9499 // Skip FPS pacing in headless/silent batch mode so transcoding runs
9500 // at full speed. Real-time file-audio output is the exception: its
9501 // hardware clock must pace video even when the window is hidden.
9502 bool use_realtime_pacing = !silent_mode;
9503#ifdef AUDIO_ENABLED
9504 use_realtime_pacing = use_realtime_pacing || (file_audio_mode && file_audio_has_output_clock());
9505#endif
9506 if (fps > 0.0 && use_realtime_pacing) {
9507 auto now = std::chrono::steady_clock::now();
9508 auto frame_duration = std::chrono::microseconds(static_cast<long long>(1000000.0 / fps));
9509 if (now > lastFrameTime + (frame_duration * 4)) {
9510 lastFrameTime = now;
9511 }
9512 auto elapsed = std::chrono::duration_cast<std::chrono::microseconds>(now - lastFrameTime);
9513 if (elapsed < frame_duration) {
9514 std::this_thread::sleep_for(frame_duration - elapsed);
9515 }
9516 lastFrameTime += frame_duration;
9517 }
9518
9519#ifdef MIDI_ENABLED
9520 pollMidi(win);
9521#endif
9522
9523#if defined(__linux__) || defined(__APPLE__) || defined(_WIN32)
9524 syncShaderSelectionFromInterface(win);
9525#endif
9526
9527 if (isMuxing.load()) {
9528 if (muxComplete.load()) {
9529 if (muxThread.joinable())
9530 muxThread.join();
9531 isMuxing = false;
9532 win->quit();
9533 return;
9534 }
9535 glViewport(0, 0, win->w, win->h);
9536 glClearColor(0.0f, 0.0f, 0.0f, 1.0f);
9537 glClear(GL_COLOR_BUFFER_BIT);
9538 if (muxDummyTex) {
9539 muxOverlayShader.useProgram();
9540 muxOverlayShader.setUniform("mv_matrix", glm::mat4(1.0f));
9541 muxOverlayShader.setUniform("proj_matrix", glm::mat4(1.0f));
9542 glUniform1f(glGetUniformLocation(muxOverlayShader.id(), "time_f"), mux_time_f);
9543 glUniform2f(glGetUniformLocation(muxOverlayShader.id(), "iResolution"), static_cast<float>(win->w), static_cast<float>(win->h));
9544 glUniform1f(glGetUniformLocation(muxOverlayShader.id(), "alpha"), 1.0f);
9545 muxOverlaySprite.draw(muxDummyTex, 0, 0, win->w, win->h);
9546 mux_time_f += 0.016f;
9547 }
9548 if (overlayFont.handle().has_value()) {
9549 glEnable(GL_BLEND);
9550 glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
9551 win->text.setColor({255, 255, 255, 255});
9552 win->text.printText_Blended(overlayFont, 10, 10, "Muxing audio...");
9553 glDisable(GL_BLEND);
9554 }
9555 return;
9556 }
9557
9558 if (duration_limit > 0.0 && media_timeline_started && (writer.is_open() || png_video_mode) && writerRunning) {
9559 double time_passed = 0.0;
9560 bool has_media_clock = false;
9561 if (filename.empty() && graphic.empty() && !no_drop_mode && fps > 0.0) {
9562 time_passed = std::chrono::duration<double>(std::chrono::steady_clock::now() - media_timeline_start_time).count();
9563 has_media_clock = true;
9564 }
9565#ifdef AUDIO_ENABLED
9567 time_passed = file_audio_playback_time();
9568 has_media_clock = true;
9569 }
9570#endif
9571 if (!has_media_clock && fps > 0.0) {
9572 frames_proc++;
9573 time_passed = static_cast<double>(frames_proc) / fps;
9574 }
9575 if (time_passed >= duration_limit) {
9576 if (silent_mode && !graphic.empty()) {
9578 } else if (silent_mode && !filename.empty()) {
9580 }
9581 mx::system_out << "acmx2: Duration limit reached (" << duration_limit << "s), stopping recording...\n";
9582 fflush(stdout);
9583 running = false;
9584 }
9585 }
9586
9587 if (max_size_limit_bytes > 0.0 && writer.is_open() && writerRunning && !ofilename.empty()) {
9588 std::error_code size_error;
9589 const std::uintmax_t output_size = std::filesystem::file_size(ofilename, size_error);
9590 if (!size_error) {
9591 const double current_size = static_cast<double>(output_size);
9592 if (current_size > max_size_limit_bytes) {
9593 mx::system_out << "acmx2: Max size reached (" << std::fixed << std::setprecision(2) << max_size_limit_mb << " MB), stopping recording...\n";
9594 fflush(stdout);
9595 running = false;
9596 }
9597 }
9598 }
9599
9600 if (!running) {
9602 beginMuxing(win);
9603 return;
9604 }
9605 win->quit();
9606 return;
9607 }
9608
9609 bool received_source_frame = false;
9610 bool file_audio_clock_controls_video = false;
9611 bool source_frame_uses_timeline_clock = false;
9612 uint64_t source_timeline_frame = 0;
9613 bool stop_after_recording_drain = false;
9614 if (!isPaused && !isFrozen) {
9615 if (!graphic.empty()) {
9616 newFrame = graphic_frame.clone();
9617 cv::flip(newFrame, newFrame, 0);
9618 received_source_frame = !newFrame.empty();
9619 } else if (filename.empty()) {
9620 std::unique_lock<std::mutex> lock(captureQueueMutex);
9621 if (!captureQueue.empty()) {
9622 newFrame = std::move(captureQueue.front());
9623 captureQueue.pop();
9624 received_source_frame = !newFrame.empty();
9625 if (received_source_frame && writer.is_open() && !no_drop_mode && fps > 0.0) {
9626 // Live webcam recording follows wall-clock time. If
9627 // rendering or encoding falls behind, explicit PTS
9628 // gaps keep the video duration aligned with live
9629 // audio instead of slowing playback down.
9630 source_frame_uses_timeline_clock = true;
9632 const double elapsed_seconds = std::chrono::duration<double>(std::chrono::steady_clock::now() - media_timeline_start_time).count();
9633 source_timeline_frame = static_cast<uint64_t>(std::floor(std::max(0.0, elapsed_seconds) * fps));
9634 }
9635 }
9636 }
9637 } else {
9638 bool audio_clock_available = false;
9639 double audio_clock_time = 0.0;
9640#ifdef AUDIO_ENABLED
9641 audio_clock_available = file_audio_mode && file_audio_has_output_clock();
9642 file_audio_clock_controls_video = audio_clock_available;
9643 if (audio_clock_available) {
9644 audio_clock_time = file_audio_playback_time();
9645 }
9646#endif
9647
9648 uint64_t target_frame = decoded_video_frame_count;
9649 if (audio_clock_available && media_timeline_started && fps > 0.0) {
9650 target_frame = static_cast<uint64_t>(std::floor(std::max(0.0, audio_clock_time) * fps));
9651 }
9652
9653 const bool frame_due = !audio_clock_available || !media_timeline_started || target_frame >= decoded_video_frame_count;
9654 if (frame_due) {
9655 const uint64_t frames_to_decode = audio_clock_available && media_timeline_started ? target_frame - decoded_video_frame_count + 1 : 1;
9656 bool read_ok = false;
9657 bool decoded_any_frame = false;
9658
9659 auto read_next_frame = [&](bool discard) {
9660 bool ok = false;
9661 if (use_ffmpeg_reader) {
9662 ok = discard ? ffmpeg_reader.skip() : input_is_hdr ? ffmpeg_reader.readHdr(hdr_frame_mat) : ffmpeg_reader.read(newFrame);
9663 if (!ok && repeat) {
9664 mx::system_out << "acmx2: video loop...\n";
9665 if (ffmpeg_reader.seekStart()) {
9666 ok = discard ? ffmpeg_reader.skip() : input_is_hdr ? ffmpeg_reader.readHdr(hdr_frame_mat) : ffmpeg_reader.read(newFrame);
9667 }
9668 }
9669 } else {
9670 ok = discard ? cap.grab() : cap.read(newFrame);
9671 if (!ok && repeat) {
9672 mx::system_out << "acmx2: video loop...\n";
9673 cap.set(cv::CAP_PROP_POS_FRAMES, 0);
9674 ok = discard ? cap.grab() : cap.read(newFrame);
9675 }
9676 }
9677 if (ok) {
9679 }
9680 return ok;
9681 };
9682
9683 for (uint64_t frame = 0; frame < frames_to_decode; ++frame) {
9684 const bool discard = frame + 1 < frames_to_decode;
9685 read_ok = read_next_frame(discard);
9686 if (!read_ok) {
9687 break;
9688 }
9689 decoded_any_frame = true;
9690 }
9691
9692 if (!read_ok) {
9693 if (decoded_any_frame) {
9694 // The audio clock jumped beyond the final source
9695 // timestamp. Render the last successfully decoded
9696 // frame, then detect EOF again on the next draw so
9697 // the asynchronous PBO can be drained.
9698 read_ok = true;
9699 } else if (repeat) {
9700 mx::system_out << "acmx2: cannot read after looping.\n";
9701 }
9702 if (!read_ok) {
9703 const bool can_drain_recording = !input_is_hdr && recording_pbo_primed && (writer.is_open() || png_video_mode || generate_mode);
9704 if (can_drain_recording) {
9705 stop_after_recording_drain = true;
9706 } else {
9707 if (silent_mode) {
9708 std::cout << "\n";
9709 }
9710 running = false;
9711 finished = true;
9712 return;
9713 }
9714 }
9715 }
9716
9717 if (read_ok) {
9718 received_source_frame = input_is_hdr ? !hdr_frame_mat.empty() : !newFrame.empty();
9719 source_frame_uses_timeline_clock = audio_clock_available;
9720 source_timeline_frame = audio_clock_available ? std::min(target_frame, decoded_video_frame_count - 1) : decoded_video_frame_count - 1;
9721 if (!newFrame.empty())
9722 cv::flip(newFrame, newFrame, 0);
9723 }
9724 }
9725 // HDR path: leave @c hdr_frame_mat top-down. The SDR path
9726 // pre-flips because its shader chain produces a Y-flipped
9727 // readback that the CPU loop then re-flips; the HDR path
9728 // has an additional sprite.draw pass (hdr_encode) before
9729 // the PBO-free readback, and empirically the single CPU
9730 // flip in @c hdrReadback's caller is all that is needed
9731 // to deliver top-down rows to the HEVC encoder.
9732 }
9733 }
9734
9735 if (received_source_frame && !isFrozen) {
9736 if (input_is_hdr) {
9738 } else {
9740 }
9741 }
9742#ifdef ACMX2_WITH_CUDA
9743 bool onnxGpuFrameReady = false;
9744#endif
9745#ifdef ACMX2_WITH_DNN
9746 // Human segmentation pass (PPHS):
9747 // * Default (--human only): isolate the person, blacken background,
9748 // then run the entire shader pipeline on the cutout.
9749 // * --human --background: leave @c newFrame untouched so shaders
9750 // process the full frame; build an RGBA overlay (original frame
9751 // + hardened alpha mask) and composite it on top of the shader
9752 // output via a final GL blend pass below.
9753 human_overlay_ready = false;
9754 if (human_seg_model && !isFrozen && !input_is_hdr && !newFrame.empty()) {
9755 try {
9756 cv::Mat mask = human_seg_model->infer(newFrame);
9757 if (human_background_only) {
9758 cv::Mat alpha8 = ac_dnn::hardenedAlphaMask(newFrame, mask, human_black_point, human_white_point);
9759 if (!alpha8.empty() && alpha8.size() == newFrame.size()) {
9760 cv::Mat rgba;
9761 cv::cvtColor(newFrame, rgba, cv::COLOR_BGR2RGBA);
9762 cv::insertChannel(alpha8, rgba, 3);
9763
9764 // Lazy-allocate / resize the overlay GL texture.
9765 if (human_overlay_tex == 0) {
9766 glGenTextures(1, &human_overlay_tex);
9767 glBindTexture(GL_TEXTURE_2D, human_overlay_tex);
9768 glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
9769 glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
9770 glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
9771 glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
9772 human_overlay_w = 0;
9773 human_overlay_h = 0;
9774 }
9775 glBindTexture(GL_TEXTURE_2D, human_overlay_tex);
9776 if (human_overlay_w != rgba.cols || human_overlay_h != rgba.rows) {
9777 glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, rgba.cols, rgba.rows, 0, GL_RGBA, GL_UNSIGNED_BYTE, rgba.ptr());
9778 human_overlay_w = rgba.cols;
9779 human_overlay_h = rgba.rows;
9780 } else {
9781 glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, rgba.cols, rgba.rows, GL_RGBA, GL_UNSIGNED_BYTE, rgba.ptr());
9782 }
9783 glBindTexture(GL_TEXTURE_2D, 0);
9784 human_overlay_ready = true;
9785
9786 // Remove the person from newFrame so the shader chain
9787 // processes only the background. The GL blend pass
9788 // below composites the original person back on top
9789 // using straight-alpha blending.
9790 cv::Mat inverseAlpha;
9791 cv::subtract(cv::Scalar::all(255), alpha8, inverseAlpha);
9792 cv::Mat inverseBgr;
9793 cv::cvtColor(inverseAlpha, inverseBgr, cv::COLOR_GRAY2BGR);
9794 cv::multiply(newFrame, inverseBgr, newFrame, 1.0 / 255.0, CV_8UC3);
9795 }
9796 } else {
9797 cv::Mat isolated = ac_dnn::isolateBody(newFrame, mask, human_black_point, human_white_point);
9798 if (!isolated.empty()) {
9799 newFrame = isolated;
9800 }
9801 }
9802 } catch (const cv::Exception &e) {
9803 mx::system_err << "acmx2: PPHS inference error: " << e.what() << "\n";
9804 }
9805 }
9806 // Edge detection pass (Dexined): replaces newFrame with a 3-channel
9807 // edge map so the shader chain renders on top of the edge output.
9808 if (edge_det_model && !isFrozen && !input_is_hdr && !newFrame.empty()) {
9809 try {
9810 cv::Mat edges;
9811 edge_det_model->processFrame(newFrame, edges);
9812 if (!edges.empty()) {
9813 if (edges.channels() == 1)
9814 cv::cvtColor(edges, newFrame, cv::COLOR_GRAY2BGR);
9815 else
9816 newFrame = edges;
9817 }
9818 } catch (const cv::Exception &e) {
9819 mx::system_err << "acmx2: Dexined inference error: " << e.what() << "\n";
9820 }
9821 }
9822 // Generic ONNX pass. When no CUDA filter needs a host frame, keep the
9823 // final normalize/resize/colour conversion in VRAM and hand RGBA
9824 // directly to TextureUploader below. GPU filters currently own a
9825 // host-input frame history, so retain the BGR CPU path for that case.
9826 if (onnx_proc_model && !isFrozen && !input_is_hdr && !newFrame.empty()) {
9827 try {
9828#ifdef ACMX2_WITH_CUDA
9829 const bool gpuFiltersNeedHostFrame = gpu_filter_enabled && !gpu_filters.empty() && gpu_frame_buffer;
9830 if (!gpuFiltersNeedHostFrame)
9831 onnxGpuFrameReady = onnx_proc_model->procGpu(newFrame, onnxGpuOutput);
9832 if (!onnxGpuFrameReady)
9833#endif
9834 {
9835 cv::Mat onnx_out;
9836 onnx_proc_model->proc(newFrame, onnx_out);
9837 if (!onnx_out.empty())
9838 newFrame = onnx_out;
9839 }
9840 } catch (const cv::Exception &e) {
9841 mx::system_err << "acmx2: OnnxWrapper inference error: " << e.what() << "\n";
9842 }
9843 }
9844#endif
9845 if (library.isBypassed()) {
9846 if (is3d_enabled) {
9847 fshader3d.useProgram();
9848 } else {
9849 fshader.useProgram();
9850 }
9851 } else {
9852 library.useProgram();
9853 }
9854 if (!isFrozen && input_is_hdr) {
9855 // HDR branch: upload 16-bit RGBA (PQ/HLG-encoded BT.2020) and
9856 // run the decode fullscreen pass so the user-shader chain
9857 // samples linear BT.2020 light via @c hdr_linear_video_texture.
9858 if (!hdr_frame_mat.empty()) {
9859 glActiveTexture(GL_TEXTURE0);
9861 runHdrDecodePass(win->w, win->h);
9862 }
9863 const bool multipass_uses_cache = shader_pass_enabled && [&]() {
9864 for (int idx : shader_pass_list) {
9865 if (idx >= 0 && library.isCache2D(static_cast<size_t>(idx)))
9866 return true;
9867 }
9868 return false;
9869 }();
9870 if (texture_cache && (library.isCache() || multipass_uses_cache)) {
9871 static int hdr_counter = 0;
9872 if (frame_cache.size() == 0) {
9873 if (received_source_frame) {
9874 frame_cache.pushFromFBO(hdr_linear_video_fbo, win->w, win->h);
9875 hdr_counter = 0;
9876 }
9877 } else if (++hdr_counter > cache_delay) {
9878 if (cache_warmup_frames <= 0) {
9879 // GPU->GPU copy of the freshly decoded linear-light
9880 // frame into the next ring slot. No CPU readback.
9881 frame_cache.pushFromFBO(hdr_linear_video_fbo, win->w, win->h);
9882 }
9883 hdr_counter = 0;
9884 }
9885 if (frame_cache.isFull()) {
9886 if (texture_cache_array) {
9887 library.setHistoryHead(frame_cache.oldestLayer());
9888 library.setUniform("history", 0);
9889 glActiveTexture(GL_TEXTURE1);
9890 glBindTexture(GL_TEXTURE_2D_ARRAY, frame_cache.historyTexture());
9891 } else {
9892 const int n_bind = library.cacheSize();
9893 for (int i = 0; i < n_bind; ++i) {
9894 // setUniform(name, slot) routes through ProgramData
9895 // and assigns BOTH `samp(i+1)` (for i<8) and
9896 // `textures[i]` to texture unit i+1.
9897 library.setUniform("samp" + std::to_string(i + 1), i);
9898 glActiveTexture(GL_TEXTURE1 + i);
9899 glBindTexture(GL_TEXTURE_2D, frame_cache.textureAt(i));
9900 }
9901 }
9902 }
9903 }
9904 } else if (!isFrozen && !newFrame.empty()) {
9905#ifdef ACMX2_WITH_CUDA
9906 if (onnxGpuFrameReady) {
9907 tex_uploader.update(onnxGpuOutput);
9910 } else if (gpu_filter_enabled && !gpu_filters.empty() && gpu_frame_buffer) {
9911 gpu_frame_buffer->update(newFrame);
9912
9913 if (gpuWorkingBuffer.empty() || gpuWorkingBuffer.cols != newFrame.cols || gpuWorkingBuffer.rows != newFrame.rows) {
9914 gpuWorkingBuffer.create(newFrame.rows, newFrame.cols, CV_8UC4);
9915 }
9916
9917 if (gpu_alpha_dir == 1) {
9918 gpu_alpha += 0.01f;
9919 if (gpu_alpha >= 3.0f)
9920 gpu_alpha_dir = 0;
9921 } else {
9922 gpu_alpha -= 0.01f;
9923 if (gpu_alpha <= 1.0f)
9924 gpu_alpha_dir = 1;
9925 }
9926
9927 if (gpu_frame_dir == 1) {
9929 if (gpu_frame_index >= gpu_frame_buffer->arraySize - 1) {
9930 gpu_frame_index = gpu_frame_buffer->arraySize - 1;
9931 gpu_frame_dir = 0;
9932 }
9933 } else {
9935 if (gpu_frame_index <= 0) {
9936 gpu_frame_index = 0;
9937 gpu_frame_dir = 1;
9938 }
9939 }
9940
9941 CHECK_CUDA(cudaMemcpy(d_ptrList, gpu_frame_buffer->rawPointers.data(), gpu_frame_buffer->arraySize * sizeof(unsigned char *), cudaMemcpyHostToDevice));
9942
9943 CHECK_CUDA(cudaMemcpy2D(gpuWorkingBuffer.ptr<unsigned char>(), gpuWorkingBuffer.step, gpu_frame_buffer->deviceFrames[gpu_frame_buffer->arraySize - 1].data, gpu_frame_buffer->framePitch, gpu_frame_buffer->w * 4, gpu_frame_buffer->h, cudaMemcpyDeviceToDevice));
9944
9945 launch_filter(gpu_filters.data(), gpu_filters.size(), gpuWorkingBuffer.ptr<unsigned char>(), d_ptrList, gpu_frame_buffer->arraySize, gpuWorkingBuffer.cols, gpuWorkingBuffer.rows, gpuWorkingBuffer.step, gpu_alpha, false, gpu_square_size, gpu_frame_index, gpu_frame_dir, &d_filterList, gpu_filtersChanged);
9946 gpu_filtersChanged = false;
9947 tex_uploader.update(gpuWorkingBuffer);
9948 // If the incoming GpuMat size ever differs from the
9949 // uploader's current size, update() calls init() which
9950 // deletes the old GL texture and allocates a new one with
9951 // a fresh textureID. Re-sync our cached camera_texture so
9952 // it never dangles at a deleted name (would render black).
9955 }
9956 } else {
9957 glActiveTexture(GL_TEXTURE0);
9959 }
9960#else
9961 {
9962 glActiveTexture(GL_TEXTURE0);
9964 }
9965#endif
9966 if (texture_cache && (library.isCache() || ([&]() {
9967 if (!shader_pass_enabled)
9968 return false;
9969 for (int idx : shader_pass_list) {
9970 if (idx >= 0 && library.isCache2D(static_cast<size_t>(idx)))
9971 return true;
9972 }
9973 return false;
9974 }()))) {
9975 static int counter = 0;
9976 if (frame_cache.size() == 0) {
9977 frame_cache.pushFromTexture(camera_texture, newFrame.cols, newFrame.rows);
9978 counter = 0;
9979 } else if (++counter > cache_delay) {
9980 // Only push frames into cache after the post-load warmup period
9981 if (cache_warmup_frames <= 0) {
9982 // GPU->GPU copy from the camera texture that was just
9983 // updated above. Avoids a second BGR->RGBA conversion
9984 // and host upload per frame, which became a major
9985 // bottleneck once CPU ONNX passes were added.
9986 frame_cache.pushFromTexture(camera_texture, newFrame.cols, newFrame.rows);
9987 }
9988 counter = 0;
9989 }
9990 if (frame_cache.isFull()) {
9991 if (texture_cache_array) {
9992 library.setHistoryHead(frame_cache.oldestLayer());
9993 library.setUniform("history", 0);
9994 glActiveTexture(GL_TEXTURE1);
9995 glBindTexture(GL_TEXTURE_2D_ARRAY, frame_cache.historyTexture());
9996 } else {
9997 const int n_bind = library.cacheSize();
9998 for (int i = 0; i < n_bind; ++i) {
9999 library.setUniform("samp" + std::to_string(i + 1), i);
10000 glActiveTexture(GL_TEXTURE1 + i);
10001 glBindTexture(GL_TEXTURE_2D, frame_cache.textureAt(i));
10002 }
10003 }
10004 }
10005 }
10006 }
10007 if (received_source_frame) {
10008 source_frame_ready = true;
10009 startMediaTimelineIfReady();
10010 }
10011
10012 // Count cache warmup in real source frames, not loading-screen draws.
10013 if (cache_warmup_frames > 0 && received_source_frame) {
10014 cache_warmup_frames--;
10015 }
10016 glBindFramebuffer(GL_FRAMEBUFFER, captureFBO);
10017 glViewport(0, 0, win->w, win->h);
10018 glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
10019
10020#ifdef AUDIO_ENABLED
10021 if (!isFrozen && audio_is_enabled && media_timeline_started) {
10022 float audio_warmup = updateAudioWarmupEnvelope();
10023 library.setAudioWarmupEnvelope(audio_warmup);
10024 if (file_audio_mode) {
10025 const bool process_file_audio = file_audio_has_output_clock() || received_source_frame;
10026 if (process_file_audio) {
10027 file_audio_process_frame(fps, audio_engine.analyzer());
10028 }
10029 if (audio_trunc_mode && !file_audio_is_active()) {
10030 mx::system_out << "acmx2: Audio file finished, stopping (--audio-trunc).\n";
10031 fflush(stdout);
10032 running = false;
10033 }
10034 }
10035 float spectrum_scale = spectrum_scale_by_sense ? (audio_engine.analyzer().sensitivity() * audio_warmup) : audio_warmup;
10036 spectrumTex.update(spectrum_scale);
10037 spectrumTex.bind();
10038 if (audio_buffer_count > 0) {
10039 spectrumHistory.update(spectrum_scale);
10040 spectrumHistory.bind();
10041 library.setSpectrumHistoryHead(spectrumHistory.newestLayer());
10042 }
10043 }
10044#endif
10045
10046 if (!isFrozen && !library.isBypassed()) {
10047 library.useProgram();
10048 library.update(win);
10049 library.setFPS(static_cast<float>(fps));
10050 }
10051
10052 {
10053 const Uint8 *keystate = SDL_GetKeyboardState(NULL);
10054 if (keystate[SDL_SCANCODE_PAGEUP]) {
10055 library.incTimeSpeed(0.1f);
10056 fflush(stdout);
10057 }
10058 if (keystate[SDL_SCANCODE_PAGEDOWN]) {
10059 library.decTimeSpeed(0.1f);
10060 fflush(stdout);
10061 }
10062
10063 if (keystate[SDL_SCANCODE_PERIOD]) {
10064 cameraRotationSpeed += 0.5f;
10065 if (cameraRotationSpeed > 50.0f)
10066 cameraRotationSpeed = 50.0f;
10067 mx::system_out << "acmx2: Camera rotation speed: " << cameraRotationSpeed << "\n";
10068 fflush(stdout);
10069 }
10070 if (keystate[SDL_SCANCODE_COMMA]) {
10071 cameraRotationSpeed -= 0.5f;
10072 if (cameraRotationSpeed < 0.5f)
10073 cameraRotationSpeed = 0.5f;
10074 mx::system_out << "acmx2: Camera rotation speed: " << cameraRotationSpeed << "\n";
10075 fflush(stdout);
10076 }
10077 }
10078
10079 if (is3d_enabled) {
10080 glEnable(GL_DEPTH_TEST);
10081 glDepthFunc(GL_LESS);
10082 glDepthMask(GL_TRUE);
10083 glDisable(GL_CULL_FACE);
10084
10085 static auto last3DTime = std::chrono::steady_clock::now();
10086 auto now3D = std::chrono::steady_clock::now();
10087 float dt = std::chrono::duration<float>(now3D - last3DTime).count();
10088 if (dt > 0.1f)
10089 dt = 0.1f;
10090 last3DTime = now3D;
10091
10092 static float rotation = 0.0f;
10093 rotation = fmod(rotation + 0.5f, 360.0f);
10094
10095 const Uint8 *keystate = SDL_GetKeyboardState(NULL);
10096 if (!oscillateScale) {
10097
10098 if (keystate[SDL_SCANCODE_1]) {
10099 movementSpeed += 0.1f * dt * 30.0f;
10100 mx::system_out << "acmx2: camera movement speed increased: " << movementSpeed << "\n";
10101 fflush(stdout);
10102 }
10103
10104 if (keystate[SDL_SCANCODE_2]) {
10105 movementSpeed -= 0.1f * dt * 30.0f;
10106 mx::system_out << "acmx2: camera movement speed decreased: " << movementSpeed << "\n";
10107 fflush(stdout);
10108 }
10109
10110 if (keystate[SDL_SCANCODE_EQUALS] || keystate[SDL_SCANCODE_KP_PLUS]) {
10111 cameraDistance += movementSpeed * dt;
10112 mx::system_out << "acmx2: cameraDistance increased: " << cameraDistance << "\n";
10113 fflush(stdout);
10114 }
10115 if (keystate[SDL_SCANCODE_MINUS] || keystate[SDL_SCANCODE_KP_MINUS]) {
10116 cameraDistance -= movementSpeed * dt;
10117 mx::system_out << "acmx2: cameraDistance decreased: " << cameraDistance << "\n";
10118 fflush(stdout);
10119 }
10120 if (keystate[SDL_SCANCODE_RIGHTBRACKET]) {
10121 modelRenderScale += 0.5f * dt;
10122 mx::system_out << "acmx2: Model scale increased to " << modelRenderScale << "\n";
10123 fflush(stdout);
10124 }
10125 if (keystate[SDL_SCANCODE_LEFTBRACKET]) {
10126 modelRenderScale -= 0.5f * dt;
10127 if (modelRenderScale < 0.05f)
10128 modelRenderScale = 0.05f;
10129 mx::system_out << "acmx2: Model scale decreased to " << modelRenderScale << "\n";
10130 fflush(stdout);
10131 }
10132 }
10133 static float t = 0.0f;
10134 float oscOffset = 0.0f;
10135 if (oscillateScale) {
10136 t += 0.016f;
10137 oscOffset = 0.3f * std::sin(t);
10138 }
10139
10140 if (!viewRotationActive) {
10141 if (keystate[SDL_SCANCODE_W]) {
10142 cameraPitch += cameraRotationSpeed * 0.3f * dt * 30.0f;
10143 }
10144 if (keystate[SDL_SCANCODE_S]) {
10145 cameraPitch -= cameraRotationSpeed * 0.33f * dt * 30.0f;
10146 }
10147 cameraPitch = fmod(cameraPitch, 360.0f);
10148 if (cameraPitch < 0.0f)
10149 cameraPitch += 360.0f;
10150 if (keystate[SDL_SCANCODE_A]) {
10151 cameraYaw -= cameraRotationSpeed * 0.3f * dt * 30.0f;
10152 cameraYaw = fmod(cameraYaw + 360.0f, 360.0f);
10153 }
10154 if (keystate[SDL_SCANCODE_D]) {
10155 cameraYaw += cameraRotationSpeed * 0.3f * dt * 30.0f;
10156 cameraYaw = fmod(cameraYaw, 360.0f);
10157 }
10158 }
10159 glm::vec3 lookDirection;
10160 if (viewRotationActive) {
10161 static float viewRotation = 0.0f;
10162 viewRotation = fmod(viewRotation + 0.3f, 360.0f);
10163 float lookX = 0.48f * sin(glm::radians(viewRotation));
10164 float lookY = 0.48f * sin(glm::radians(viewRotation * 0.7f));
10165 float lookZ = 0.48f * cos(glm::radians(viewRotation));
10166 lookDirection = glm::vec3(lookX, lookY, lookZ);
10167 } else {
10168 lookDirection.x = cos(glm::radians(cameraPitch)) * cos(glm::radians(cameraYaw));
10169 lookDirection.y = sin(glm::radians(cameraPitch));
10170 lookDirection.z = cos(glm::radians(cameraPitch)) * sin(glm::radians(cameraYaw));
10171 lookDirection = glm::normalize(lookDirection) * 0.48f;
10172 }
10173
10174 float finalOffset = oscillateScale ? oscOffset : cameraDistance;
10175 glm::vec3 cameraPosBase = glm::vec3(0.0f, 0.0f, 0.0f);
10176 glm::vec3 cameraPos = cameraPosBase - glm::normalize(lookDirection) * finalOffset;
10177 glm::vec3 cameraTarget = cameraPos + lookDirection;
10178 glm::vec3 cameraUp = glm::vec3(0.0f, 1.0f, 0.0f);
10179 if (!viewRotationActive) {
10180 const float pitch = glm::radians(cameraPitch);
10181 const float yaw = glm::radians(cameraYaw);
10182 cameraUp = glm::vec3(-sin(pitch) * cos(yaw), cos(pitch), -sin(pitch) * sin(yaw));
10183 }
10184 glm::mat4 viewMatrix = glm::lookAt(cameraPos, cameraTarget, cameraUp);
10185 glm::mat4 projectionMatrix = glm::perspective(glm::radians(120.0f), static_cast<float>(win->w) / static_cast<float>(win->h), 0.01f, 1000.0f);
10186
10187 glm::mat4 modelMatrix = glm::mat4(1.0f);
10188 modelMatrix = glm::scale(modelMatrix, glm::vec3(modelRenderScale));
10189 modelMatrix = glm::rotate(modelMatrix, glm::radians(modelRotX), glm::vec3(1.0f, 0.0f, 0.0f));
10190 modelMatrix = glm::rotate(modelMatrix, glm::radians(modelRotY), glm::vec3(0.0f, 1.0f, 0.0f));
10191 modelMatrix = glm::rotate(modelMatrix, glm::radians(modelRotZ), glm::vec3(0.0f, 0.0f, 1.0f));
10192 modelMatrix = glm::translate(modelMatrix, modelCenterOffset);
10193
10194 glm::mat4 mvMatrix = viewMatrix * modelMatrix;
10195 GLuint textureForMesh = input_is_hdr ? hdr_linear_video_texture : camera_texture;
10196 if (shader_pass_enabled && !shader_pass_list.empty() && !library.isBypassed()) {
10197 glDisable(GL_DEPTH_TEST);
10198 auto logPassWarning3D = [](const std::string &msg) {
10199 static Uint64 last_warn_tick = 0;
10200 Uint64 now_tick = SDL_GetTicks64();
10201 if (now_tick - last_warn_tick >= 1000) {
10202 mx::system_out << "acmx2: multipass(3D) " << msg << "\n";
10203 fflush(stdout);
10204 last_warn_tick = now_tick;
10205 }
10206 };
10207
10208 ensurePassTargets(win);
10209
10210 GLuint inputTex = input_is_hdr ? hdr_linear_video_texture : camera_texture;
10211 int pingpong = 0;
10212 bool pass_applied = false;
10213 for (size_t i = 0; i < shader_pass_list.size(); ++i) {
10214 int shader_idx = shader_pass_list[i];
10215 if (shader_idx >= 0 && shader_idx < static_cast<int>(library.size2d())) {
10216 gl::ShaderProgram *pass_shader = library.getShader2D(shader_idx);
10217 if (pass_shader) {
10218 bool applied = false;
10219 if (library.isCompute(static_cast<size_t>(shader_idx))) {
10220 glBindFramebuffer(GL_FRAMEBUFFER, captureFBO);
10221 applied = runComputePass(win, static_cast<size_t>(shader_idx), inputTex, passTexture[pingpong]);
10222 } else {
10223 glBindFramebuffer(GL_FRAMEBUFFER, passFBO[pingpong]);
10224 glViewport(0, 0, win->w, win->h);
10225 glClear(GL_COLOR_BUFFER_BIT);
10226 pass_shader->useProgram();
10227 library.updateShaderUniforms2D(win, shader_idx);
10228 pass_shader->setUniform("mv_matrix", glm::mat4(1.0f));
10229 pass_shader->setUniform("proj_matrix", glm::mat4(1.0f));
10230 glActiveTexture(GL_TEXTURE0);
10231 glBindTexture(GL_TEXTURE_2D, inputTex);
10232 glUniform1i(glGetUniformLocation(pass_shader->id(), "samp"), 0);
10233 bindPassHistoryTextures(static_cast<size_t>(shader_idx));
10234 sprite.setShader(pass_shader);
10235 sprite.setName("samp");
10236 sprite.draw(inputTex, 0, 0, win->w, win->h);
10237 applied = true;
10238 }
10239 if (applied) {
10240 pass_applied = true;
10241 inputTex = passTexture[pingpong];
10242 pingpong = 1 - pingpong;
10243 }
10244 } else {
10245 logPassWarning3D("skipping pass index " + std::to_string(shader_idx) + " (null shader)");
10246 }
10247 } else {
10248 logPassWarning3D("skipping invalid pass index " + std::to_string(shader_idx) + " (valid range 0-" + std::to_string(static_cast<int>(library.size2d()) - 1) + ")");
10249 }
10250 }
10251 if (!pass_applied) {
10252 logPassWarning3D("no pass applied; using base camera texture");
10253 }
10254
10255 textureForMesh = inputTex;
10256 glBindFramebuffer(GL_FRAMEBUFFER, captureFBO);
10257 glViewport(0, 0, win->w, win->h);
10258 glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
10259 glEnable(GL_DEPTH_TEST);
10260 glDepthFunc(GL_LESS);
10261 glDepthMask(GL_TRUE);
10262 }
10263
10264 const bool active_compute = !library.isBypassed() && library.isCompute(library.index());
10265 if (active_compute) {
10266 ensurePassTargets(win);
10267 const int output_index = textureForMesh == passTexture[0] ? 1 : 0;
10268 if (runComputePass(win, library.index(), textureForMesh, passTexture[output_index])) {
10269 textureForMesh = passTexture[output_index];
10270 }
10271 glBindFramebuffer(GL_FRAMEBUFFER, captureFBO);
10272 glViewport(0, 0, win->w, win->h);
10273 glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
10274 glEnable(GL_DEPTH_TEST);
10275 }
10276
10277 gl::ShaderProgram *activeShader;
10278 if (library.isBypassed() || active_compute) {
10279 activeShader = &fshader3d;
10280 } else {
10281 activeShader = library.shader();
10282 }
10283 activeShader->useProgram();
10284 activeShader->setUniform("mv_matrix", mvMatrix);
10285 activeShader->setUniform("proj_matrix", projectionMatrix);
10286 glActiveTexture(GL_TEXTURE0);
10287 glBindTexture(GL_TEXTURE_2D, textureForMesh);
10288 glUniform1i(glGetUniformLocation(activeShader->id(), "samp"), 0);
10289 if (!library.isBypassed()) {
10290 cube.setShaderProgram(activeShader);
10291 } else {
10292 cube.setShaderProgram(&fshader3d);
10293 }
10294 cube.resetToOriginal();
10295 if (waveActive) {
10296 static float amp_x = 0.0f;
10297 static float amp_y = 0.0f;
10298 static float amp_z = 0.0f;
10299 static int dir_x = 1;
10300 static int dir_y = 1;
10301 static int dir_z = 1;
10302 static const float wave_speed = 0.005f;
10303 static const float wave_max = 0.5f;
10304 static const float wave_min = 0.0f;
10305 static float phase = 0.0f;
10306
10307#ifdef AUDIO_ENABLED
10308 if (audio_is_enabled && library.timeAudio())
10309 phase += (library.getAmp() * library.getAmpUntouched());
10310 else
10311 phase += 0.05f;
10312#else
10313 phase += 0.05f;
10314#endif
10315 if (phase > 360.0f)
10316 phase -= 360.0f;
10317
10318 amp_x += wave_speed * dir_x;
10319 if (amp_x >= wave_max) {
10320 amp_x = wave_max;
10321 dir_x = -1;
10322 } else if (amp_x <= wave_min) {
10323 amp_x = wave_min;
10324 dir_x = 1;
10325 }
10326
10327 amp_y += wave_speed * dir_y;
10328 if (amp_y >= wave_max) {
10329 amp_y = wave_max;
10330 dir_y = -1;
10331 } else if (amp_y <= wave_min) {
10332 amp_y = wave_min;
10333 dir_y = 1;
10334 }
10335
10336 amp_z += wave_speed * dir_z;
10337 if (amp_z >= wave_max) {
10338 amp_z = wave_max;
10339 dir_z = -1;
10340 } else if (amp_z <= wave_min) {
10341 amp_z = wave_min;
10342 dir_z = 1;
10343 }
10344 cube.wave(mx::DeformAxis::X, amp_x, 2.0f, phase);
10345 cube.wave(mx::DeformAxis::Y, amp_y, 2.0f, phase + 120.0f);
10346 cube.wave(mx::DeformAxis::Z, amp_z, 2.0f, phase + 240.0f);
10347 }
10348
10349 cube.updateBuffers();
10350 cube.recalculateNormals();
10351
10352 for (auto &m : cube.meshes) {
10353 m.draw();
10354 }
10355 glFrontFace(GL_CCW);
10356 } else {
10357 glDisable(GL_DEPTH_TEST);
10358 GLuint textureForSprite = input_is_hdr ? hdr_linear_video_texture : camera_texture;
10359 if (shader_pass_enabled && !shader_pass_list.empty() && !library.isBypassed()) {
10360 auto logPassWarning2D = [](const std::string &msg) {
10361 static Uint64 last_warn_tick = 0;
10362 Uint64 now_tick = SDL_GetTicks64();
10363 if (now_tick - last_warn_tick >= 1000) {
10364 mx::system_out << "acmx2: multipass(2D) " << msg << "\n";
10365 fflush(stdout);
10366 last_warn_tick = now_tick;
10367 }
10368 };
10369 ensurePassTargets(win);
10370 glBindFramebuffer(GL_FRAMEBUFFER, captureFBO);
10371
10372 GLuint inputTex = input_is_hdr ? hdr_linear_video_texture : camera_texture;
10373 int pingpong = 0;
10374 bool pass_applied = false;
10375
10376 for (size_t i = 0; i < shader_pass_list.size(); ++i) {
10377 int shader_idx = shader_pass_list[i];
10378 if (shader_idx >= 0 && shader_idx < static_cast<int>(library.size())) {
10379 gl::ShaderProgram *pass_shader = library.getShader(shader_idx);
10380 if (pass_shader) {
10381 bool applied = false;
10382 if (library.isCompute(static_cast<size_t>(shader_idx))) {
10383 glBindFramebuffer(GL_FRAMEBUFFER, captureFBO);
10384 applied = runComputePass(win, static_cast<size_t>(shader_idx), inputTex, passTexture[pingpong]);
10385 } else {
10386 glBindFramebuffer(GL_FRAMEBUFFER, passFBO[pingpong]);
10387 glViewport(0, 0, win->w, win->h);
10388 glClear(GL_COLOR_BUFFER_BIT);
10389 pass_shader->useProgram();
10390 library.updateShaderUniforms(win, shader_idx);
10391 pass_shader->setUniform("mv_matrix", glm::mat4(1.0f));
10392 pass_shader->setUniform("proj_matrix", glm::mat4(1.0f));
10393 glActiveTexture(GL_TEXTURE0);
10394 glBindTexture(GL_TEXTURE_2D, inputTex);
10395 glUniform1i(glGetUniformLocation(pass_shader->id(), "samp"), 0);
10396 bindPassHistoryTextures(static_cast<size_t>(shader_idx));
10397 sprite.setShader(pass_shader);
10398 sprite.setName("samp");
10399 sprite.draw(inputTex, 0, 0, win->w, win->h);
10400 applied = true;
10401 }
10402 if (applied) {
10403 pass_applied = true;
10404 inputTex = passTexture[pingpong];
10405 pingpong = 1 - pingpong;
10406 }
10407 } else {
10408 logPassWarning2D("skipping pass index " + std::to_string(shader_idx) + " (null shader)");
10409 }
10410 } else {
10411 logPassWarning2D("skipping invalid pass index " + std::to_string(shader_idx) + " (valid range 0-" + std::to_string(static_cast<int>(library.size()) - 1) + ")");
10412 }
10413 }
10414 if (!pass_applied) {
10415 logPassWarning2D("no pass applied; using base camera texture");
10416 } else {
10417 textureForSprite = inputTex;
10418 }
10419 glBindFramebuffer(GL_FRAMEBUFFER, captureFBO);
10420 glViewport(0, 0, win->w, win->h);
10421 glClear(GL_COLOR_BUFFER_BIT);
10422 }
10423
10424 const bool active_compute = !library.isBypassed() && library.isCompute(library.index());
10425 if (active_compute) {
10426 ensurePassTargets(win);
10427 const int output_index = textureForSprite == passTexture[0] ? 1 : 0;
10428 if (runComputePass(win, library.index(), textureForSprite, passTexture[output_index])) {
10429 textureForSprite = passTexture[output_index];
10430 }
10431 glBindFramebuffer(GL_FRAMEBUFFER, captureFBO);
10432 glViewport(0, 0, win->w, win->h);
10433 glClear(GL_COLOR_BUFFER_BIT);
10434 }
10435
10436 gl::ShaderProgram *activeShader;
10437 if (library.isBypassed() || active_compute) {
10438 activeShader = &fshader;
10439 } else {
10440 activeShader = library.shader();
10441 }
10442 activeShader->useProgram();
10443 activeShader->setUniform("mv_matrix", glm::mat4(1.0f));
10444 activeShader->setUniform("proj_matrix", glm::mat4(1.0f));
10445 sprite.setShader(activeShader);
10446 sprite.setName("samp");
10447 sprite.draw(textureForSprite, 0, 0, win->w, win->h);
10448 }
10449
10450 if (crossfadeActive) {
10451 applyCrossfade(win, fboTexture);
10452 if (crossfadeActive) {
10453 glBindFramebuffer(GL_FRAMEBUFFER, captureFBO);
10454 glViewport(0, 0, win->w, win->h);
10455 glClear(GL_COLOR_BUFFER_BIT);
10456 fshader.useProgram();
10457 fshader.setUniform("mv_matrix", glm::mat4(1.0f));
10458 fshader.setUniform("proj_matrix", glm::mat4(1.0f));
10459 sprite.setShader(&fshader);
10460 sprite.setName("samp");
10461 sprite.draw(crossfadeTexture, 0, 0, win->w, win->h);
10462 }
10463 }
10464
10465#ifdef ACMX2_WITH_DNN
10466 // --human --background : composite the original person on top of the
10467 // shaded captureFBO using the hardened alpha mask. The overlay
10468 // texture stores the original frame in RGB and the cleaned mask in
10469 // its alpha channel, so a single straight-alpha blend reproduces the
10470 // person over whatever the shader chain just rendered to captureFBO.
10471 if (human_seg_model && human_background_only && human_overlay_ready && human_overlay_tex != 0 && !input_is_hdr) {
10472 glBindFramebuffer(GL_FRAMEBUFFER, captureFBO);
10473 glViewport(0, 0, win->w, win->h);
10474 glDisable(GL_DEPTH_TEST);
10475 glEnable(GL_BLEND);
10476 glBlendFuncSeparate(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA, GL_ONE, GL_ONE_MINUS_SRC_ALPHA);
10477 fshader.useProgram();
10478 fshader.setUniform("mv_matrix", glm::mat4(1.0f));
10479 fshader.setUniform("proj_matrix", glm::mat4(1.0f));
10480 sprite.setShader(&fshader);
10481 sprite.setName("samp");
10482 sprite.draw(human_overlay_tex, 0, 0, win->w, win->h);
10483 glDisable(GL_BLEND);
10484 }
10485#endif
10486
10487 glBindFramebuffer(GL_FRAMEBUFFER, 0);
10488 glViewport(0, 0, win->w, win->h);
10489 glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
10490 glDisable(GL_DEPTH_TEST);
10491
10492 // In HDR windowed mode, flip by default to match headless orientation.
10493 // When --flip is set, invert this (don't flip) to show upside down.
10494 gl::ShaderProgram *display_shader = &fshader;
10495 int flip_y_uniform = 0;
10496 if (input_is_hdr && display_flip_shader.id() != 0) {
10497 // Apply flip unless --flip flag is set (which inverts the behavior)
10498 if (!flip_output) {
10499 display_shader = &display_flip_shader;
10500 flip_y_uniform = 1;
10501 }
10502 }
10503
10504 display_shader->useProgram();
10505 display_shader->setUniform("mv_matrix", glm::mat4(1.0f));
10506 display_shader->setUniform("proj_matrix", glm::mat4(1.0f));
10507 if (flip_y_uniform == 1) {
10508 glUniform1i(glGetUniformLocation(display_shader->id(), "flip_y"), flip_y_uniform);
10509 }
10510 sprite.setShader(display_shader);
10511 sprite.draw(fboTexture, 0, 0, win->w, win->h);
10512
10513 // Drawing the watermark / display-filter overlay into captureFBO would
10514 // pollute fboTexture and bleed into the next frame's crossfade
10515 // snapshot (beginCrossfade samples fboTexture). Save the un-watermarked
10516 // image now and restore it after the writer/snapshot readback so
10517 // recordings include the overlay but the on-screen / crossfade source
10518 // does not.
10519 const bool overlay_will_draw = writer.is_open() && waterFont.handle().has_value() && (display_filter || enableWatermark);
10520 if (overlay_will_draw && preOverlayFBO != 0) {
10521 glBindFramebuffer(GL_READ_FRAMEBUFFER, captureFBO);
10522 glBindFramebuffer(GL_DRAW_FRAMEBUFFER, preOverlayFBO);
10523 glBlitFramebuffer(0, 0, win->w, win->h, 0, 0, win->w, win->h, GL_COLOR_BUFFER_BIT, GL_NEAREST);
10524 glBindFramebuffer(GL_READ_FRAMEBUFFER, 0);
10525 glBindFramebuffer(GL_DRAW_FRAMEBUFFER, 0);
10526 }
10527
10528 int watermarkY = 10;
10529 if (display_filter && (writer.is_open() || png_video_mode) && waterFont.handle().has_value()) {
10530 glBindFramebuffer(GL_FRAMEBUFFER, captureFBO);
10531 glViewport(0, 0, win->w, win->h);
10532 glEnable(GL_BLEND);
10533 glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
10534 win->text.setColor({255, 0, 255, 255});
10535 int dfY = 10;
10536 const int lineH = waterFontSize + 4;
10537 std::string shaderName = library.getShaderNameByIndex(library.index());
10538 if (!shaderName.empty()) {
10539 win->text.printText_Solid(waterFont, 10, dfY, "Shader: " + shaderName);
10540 dfY += lineH;
10541 }
10542 if (shader_pass_enabled && !shader_pass_list.empty()) {
10543 std::string mpLine = "Multipass: ";
10544 for (size_t i = 0; i < shader_pass_list.size(); ++i) {
10545 if (i > 0)
10546 mpLine += ", ";
10547 std::string n = library.getShaderNameByIndex(shader_pass_list[i]);
10548 mpLine += n.empty() ? std::to_string(shader_pass_list[i]) : n;
10549 }
10550 win->text.printText_Solid(waterFont, 10, dfY, mpLine);
10551 dfY += lineH;
10552 }
10553 if (gpu_filter_enabled && !gpu_filters.empty()) {
10554 std::string gpuLine = "GPU: ";
10555 for (size_t i = 0; i < gpu_filters.size(); ++i) {
10556 if (i > 0)
10557 gpuLine += ", ";
10558 gpuLine += gpu_filters[i].name;
10559 }
10560 win->text.printText_Solid(waterFont, 10, dfY, gpuLine);
10561 dfY += lineH;
10562 }
10563 glDisable(GL_BLEND);
10564 watermarkY = dfY;
10565 }
10566 if (enableWatermark && (writer.is_open() || png_video_mode) && waterFont.handle().has_value()) {
10567 glBindFramebuffer(GL_FRAMEBUFFER, captureFBO);
10568 glViewport(0, 0, win->w, win->h);
10569 glEnable(GL_BLEND);
10570 glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
10571 win->text.setColor({static_cast<Uint8>(watermark_r), static_cast<Uint8>(watermark_g), static_cast<Uint8>(watermark_b), 255});
10572 win->text.printText_Blended(waterFont, 10, watermarkY, watermark_text);
10573 glDisable(GL_BLEND);
10574 }
10575
10576 const bool normal_output_frame_due = media_timeline_started && (!file_audio_clock_controls_video || received_source_frame || stop_after_recording_drain);
10577 bool needWriter = (((writer.is_open() || png_video_mode || generate_mode) && normal_output_frame_due) || snapshot_state > 0 || hdr_snapshot_state > 0 || raw_snapshot_state > 0 || tiff_snapshot_state > 0) && !isFrozen;
10578
10579 bool has_snapshot_request = (snapshot_state > 0);
10580 bool has_hdr_snapshot_request = (hdr_snapshot_state > 0);
10581 bool has_raw_snapshot_request = (raw_snapshot_state > 0);
10582 bool has_tiff_snapshot_request = (tiff_snapshot_state > 0);
10583 if (needWriter && input_is_hdr && (writer.is_open() || png_video_mode || generate_mode || has_snapshot_request || has_hdr_snapshot_request || has_raw_snapshot_request || has_tiff_snapshot_request)) {
10584 // HDR writer readback path. Bypasses the 8-bit PBO ring entirely
10585 // and reads 16-bit PQ-encoded BT.2020 RGBA from
10586 // @c hdr_encoded_texture via a synchronous glGetTexImage. The
10587 // resulting buffer (8 bytes/pixel) is pushed through the same
10588 // frame queue with @c FrameData::isHdr set so the writer thread
10589 // dispatches @c writer.write_hdr_rgba16() instead of the SDR
10590 // @c writer.write().
10591 //
10592 // Orientation: The HDR input frame is NOT pre-flipped (unlike SDR),
10593 // and the hdr_encode shader pass produces top-down output. If
10594 // flip_output is set, apply vertical flipping here to correct
10595 // the output orientation.
10596 std::vector<unsigned char> pixels;
10597 hdrReadback(fboTexture, win->w, win->h, pixels);
10598
10599 // Apply vertical flip if requested (e.g., for HDR correction).
10600 if (flip_output) {
10601 const int row_bytes = win->w * 8; // 4 channels * 2 bytes
10602 std::vector<unsigned char> flipped_pixels(pixels.size());
10603 for (int y = 0; y < win->h; ++y) {
10604 std::copy(pixels.begin() + y * row_bytes, pixels.begin() + (y + 1) * row_bytes, flipped_pixels.begin() + (win->h - 1 - y) * row_bytes);
10605 }
10606 pixels = std::move(flipped_pixels);
10607 }
10608
10609 if (writer.is_open() || png_video_mode || generate_mode || has_hdr_snapshot_request || has_raw_snapshot_request) {
10610 FrameData fd;
10611 fd.pixels = pixels;
10612 fd.width = win->w;
10613 fd.height = win->h;
10614 fd.isHdr = true;
10615 fd.hdrTrc = input_hdr_trc;
10616 fd.isSnapshot = has_hdr_snapshot_request;
10617 fd.isWebPSnapshot = has_hdr_snapshot_request;
10618 fd.isRawSnapshot = has_raw_snapshot_request;
10619 fd.usesTimelineClock = source_frame_uses_timeline_clock && !has_hdr_snapshot_request && !has_raw_snapshot_request;
10620 fd.timelineFrame = source_timeline_frame;
10621
10622 {
10623 std::unique_lock<std::mutex> lock(queueMutex);
10624 queueCondVar.wait(lock, [this] { return frameQueue.size() < 30 || !writerRunning; });
10625 frameQueue.push(std::move(fd));
10626 }
10627 queueCondVar.notify_one();
10628 }
10629
10630 if (has_tiff_snapshot_request) {
10631 FrameData tiff_fd;
10632 tiff_fd.pixels = pixels;
10633 tiff_fd.width = win->w;
10634 tiff_fd.height = win->h;
10635 tiff_fd.isHdr = true;
10636 tiff_fd.hdrTrc = input_hdr_trc;
10637 tiff_fd.isSnapshot = false;
10638 tiff_fd.isWebPSnapshot = false;
10639 tiff_fd.isRawSnapshot = false;
10640 tiff_fd.isTiffSnapshot = true;
10641
10642 {
10643 std::unique_lock<std::mutex> lock(queueMutex);
10644 queueCondVar.wait(lock, [this] { return frameQueue.size() < 30 || !writerRunning; });
10645 frameQueue.push(std::move(tiff_fd));
10646 }
10647 queueCondVar.notify_one();
10648 }
10649
10650 if (has_snapshot_request) {
10651 // In HDR mode, normal PNG snapshots intentionally use the
10652 // non-HDR 8-bit readback path so viewers can open them as
10653 // standard SDR PNGs.
10654 std::vector<unsigned char> sdr_pixels(static_cast<size_t>(win->w) * static_cast<size_t>(win->h) * 4);
10655 glBindTexture(GL_TEXTURE_2D, fboTexture);
10656 glGetTexImage(GL_TEXTURE_2D, 0, GL_RGBA, GL_UNSIGNED_BYTE, sdr_pixels.data());
10657 glBindTexture(GL_TEXTURE_2D, 0);
10658
10659 std::vector<unsigned char> flipped_pixels(static_cast<size_t>(win->w) * static_cast<size_t>(win->h) * 4);
10660 for (int y = 0; y < win->h; ++y) {
10661 const int src_row_start = y * win->w * 4;
10662 const int dest_row_start = (win->h - 1 - y) * win->w * 4;
10663 std::copy(sdr_pixels.begin() + src_row_start, sdr_pixels.begin() + src_row_start + (win->w * 4), flipped_pixels.begin() + dest_row_start);
10664 }
10665
10666 FrameData sdr_fd;
10667 sdr_fd.pixels = std::move(flipped_pixels);
10668 sdr_fd.width = win->w;
10669 sdr_fd.height = win->h;
10670 sdr_fd.isHdr = false;
10671 sdr_fd.isSnapshot = true;
10672 sdr_fd.isWebPSnapshot = false;
10673 sdr_fd.isRawSnapshot = false;
10674
10675 {
10676 std::unique_lock<std::mutex> lock(queueMutex);
10677 queueCondVar.wait(lock, [this] { return frameQueue.size() < 30 || !writerRunning; });
10678 frameQueue.push(std::move(sdr_fd));
10679 }
10680 queueCondVar.notify_one();
10681 }
10682
10683 if (has_snapshot_request) {
10684 snapshot_state = 0;
10685 }
10686
10687 if (has_hdr_snapshot_request) {
10688 hdr_snapshot_state = 0;
10689 }
10690
10691 if (has_raw_snapshot_request) {
10692 raw_snapshot_state = 0;
10693 }
10694
10695 if (has_tiff_snapshot_request) {
10696 tiff_snapshot_state = 0;
10697 }
10698 } else if (needWriter) {
10699
10700 if (snapshot_state == 1 || hdr_snapshot_state == 1 || raw_snapshot_state == 1 || tiff_snapshot_state == 1) {
10701 glBindBuffer(GL_PIXEL_PACK_BUFFER, pboIds[pboIndex]);
10702 glBindTexture(GL_TEXTURE_2D, fboTexture);
10703 glGetTexImage(GL_TEXTURE_2D, 0, GL_RGBA, GL_UNSIGNED_BYTE, 0);
10704 glBindBuffer(GL_PIXEL_PACK_BUFFER, 0);
10705 glBindTexture(GL_TEXTURE_2D, 0);
10706
10707 pboIndex = (pboIndex + 1) % 2;
10708 pboNextIndex = (pboNextIndex + 1) % 2;
10709 if (snapshot_state == 1)
10710 snapshot_state = 2;
10711 if (hdr_snapshot_state == 1)
10712 hdr_snapshot_state = 2;
10713 if (raw_snapshot_state == 1)
10714 raw_snapshot_state = 2;
10715 if (tiff_snapshot_state == 1)
10716 tiff_snapshot_state = 2;
10717 } else {
10718 bool is_snapshot_frame = (snapshot_state == 2);
10719 bool is_webp_snapshot_frame = (hdr_snapshot_state == 2);
10720 bool is_raw_snapshot_frame = (raw_snapshot_state == 2);
10721 bool is_tiff_snapshot_frame = (tiff_snapshot_state == 2);
10722 const bool has_normal_output = normal_output_frame_due && (writer.is_open() || png_video_mode || generate_mode);
10723 const bool previous_recording_frame_ready = recording_pbo_primed;
10724
10725 glBindBuffer(GL_PIXEL_PACK_BUFFER, pboIds[pboIndex]);
10726 glBindTexture(GL_TEXTURE_2D, fboTexture);
10727 glGetTexImage(GL_TEXTURE_2D, 0, GL_RGBA, GL_UNSIGNED_BYTE, 0);
10728
10729 if (has_normal_output) {
10730 recording_pbo_uses_timeline_clock[pboIndex] = source_frame_uses_timeline_clock;
10731 recording_pbo_timeline_frame[pboIndex] = source_timeline_frame;
10732 recording_pbo_primed = true;
10733 }
10734
10735 if ((has_normal_output && previous_recording_frame_ready) || is_snapshot_frame || is_webp_snapshot_frame || is_raw_snapshot_frame || is_tiff_snapshot_frame) {
10736 bool used_zero_copy = false;
10737
10738#ifdef ACMX2_WITH_CUDA
10739 if (writer.is_open() && !generate_mode && !is_snapshot_frame && !is_webp_snapshot_frame && !is_raw_snapshot_frame && !is_tiff_snapshot_frame && recordCudaPboResources[pboNextIndex]) {
10740 cudaGraphicsResource *resource = recordCudaPboResources[pboNextIndex];
10741 void *devPtr = nullptr;
10742 size_t mappedBytes = 0;
10743
10744 CHECK_CUDA(cudaGraphicsMapResources(1, &resource, 0));
10745 CHECK_CUDA(cudaGraphicsResourceGetMappedPointer(&devPtr, &mappedBytes, resource));
10746
10747 const size_t requiredBytes = static_cast<size_t>(win->w) * static_cast<size_t>(win->h) * 4;
10748 if (devPtr && mappedBytes >= requiredBytes) {
10749 if (recording_pbo_uses_timeline_clock[pboNextIndex]) {
10750 used_zero_copy = writer.write_cuda_rgba_at_pts(devPtr, static_cast<int>(win->w) * 4, static_cast<int64_t>(recording_pbo_timeline_frame[pboNextIndex]), true);
10751 } else {
10752 used_zero_copy = writer.write_cuda_rgba(devPtr, static_cast<int>(win->w) * 4, true);
10753 }
10754 }
10755
10756 CHECK_CUDA(cudaGraphicsUnmapResources(1, &resource, 0));
10757 }
10758#endif
10759
10760 if (!used_zero_copy) {
10761 glBindBuffer(GL_PIXEL_PACK_BUFFER, pboIds[pboNextIndex]);
10762 GLubyte *src = static_cast<GLubyte *>(glMapBuffer(GL_PIXEL_PACK_BUFFER, GL_READ_ONLY));
10763
10764 if (src) {
10765 std::vector<unsigned char> pixels(win->w * win->h * 4);
10766 std::memcpy(pixels.data(), src, pixels.size());
10767 glUnmapBuffer(GL_PIXEL_PACK_BUFFER);
10768
10769 std::vector<unsigned char> flipped_pixels(win->w * win->h * 4);
10770 for (int y = 0; y < win->h; ++y) {
10771 int src_row_start = y * win->w * 4;
10772 int dest_row_start = (win->h - 1 - y) * win->w * 4;
10773 std::copy(pixels.begin() + src_row_start, pixels.begin() + src_row_start + (win->w * 4), flipped_pixels.begin() + dest_row_start);
10774 }
10775
10776 FrameData fd;
10777 fd.pixels = std::move(flipped_pixels);
10778 fd.width = win->w;
10779 fd.height = win->h;
10780 fd.isSnapshot = (is_snapshot_frame || is_webp_snapshot_frame);
10781 fd.isWebPSnapshot = is_webp_snapshot_frame;
10782 fd.isRawSnapshot = is_raw_snapshot_frame;
10783 fd.isTiffSnapshot = is_tiff_snapshot_frame;
10784 fd.usesTimelineClock = recording_pbo_uses_timeline_clock[pboNextIndex] && !fd.isSnapshot && !fd.isRawSnapshot && !fd.isTiffSnapshot;
10785 fd.timelineFrame = recording_pbo_timeline_frame[pboNextIndex];
10786
10787 if (is_snapshot_frame) {
10788 snapshot_state = 0;
10789 }
10790 if (is_webp_snapshot_frame) {
10791 hdr_snapshot_state = 0;
10792 }
10793 if (is_raw_snapshot_frame) {
10794 raw_snapshot_state = 0;
10795 }
10796 if (is_tiff_snapshot_frame) {
10797 tiff_snapshot_state = 0;
10798 }
10799
10800 {
10801 std::unique_lock<std::mutex> lock(queueMutex);
10802 bool is_camera_mode = filename.empty() && graphic.empty();
10803 if (is_camera_mode && !is_snapshot_frame && !is_webp_snapshot_frame && !is_raw_snapshot_frame && !is_tiff_snapshot_frame) {
10804 if (frameQueue.size() > 30) {
10805 frames_dropped++;
10806 frameQueue.pop();
10807 }
10808 } else {
10809 queueCondVar.wait(lock, [this] { return frameQueue.size() < 30 || !writerRunning; });
10810 }
10811 frameQueue.push(std::move(fd));
10812 }
10813 queueCondVar.notify_one();
10814 }
10815 }
10816
10817 if (used_zero_copy) {
10818 snapshot_state = 0;
10819 hdr_snapshot_state = 0;
10820 raw_snapshot_state = 0;
10821 tiff_snapshot_state = 0;
10822 }
10823 }
10824
10825 glBindBuffer(GL_PIXEL_PACK_BUFFER, 0);
10826 glBindTexture(GL_TEXTURE_2D, 0);
10827 pboIndex = (pboIndex + 1) % 2;
10828 pboNextIndex = (pboNextIndex + 1) % 2;
10829 }
10830 }
10831
10832 if (stop_after_recording_drain) {
10833 // The EOF draw consumed the last valid PBO. The replacement
10834 // readback contains the already-rendered final frame, not a new
10835 // source frame, so it must not be flushed again at shutdown.
10836 recording_pbo_primed = false;
10837 if (silent_mode) {
10838 emitSilentVideoProgress(true);
10839 std::cout << "\n";
10840 }
10841 running = false;
10842 finished = true;
10843 }
10844
10845 // Restore fboTexture so the next frame (and any crossfade snapshot)
10846 // sees the un-watermarked frame; the writer has already captured the
10847 // overlaid pixels above.
10848 if (overlay_will_draw && preOverlayFBO != 0) {
10849 glBindFramebuffer(GL_READ_FRAMEBUFFER, preOverlayFBO);
10850 glBindFramebuffer(GL_DRAW_FRAMEBUFFER, captureFBO);
10851 glBlitFramebuffer(0, 0, win->w, win->h, 0, 0, win->w, win->h, GL_COLOR_BUFFER_BIT, GL_NEAREST);
10852 glBindFramebuffer(GL_READ_FRAMEBUFFER, 0);
10853 glBindFramebuffer(GL_DRAW_FRAMEBUFFER, 0);
10854 }
10855
10856 glBindFramebuffer(GL_FRAMEBUFFER, 0);
10857 glViewport(0, 0, win->w, win->h);
10858
10859 if (!counter_disabled && overlayFont.handle().has_value()) {
10860 fpsFrameCount++;
10861 auto currentTime = std::chrono::steady_clock::now();
10862 auto fpsDelta = std::chrono::duration_cast<std::chrono::milliseconds>(currentTime - fpsLastTime).count();
10863 if (fpsDelta >= 500) {
10864 displayFPS = (fpsFrameCount * 1000.0) / fpsDelta;
10865 fpsFrameCount = 0;
10866 fpsLastTime = currentTime;
10867 }
10868
10869 std::string timerStr = getTimeString();
10870 std::ostringstream fpsStr;
10871 fpsStr << std::fixed << std::setprecision(1) << displayFPS << " FPS";
10872 glEnable(GL_BLEND);
10873 glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
10874 win->text.setColor({0, 0, 255, 255});
10875 win->text.printText_Blended(overlayFont, 10, 10, cached_shader_name);
10876 int overlayY = 40;
10877#ifdef AUDIO_ENABLED
10878 if (file_audio_mode && file_audio_has_output_clock()) {
10879 const std::string trackPath = file_audio_current_source_path();
10880 const std::string trackName = std::filesystem::path(trackPath).filename().string();
10881 if (!trackName.empty()) {
10882 win->text.setColor({255, 0, 255, 255});
10883 win->text.printText_Blended(overlayFont, 10, overlayY, "Track: " + trackName);
10884 overlayY += 30;
10885 }
10886 }
10887#endif
10888 if (gpu_filter_enabled && !gpu_filters.empty()) {
10889 win->text.setColor({255, 0, 255, 255});
10890 std::string gpuLine = "GPU: ";
10891 for (size_t i = 0; i < gpu_filters.size(); ++i) {
10892 if (i > 0)
10893 gpuLine += ", ";
10894 gpuLine += gpu_filters[i].name;
10895 }
10896 win->text.printText_Blended(overlayFont, 10, overlayY, gpuLine);
10897 overlayY += 30;
10898 }
10899 if (autopilot_enabled) {
10900 const int activeInterval = autopilot_random_interval ? autopilot_interval_frames : autopilot_frames;
10901 const int remainingFrames = std::max(0, activeInterval - autopilot_counter);
10902 const int playlistCount = activePlaylistSize();
10903 std::ostringstream autopilotLine;
10904 if (autopilot_random_interval) {
10905 autopilotLine << "Autopilot " << (autopilot_sequential ? "seq" : "rnd") << " [4-" << std::max(4, autopilot_random_timeout) << "] cur=" << activeInterval << " next=" << remainingFrames << "f";
10906 } else {
10907 autopilotLine << "Autopilot " << (autopilot_sequential ? "seq" : "rnd") << " every " << activeInterval << "f next=" << remainingFrames << "f";
10908 }
10909 if (playlistCount > 0) {
10910 autopilotLine << " idx=" << (playlist_index + 1) << "/" << playlistCount;
10911 }
10912 win->text.setColor({0, 255, 255, 255});
10913 win->text.printText_Blended(overlayFont, 10, overlayY, autopilotLine.str());
10914 overlayY += 30;
10915 }
10916 if (!crossfadeShaderNames.empty()) {
10917 int n = static_cast<int>(crossfadeShaderNames.size());
10918 std::string xfadeLine = "XFade [" + std::to_string(crossfadeShaderIndex + 1) + "/" + std::to_string(n) + "]: " + crossfadeShaderNames[crossfadeShaderIndex];
10919 win->text.setColor({255, 200, 0, 255});
10920 win->text.printText_Blended(overlayFont, 10, overlayY, xfadeLine);
10921 overlayY += 30;
10922 }
10923 win->text.setColor({255, 255, 255, 255});
10924 win->text.printText_Blended(overlayFont, 10, overlayY, timerStr);
10925 win->text.printText_Blended(overlayFont, 10, overlayY + 30, fpsStr.str());
10926 win->text.setColor({128, 128, 128, 255});
10927 win->text.printText_Blended(overlayFont, 10, overlayY + 55, "F9: Toggle overlay");
10928#ifdef MIDI_ENABLED
10929 drawMidiOverlay(win, overlayFont, overlayY + 80);
10930#endif
10931 glDisable(GL_BLEND);
10932 }
10933
10934 static auto lastUpdate = std::chrono::steady_clock::now();
10935 auto now = std::chrono::steady_clock::now();
10936
10937 if (!graphic.empty()) {
10938 if (silent_mode) {
10939 emitSilentGraphicsProgress(false);
10940 } else if (std::chrono::duration_cast<std::chrono::milliseconds>(now - lastUpdate).count() >= 250) {
10941 std::string timeStr = getTimeString();
10942 int64_t currentFrames = getFrameCount();
10943
10944 std::ostringstream stream;
10945 stream << "ACMX2 - Graphics Mode - " << timeStr << " [" << currentFrames << " frames]";
10946 appendRecordingTitleSuffix(stream);
10947 win->setWindowTitle(stream.str());
10948 lastUpdate = now;
10949 }
10950
10951 } else if (!filename.empty()) {
10952 if (use_ffmpeg_reader) {
10953 frame_counter = static_cast<unsigned int>(std::max<int64_t>(0, ffmpeg_reader.getCurrentFrame()));
10954 } else if (cap.isOpened()) {
10955 frame_counter = static_cast<unsigned int>(cap.get(cv::CAP_PROP_POS_FRAMES));
10956 }
10957
10958 if (silent_mode && (totalFrames > 0.0 || (duration_limit > 0.0 && fps > 0.0))) {
10959 emitSilentVideoProgress(false);
10960 } else if (silent_mode) {
10961 // Fallback: input reports unknown frame count (e.g. some MKV
10962 // / streaming containers). No percentage possible, so emit
10963 // an elapsed-style progress line every 500 ms with what we
10964 // do know: current frame number, frames written, elapsed
10965 // time based on the input FPS.
10966 static auto lastProgressEmitUnk = std::chrono::steady_clock::now();
10967 if (std::chrono::duration_cast<std::chrono::milliseconds>(now - lastProgressEmitUnk).count() >= 500) {
10968 lastProgressEmitUnk = now;
10969 int64_t frames_written = png_video_mode ? static_cast<int64_t>(png_video_frame_counter.load()) : (writer.is_open() ? writer.get_frame_count() : 0);
10970 double elapsed_secs = (fps > 0.0) ? static_cast<double>(frame_counter) / fps : 0.0;
10971 uint64_t hours = static_cast<uint64_t>(elapsed_secs / 3600);
10972 uint64_t minutes = static_cast<uint64_t>(elapsed_secs / 60) % 60;
10973 uint64_t seconds = static_cast<uint64_t>(elapsed_secs) % 60;
10974
10975 std::cout << "acmx2: [ ?%] "
10976 << "Frame " << frame_counter << "/?"
10977 << " | Written: " << frames_written << " | Time: " << std::setfill('0') << std::setw(2) << hours << ":" << std::setfill('0') << std::setw(2) << minutes << ":" << std::setfill('0') << std::setw(2) << seconds << std::setfill(' ');
10978 appendSilentProgressFileSize(std::cout);
10979 std::cout << "\n" << std::flush;
10980 }
10981 }
10982
10983 if (!silent_mode && std::chrono::duration_cast<std::chrono::seconds>(now - lastUpdate).count() >= 1) {
10984 if (totalFrames <= 0.0 && !use_ffmpeg_reader && cap.isOpened()) {
10985 totalFrames = cap.get(cv::CAP_PROP_FRAME_COUNT);
10986 }
10987 std::string timeStr = getTimeString();
10988 std::ostringstream stream;
10989 stream << "ACMX2 - [" << frame_counter << "/" << static_cast<int>(totalFrames) << "] - " << timeStr << " - Video Mode";
10990 appendRecordingTitleSuffix(stream);
10991 win->setWindowTitle(stream.str());
10992 lastUpdate = now;
10993 }
10994
10995 } else if (cap.isOpened() && filename.empty()) {
10996 if (std::chrono::duration_cast<std::chrono::seconds>(now - lastUpdate).count() >= 1) {
10997 std::string timeStr = getTimeString();
10998 int64_t currentFrames = getFrameCount();
10999 std::ostringstream stream;
11000 stream << "ACMX2 - Capture Mode - " << timeStr << " [" << currentFrames << " frames]";
11001 appendRecordingTitleSuffix(stream);
11002 win->setWindowTitle(stream.str());
11003 lastUpdate = now;
11004 }
11005 }
11006 const bool autopilotTickEnabled = autopilot_random_interval || autopilot_frames > 0;
11007 if (playlist_enabled && autopilot_enabled && autopilotTickEnabled) {
11008 const int interval = autopilot_random_interval ? autopilot_interval_frames : autopilot_frames;
11009 if (++autopilot_counter >= interval) {
11010 autopilot_counter = 0;
11011 if (autopilot_sequential && !autopilot_random_interval) {
11012 autopilotSequentialAdvance(win);
11013 } else {
11014 autopilotRandomSwitch(win);
11015 }
11016 if (autopilot_random_interval) {
11017 resetAutopilotInterval();
11018 }
11019 }
11020 }
11021 frame_counter++;
11022 }
11023
11024 /**
11025 * @brief Emit progress for headless video rendering.
11026 *
11027 * When a duration limit is active, its frame boundary becomes the
11028 * progress total if it occurs before source EOF. This makes a capped
11029 * silent render finish at 100% instead of stopping partway through the
11030 * input video's progress range.
11031 *
11032 * @param complete Force the final 100% progress update.
11033 */
11034 void emitSilentVideoProgress(bool complete) {
11035 if (!silent_mode || filename.empty() || fps <= 0.0) {
11036 return;
11037 }
11038
11039 uint64_t expected_frames = totalFrames > 0.0 ? static_cast<uint64_t>(std::ceil(totalFrames)) : 0;
11040 if (duration_limit > 0.0) {
11041 const uint64_t duration_frames = std::max<uint64_t>(1, static_cast<uint64_t>(std::ceil(duration_limit * fps)));
11042 if (repeat || expected_frames == 0) {
11043 expected_frames = duration_frames;
11044 } else {
11045 expected_frames = std::min(expected_frames, duration_frames);
11046 }
11047 }
11048 if (expected_frames == 0) {
11049 return;
11050 }
11051
11052 const uint64_t current_frame = duration_limit > 0.0 && repeat ? static_cast<uint64_t>(frames_proc) + 1 : static_cast<uint64_t>(frame_counter);
11053 const uint64_t processed_frames = complete ? expected_frames : std::min(current_frame, expected_frames);
11054 int current_percent = static_cast<int>((static_cast<double>(processed_frames) / expected_frames) * 100.0);
11055 if (!complete) {
11056 current_percent = std::min(current_percent, 99);
11057 }
11058
11059 const auto now = std::chrono::steady_clock::now();
11060 const bool percent_changed = current_percent > last_progress_percent;
11061 const bool time_elapsed = last_video_progress_emit.time_since_epoch().count() == 0 || std::chrono::duration_cast<std::chrono::milliseconds>(now - last_video_progress_emit).count() >= 500;
11062 if (!complete && !percent_changed && !time_elapsed) {
11063 return;
11064 }
11065
11066 last_progress_percent = current_percent;
11068 const int64_t frames_written = png_video_mode ? static_cast<int64_t>(png_video_frame_counter.load()) : (writer.is_open() ? writer.get_frame_count() : 0);
11069 const double elapsed_secs = static_cast<double>(processed_frames) / fps;
11070 const uint64_t hours = static_cast<uint64_t>(elapsed_secs / 3600.0);
11071 const uint64_t minutes = static_cast<uint64_t>(elapsed_secs / 60.0) % 60;
11072 const uint64_t seconds = static_cast<uint64_t>(elapsed_secs) % 60;
11073
11074 std::cout << "acmx2: [" << std::setw(3) << current_percent << "%] "
11075 << "Frame " << processed_frames << "/" << expected_frames << " | Written: " << frames_written << " | Time: " << std::setfill('0') << std::setw(2) << hours << ":" << std::setw(2) << minutes << ":" << std::setw(2) << seconds << std::setfill(' ');
11077 std::cout << "\n" << std::flush;
11078 }
11079
11080 /**
11081 * @brief Emit bounded progress updates for duration-limited headless graphics rendering.
11082 *
11083 * Reports after approximately one second of output frames or 500 ms of
11084 * wall time, whichever happens first. The duration limit provides the
11085 * expected frame count and allows a final 100% update.
11086 *
11087 * @param complete Force the final 100% progress update.
11088 */
11089 void emitSilentGraphicsProgress(bool complete) {
11090 if (!silent_mode || graphic.empty() || duration_limit <= 0.0 || fps <= 0.0) {
11091 return;
11092 }
11093
11094 const uint64_t expected_frames = std::max<uint64_t>(1, static_cast<uint64_t>(std::ceil(duration_limit * fps)));
11095 const uint64_t processed_frames = complete ? expected_frames : std::min<uint64_t>(static_cast<uint64_t>(frame_counter) + 1, expected_frames);
11096 const uint64_t frame_interval = std::max<uint64_t>(1, static_cast<uint64_t>(std::ceil(fps)));
11097 const auto now = std::chrono::steady_clock::now();
11098 const bool frame_interval_elapsed = processed_frames >= last_graphics_progress_frame + frame_interval;
11099 const bool time_interval_elapsed = last_graphics_progress_emit.time_since_epoch().count() == 0 || std::chrono::duration_cast<std::chrono::milliseconds>(now - last_graphics_progress_emit).count() >= 500;
11100
11101 if (!complete && !frame_interval_elapsed && !time_interval_elapsed) {
11102 return;
11103 }
11104
11105 int percent = static_cast<int>((static_cast<double>(processed_frames) / expected_frames) * 100.0);
11106 if (!complete) {
11107 percent = std::min(percent, 99);
11108 }
11109
11110 const double elapsed_secs = static_cast<double>(processed_frames) / fps;
11111 const uint64_t hours = static_cast<uint64_t>(elapsed_secs / 3600.0);
11112 const uint64_t minutes = static_cast<uint64_t>(elapsed_secs / 60.0) % 60;
11113 const uint64_t seconds = static_cast<uint64_t>(elapsed_secs) % 60;
11114 const int64_t frames_written = writer.is_open() ? writer.get_frame_count() : 0;
11115
11116 std::cout << "acmx2: [" << std::setw(3) << percent << "%] "
11117 << "Frame " << processed_frames << "/" << expected_frames << " | Written: " << frames_written << " | Time: " << std::setfill('0') << std::setw(2) << hours << ":" << std::setw(2) << minutes << ":" << std::setw(2) << seconds << std::setfill(' ');
11119 std::cout << "\n" << std::flush;
11120
11121 last_graphics_progress_frame = processed_frames;
11123 }
11124
11125 /**
11126 * @brief Format the current session time as `HH:MM:SS`.
11127 *
11128 * When a writer is open the time is based on the number of
11129 * frames actually written (at the configured FPS). For video
11130 * file input, a percentage prefix is prepended.
11131 *
11132 * @return Formatted time string for display.
11133 */
11134 std::string getTimeString() {
11135 int64_t frameCount = 0;
11136 double timeSeconds = 0.0;
11137
11138 if (writer.is_open()) {
11139 frameCount = writer.get_frame_count();
11140 timeSeconds = (fps > 0.0) ? static_cast<double>(frameCount) / fps : 0.0;
11141 } else {
11142 frameCount = static_cast<int64_t>(frame_counter);
11143 timeSeconds = (fps > 0.0) ? static_cast<double>(frameCount) / fps : 0.0;
11144 }
11145
11146 uint64_t hours = static_cast<uint64_t>(timeSeconds / 3600);
11147 uint64_t minutes = static_cast<uint64_t>(timeSeconds / 60) % 60;
11148 uint64_t seconds = static_cast<uint64_t>(timeSeconds) % 60;
11149
11150 std::ostringstream timerStr;
11151 if (!filename.empty() && totalFrames > 0.0) {
11152 double currentFrame = static_cast<double>(frame_counter);
11153 double percentage = (currentFrame / totalFrames) * 100.0;
11154 timerStr << std::fixed << std::setprecision(1) << percentage << "% - ";
11155 }
11156
11157 timerStr << std::setfill('0') << std::setw(2) << hours << ":" << std::setfill('0') << std::setw(2) << minutes << ":" << std::setfill('0') << std::setw(2) << seconds;
11158 return timerStr.str();
11159 }
11160
11161 void appendRecordingTitleSuffix(std::ostringstream &stream) {
11162 if (!writer.is_open() && !png_video_mode) {
11163 return;
11164 }
11165
11166 stream << " (Recording)";
11167 if (const auto file_size_bytes = getOutputFileSizeBytes(); file_size_bytes.has_value()) {
11168 constexpr double kBytesPerMB = 1024.0 * 1024.0;
11169 const double file_size_mb = static_cast<double>(*file_size_bytes) / kBytesPerMB;
11170 stream << " [File: " << std::fixed << std::setprecision(2) << file_size_mb << " MB]";
11171 }
11172 }
11173
11174 std::optional<uintmax_t> getOutputFileSizeBytes() const {
11175 if (ofilename.empty()) {
11176 return std::nullopt;
11177 }
11178
11179 std::error_code file_error;
11180 if (!std::filesystem::is_regular_file(ofilename, file_error) || file_error) {
11181 return std::nullopt;
11182 }
11183 const std::uintmax_t file_size = std::filesystem::file_size(ofilename, file_error);
11184 if (file_error) {
11185 return std::nullopt;
11186 }
11187
11188 return file_size;
11189 }
11190
11191 /// @brief Append the current encoded output size to a silent progress line.
11192 void appendSilentProgressFileSize([[maybe_unused]] std::ostream &stream) const {
11193 const auto file_size_bytes = getOutputFileSizeBytes();
11194 if (!file_size_bytes.has_value()) {
11195 return;
11196 }
11197
11198 constexpr double kBytesPerMB = 1024.0 * 1024.0;
11199 const double file_size_mb = static_cast<double>(*file_size_bytes) / kBytesPerMB;
11200 std::ostringstream size_stream;
11201 size_stream << std::fixed << std::setprecision(2) << file_size_mb;
11202 stream << " | Size: " << size_stream.str() << " MB";
11203 }
11204
11205 /**
11206 * @brief Return the current frame count (writer count or display count).
11207 * @return Number of frames written if recording, else the display counter.
11208 */
11209 int64_t getFrameCount() {
11210 if (png_video_mode) {
11211 return static_cast<int64_t>(png_video_frame_counter.load());
11212 }
11213 if (writer.is_open()) {
11214 return writer.get_frame_count();
11215 }
11216 return static_cast<int64_t>(frame_counter);
11217 }
11218
11219 /**
11220 * @brief Handle SDL keyboard events (shader navigation, mode toggles, etc.).
11221 *
11222 * Key bindings (SDL_KEYUP):
11223 * - Up/Down: Crossfade to the previous/next shader (or playlist entry if
11224 * playlist mode is enabled).
11225 * - Shift+Up/Down: In playlist or autopilot mode, change the post-multipass
11226 * shader without altering the current playlist position.
11227 * - Left/Right: Previous/next GPU CUDA filter.
11228 * - Space: Toggle shader bypass.
11229 * - P: Toggle playlist mode or pause video.
11230 * - L: Freeze frame (stop updating texture but keep time advancing).
11231 * - Z: Take a PNG snapshot (8-bit non-HDR readback when HDR input is active).
11232 * - 5: Take an HDR PNG snapshot (HDR mode only).
11233 * - T: Toggle active time. Q: Toggle audio time. Home: Toggle audio delta.
11234 * - V: Toggle view rotation (3D). O: Oscillation. C: Wave.
11235 * - X: Reset camera. Ctrl+X: Quit immediately without audio mux/transfer.
11236 * - N: Toggle random autopilot crossfade. 1: Camera movement speed up (3D). 2: Camera movement speed down (3D).
11237 * - 3: Toggle 2D/3D mode. M: Toggle multi-pass. E: Watermark.
11238 * - R: Toggle random multipass mode (generates 1-5 random shader chain).
11239 * - G: Generate new random shader chain (while in random multipass mode).
11240 * - H: Generate long random shader chain up to 10 (while in random multipass mode).
11241 * - F: Generate short random shader pair of 2 (while in random multipass mode).
11242 * - F9: Toggle HUD overlay visibility.
11243 *
11244 * Key bindings (SDL_KEYDOWN):
11245 * - U/I: Manual time step forward/backward.
11246 * - Insert/Delete: Audio sensitivity +/-.
11247 * - End: Toggle spectrum sensitivity scaling on/off.
11248 * - Page Up/Down: Time speed (handled in draw() via key-state polling).
11249 *
11250 * @param win Hosting GLWindow.
11251 * @param e The SDL event to process.
11252 */
11253 virtual void event(gl::GLWindow *win, SDL_Event &e) override {
11254 switch (e.type) {
11255 case SDL_KEYUP:
11256 switch (e.key.keysym.sym) {
11257 case SDLK_UP:
11258 if (shaderLocked)
11259 break;
11260 if ((e.key.keysym.mod & KMOD_SHIFT) && (playlist_enabled || autopilot_frames > 0)) {
11261 beginCrossfade(win);
11262 library.dec();
11263 mx::system_out << "acmx2: Post-shader (Shift+Up): " << library.getFullShaderName() << "\n";
11264 fflush(stdout);
11265 if (is3d_enabled)
11266 cube.setShaderProgram(library.shader());
11267 sprite.setShader(library.shader());
11269 break;
11270 }
11272 beginCrossfade(win);
11273 library.dec();
11274 mx::system_out << "acmx2: Random mode shader: " << library.getFullShaderName(shader_pass_list) << "\n";
11275 fflush(stdout);
11276 } else if (playlist_enabled && !playlist_tree.empty()) {
11277 if (playlist_index > 0) {
11278 beginCrossfade(win);
11280 const auto &node = playlist_tree[playlist_index];
11281 shader_pass_list = node.shader_indices;
11283 mx::system_out << "acmx2: Playlist Node: " << node.name << " [" << node.shader_indices.size() << " shaders] (" << (playlist_index + 1) << "/" << playlist_tree.size() << ")\n";
11284 fflush(stdout);
11285 }
11286 } else if (playlist_enabled && !playlist_indices.empty()) {
11287 if (playlist_index > 0) {
11288 beginCrossfade(win);
11290 }
11292 mx::system_out << "acmx2: Playlist [" << playlist_index << "/" << playlist_indices.size() << "]\n";
11293 fflush(stdout);
11294 } else {
11295 if (library.index() > 0) {
11296 beginCrossfade(win);
11297 library.dec();
11298 }
11299 }
11300 if (is3d_enabled)
11301 cube.setShaderProgram(library.shader());
11302 sprite.setShader(library.shader());
11304 break;
11305 case SDLK_DOWN:
11306 if (shaderLocked)
11307 break;
11308 if ((e.key.keysym.mod & KMOD_SHIFT) && (playlist_enabled || autopilot_frames > 0)) {
11309 beginCrossfade(win);
11310 library.inc();
11311 mx::system_out << "acmx2: Post-shader (Shift+Down): " << library.getFullShaderName() << "\n";
11312 fflush(stdout);
11313 if (is3d_enabled)
11314 cube.setShaderProgram(library.shader());
11315 sprite.setShader(library.shader());
11317 break;
11318 }
11320 beginCrossfade(win);
11321 library.inc();
11322 mx::system_out << "acmx2: Random mode shader: " << library.getFullShaderName(shader_pass_list) << "\n";
11323 fflush(stdout);
11324 } else if (playlist_enabled && !playlist_tree.empty()) {
11325 if (playlist_index + 1 < static_cast<int>(playlist_tree.size())) {
11326 beginCrossfade(win);
11328 const auto &node = playlist_tree[playlist_index];
11329 shader_pass_list = node.shader_indices;
11331 mx::system_out << "acmx2: Playlist Node: " << node.name << " [" << node.shader_indices.size() << " shaders] (" << (playlist_index + 1) << "/" << playlist_tree.size() << ")\n";
11332 fflush(stdout);
11333 }
11334 } else if (playlist_enabled && !playlist_indices.empty()) {
11335 if (playlist_index + 1 < static_cast<int>(playlist_indices.size())) {
11336 beginCrossfade(win);
11338 }
11340 mx::system_out << "acmx2: Playlist [" << playlist_index << "/" << playlist_indices.size() << "]\n";
11341 fflush(stdout);
11342 } else {
11343 if (library.index() + 1 < library.size()) {
11344 beginCrossfade(win);
11345 library.inc();
11346 }
11347 }
11348 if (is3d_enabled)
11349 cube.setShaderProgram(library.shader());
11350 sprite.setShader(library.shader());
11352 break;
11353 case SDLK_LEFT:
11354 if (gpu_filter_enabled && !gpu_filters.empty()) {
11358 gpu_filters.clear();
11360 gpu_filtersChanged = true;
11361 mx::system_out << "acmx2: GPU Filter: " << ac_gpu::filters[gpu_current_filter_index].name << " [" << gpu_current_filter_index << "]\n";
11362 fflush(stdout);
11363 }
11364 break;
11365 case SDLK_RIGHT:
11366 if (gpu_filter_enabled && !gpu_filters.empty()) {
11370 gpu_filters.clear();
11372 gpu_filtersChanged = true;
11373 mx::system_out << "acmx2: GPU Filter: " << ac_gpu::filters[gpu_current_filter_index].name << " [" << gpu_current_filter_index << "]\n";
11374 fflush(stdout);
11375 }
11376 break;
11377 case SDLK_SPACE:
11378 library.toggleBypass();
11380 break;
11381 case SDLK_p:
11382 if (!playlist_tree.empty()) {
11384 if (playlist_enabled) {
11385 beginCrossfade(win);
11388 if (playlist_index < 0 || playlist_index >= static_cast<int>(playlist_tree.size()))
11389 playlist_index = 0;
11390 const auto &node = playlist_tree[playlist_index];
11391 shader_pass_list = node.shader_indices;
11393 if (is3d_enabled)
11394 cube.setShaderProgram(library.shader());
11395 sprite.setShader(library.shader());
11397 mx::system_out << "acmx2: Playlist mode enabled - Node: " << node.name << " [" << node.shader_indices.size() << " shaders] (" << (playlist_index + 1) << "/" << playlist_tree.size() << ")\n";
11398 } else {
11399 beginCrossfade(win);
11402 if (is3d_enabled)
11403 cube.setShaderProgram(library.shader());
11404 sprite.setShader(library.shader());
11406 mx::system_out << "acmx2: Playlist mode disabled - restored original" << (shader_pass_enabled ? " multi-pass" : " single shader") << "\n";
11407 }
11408 fflush(stdout);
11409 } else if (!playlist_indices.empty()) {
11411 if (playlist_enabled) {
11412 beginCrossfade(win);
11413 if (playlist_index >= 0 && playlist_index < static_cast<int>(playlist_indices.size())) {
11415 if (is3d_enabled)
11416 cube.setShaderProgram(library.shader());
11417 sprite.setShader(library.shader());
11419 }
11420 mx::system_out << "acmx2: Playlist mode enabled [" << playlist_indices.size() << " shaders]\n";
11421 } else {
11422 beginCrossfade(win);
11423 mx::system_out << "acmx2: Playlist mode disabled\n";
11424 }
11425 fflush(stdout);
11426 } else if (!filename.empty() || !graphic.empty()) {
11427 isPaused = !isPaused;
11428 mx::system_out << "acmx2: paused: " << ((isPaused == true) ? "enabled" : "disabled") << "\n";
11429 fflush(stdout);
11430 fflush(stderr);
11431 }
11432 break;
11433 case SDLK_l:
11434 if (!filename.empty() || !graphic.empty()) {
11435 isFrozen = !isFrozen;
11436 mx::system_out << "acmx2: frozen: " << ((isFrozen == true) ? "enabled" : "disabled") << "\n";
11437 fflush(stdout);
11438 fflush(stderr);
11439 }
11440 break;
11441 case SDLK_k:
11443 mx::system_out << "acmx2: Shader lock: " << (shaderLocked ? "enabled" : "disabled") << "\n";
11444 fflush(stdout);
11445 break;
11446 case SDLK_j:
11447 if (!playlist_enabled) {
11448 mx::system_out << "acmx2: Autopilot requires playlist mode (press P first)\n";
11449 fflush(stdout);
11450 break;
11451 }
11452 if (playlist_tree.empty() && playlist_indices.empty()) {
11453 mx::system_out << "acmx2: Autopilot has no playlist entries\n";
11454 fflush(stdout);
11455 break;
11456 }
11459 if (autopilot_enabled) {
11460 autopilot_sequential = false;
11462 autopilot_frames = 300; // sensible default if user never set it
11463 }
11465 }
11466 if (autopilot_enabled) {
11468 mx::system_out << "acmx2: Autopilot enabled (random) (interval 4-" << std::max(4, autopilot_random_timeout) << " frames, current " << autopilot_interval_frames << ")\n";
11469 } else {
11470 mx::system_out << "acmx2: Autopilot enabled (random) (every " << autopilot_frames << " frames)\n";
11471 }
11472 } else {
11473 mx::system_out << "acmx2: Autopilot disabled\n";
11474 }
11475 fflush(stdout);
11476 break;
11477 case SDLK_y:
11478 if (!playlist_enabled) {
11479 mx::system_out << "acmx2: Sequential autopilot requires playlist mode (press P first)\n";
11480 fflush(stdout);
11481 break;
11482 }
11483 if (playlist_tree.empty() && playlist_indices.empty()) {
11484 mx::system_out << "acmx2: Sequential autopilot has no playlist entries\n";
11485 fflush(stdout);
11486 break;
11487 }
11489 autopilot_enabled = false;
11490 autopilot_sequential = false;
11491 mx::system_out << "acmx2: Autopilot disabled\n";
11492 } else {
11493 autopilot_enabled = true;
11494 autopilot_sequential = true;
11497 autopilot_frames = 300;
11498 }
11501 mx::system_out << "acmx2: Autopilot enabled (sequential timing + random index) (interval 4-" << std::max(4, autopilot_random_timeout) << " frames, current " << autopilot_interval_frames << ")\n";
11502 } else {
11503 mx::system_out << "acmx2: Autopilot enabled (sequential) (every " << autopilot_frames << " frames)\n";
11504 }
11505 }
11506 fflush(stdout);
11507 break;
11508 case SDLK_n:
11510 mx::system_out << "acmx2: Random autopilot crossfade " << (autopilot_random_crossfade ? "enabled" : "disabled") << "\n";
11511 fflush(stdout);
11512 break;
11513 case SDLK_z:
11514 if (snapshot_state == 0) {
11515 snapshot_state = 1;
11516 }
11517 break;
11518 case SDLK_4:
11519#ifdef ACMX2_WITH_TIFF
11520 if (tiff_snapshot_state == 0) {
11522 }
11523#endif
11524 break;
11525 case SDLK_5:
11526 if (hdr_snapshot_state == 0) {
11528 }
11529 break;
11530 case SDLK_6:
11531 if (raw_snapshot_state == 0) {
11533 }
11534 break;
11535#ifdef AUDIO_ENABLED
11536 case SDLK_t:
11537 library.activeTime(!library.timeActive());
11538 break;
11539 case SDLK_q:
11540 library.audioTime(!library.timeAudio());
11541 break;
11542 case SDLK_HOME:
11543 library.toggleAudioDelta();
11544 break;
11545#endif
11546 case SDLK_v:
11548 mx::system_out << "acmx2: View rotation: " << (viewRotationActive ? "enabled" : "disabled") << "\n";
11549 fflush(stdout);
11550 break;
11551 case SDLK_x:
11552 if ((e.key.keysym.mod & KMOD_CTRL) != 0) {
11554 mx::system_out << "acmx2: Ctrl+X pressed, exiting without audio mux\n";
11555 fflush(stdout);
11556 win->quit();
11557 break;
11558 }
11559 cameraDistance = 0.0f;
11560 mx::system_out << "acmx2: Camera distance reset\n";
11561 fflush(stdout);
11562 break;
11563 case SDLK_o:
11565 mx::system_out << "acmx2: Scale oscillation " << (oscillateScale ? "enabled" : "disabled") << "\n";
11566 fflush(stdout);
11567 break;
11568 case SDLK_c:
11570 mx::system_out << "acmx2: Wave effect " << (waveActive ? "enabled" : "disabled") << "\n";
11571 fflush(stdout);
11572 break;
11573 case SDLK_e:
11575 mx::system_out << "acmx2: Watermark " << (enableWatermark ? "enabled" : "disabled") << "\n";
11576 fflush(stdout);
11577 break;
11578 case SDLK_m:
11579 if (!shader_pass_list.empty()) {
11582 mx::system_out << "acmx2: Multi-shader pass " << (shader_pass_enabled ? "enabled" : "disabled") << "\n";
11583 fflush(stdout);
11584 } else {
11585 mx::system_out << "acmx2: No shader pass list defined (use --shader-pass)\n";
11586 fflush(stdout);
11587 }
11588 break;
11589 case SDLK_3:
11590 if (!library.isDualMode()) {
11591 mx::system_out << "acmx2: Cannot switch to 3D mode - 3D shaders not compiled (use --enable-3d at startup)\n";
11592 fflush(stdout);
11593 } else if (cube.meshes.empty()) {
11594 mx::system_out << "acmx2: Cannot switch to 3D mode - no model loaded (use --enable-3d)\n";
11595 fflush(stdout);
11596 } else {
11598 library.is3D(is3d_enabled);
11600 mx::system_out << "acmx2: " << (is3d_enabled ? "3D" : "2D") << " mode " << (is3d_enabled ? "enabled" : "disabled") << "\n";
11601 fflush(stdout);
11602 }
11603 break;
11604 case SDLK_r:
11605 if (std::get<0>(flib) == 0) {
11606 mx::system_out << "acmx2: Random multipass mode not available in single shader mode\n";
11607 fflush(stdout);
11608 } else if (!random_multipass_mode) {
11609 random_multipass_mode = true;
11614 mx::system_out << "acmx2: Random multipass mode enabled\n";
11615 fflush(stdout);
11616 } else {
11617 random_multipass_mode = false;
11618 beginCrossfade(win);
11622 if (is3d_enabled)
11623 cube.setShaderProgram(library.shader());
11624 sprite.setShader(library.shader());
11626 mx::system_out << "acmx2: Random multipass mode disabled - restored original\n";
11627 fflush(stdout);
11628 }
11629 break;
11630 case SDLK_g:
11633 } else {
11634 mx::system_out << "acmx2: Press R first to enable random multipass mode\n";
11635 fflush(stdout);
11636 }
11637 break;
11638 case SDLK_h:
11641 } else {
11642 mx::system_out << "acmx2: Press R first to enable random multipass mode\n";
11643 fflush(stdout);
11644 }
11645 break;
11646 case SDLK_f:
11649 } else {
11650 mx::system_out << "acmx2: Press R first to enable random multipass mode\n";
11651 fflush(stdout);
11652 }
11653 break;
11654 }
11655 break;
11656 case SDL_KEYDOWN:
11657 switch (e.key.keysym.sym) {
11658 case SDLK_u:
11659 library.incTime(0.05f);
11660 break;
11661 case SDLK_i:
11662 library.decTime(0.05f);
11663 break;
11664#ifdef AUDIO_ENABLED
11665 case SDLK_INSERT: {
11666 float s = audio_engine.analyzer().sensitivity() + 0.1f;
11667 if (s > 5.0f)
11668 s = 5.0f;
11670 mx::system_out << "acmx2: Audio sensitivity increased to " << s << "\n";
11671 fflush(stdout);
11672 break;
11673 }
11674 case SDLK_DELETE: {
11675 float s = audio_engine.analyzer().sensitivity() - 0.1f;
11676 if (s < 0.1f)
11677 s = 0.1f;
11679 mx::system_out << "acmx2: Audio sensitivity decreased to " << s << "\n";
11680 fflush(stdout);
11681 break;
11682 }
11683 case SDLK_END:
11685 mx::system_out << "acmx2: Spectrum sensitivity scaling " << (spectrum_scale_by_sense ? "enabled" : "disabled") << "\n";
11686 fflush(stdout);
11687 break;
11688#endif
11689 case SDLK_F9:
11691 mx::system_out << "acmx2: Overlay " << (counter_disabled ? "hidden" : "shown") << " (F9)\n";
11692 fflush(stdout);
11693 break;
11694 case SDLK_LEFTBRACKET:
11695 if (!crossfadeShaders.empty()) {
11696 int n = static_cast<int>(crossfadeShaders.size());
11698 mx::system_out << "acmx2: Crossfade shader: " << crossfadeShaderNames[crossfadeShaderIndex] << " (" << (crossfadeShaderIndex + 1) << "/" << n << ")\n";
11699 fflush(stdout);
11700 }
11701 break;
11702 case SDLK_RIGHTBRACKET:
11703 if (!crossfadeShaders.empty()) {
11704 int n = static_cast<int>(crossfadeShaders.size());
11706 mx::system_out << "acmx2: Crossfade shader: " << crossfadeShaderNames[crossfadeShaderIndex] << " (" << (crossfadeShaderIndex + 1) << "/" << n << ")\n";
11707 fflush(stdout);
11708 }
11709 break;
11710 }
11711 break;
11712 }
11713 library.event(e);
11714 }
11715
11716 private:
11717 unsigned int frame_counter = 0;
11718 std::string crf = "23";
11719 EncodeOptions encode_opts{};
11720 std::string prefix_path;
11723 std::tuple<int, std::string, int> flib;
11724 std::optional<cv::Size> sizev, sizec;
11725#ifdef AUDIO_ENABLED
11727#else
11728 ShaderLibrary library;
11729#endif
11730 Writer writer;
11731 double fps = 30;
11732 bool repeat = false;
11733 bool full = false;
11738 double totalFrames = 0;
11739 cv::VideoCapture cap;
11741 bool use_ffmpeg_reader = false;
11743 gl::GLSprite sprite;
11744 GLuint camera_texture = 0;
11745 GLuint captureFBO = 0;
11746 GLuint fboTexture = 0;
11747 GLuint preOverlayFBO = 0; ///< FBO whose color attachment is preOverlayTexture; used to save/restore fboTexture around the overlay+writer step.
11748 GLuint preOverlayTexture = 0; ///< Snapshot of fboTexture taken before drawing the watermark/display-filter overlay, used to restore the un-watermarked image after the writer readback so the next frame's crossfade snapshot does not pick up the overlay.
11749 GLuint depthBuffer = 0;
11750 GLuint passFBO[2] = {0, 0};
11751 GLuint passTexture[2] = {0, 0};
11754 GLuint crossfadeFBO = 0; ///< FBO used for the crossfade compositing pass.
11755 GLuint crossfadeTexture = 0; ///< Colour attachment of @c crossfadeFBO (blended output).
11756 GLuint crossfadePrevTexture = 0; ///< Snapshot of the previous frame used as the blend source.
11757
11758 // --- HDR-mode GL resources ----------------------------------------------
11759 // Only allocated / used when @ref input_is_hdr is true. SDR path is
11760 // entirely unchanged. Internal formats: GL_RGBA16 for textures that
11761 // hold PQ/HLG-encoded normalised values (source upload + pre-encode
11762 // readback target), GL_RGBA16F for linear-BT.2020 intermediates.
11763 bool input_is_hdr = false; ///< Active HDR pipeline for this input.
11764 int input_hdr_trc = 0; ///< AVColorTransferCharacteristic (PQ/HLG/BT2020).
11765 int hdr_upload_tex_w = 0; ///< Current GL size of @ref camera_texture in HDR upload mode.
11766 int hdr_upload_tex_h = 0; ///< Current GL size of @ref camera_texture in HDR upload mode.
11767 int hdr_resource_w = 0; ///< Width of HDR intermediate/encoded textures.
11768 int hdr_resource_h = 0; ///< Height of HDR intermediate/encoded textures.
11769 GLuint hdr_linear_video_texture = 0; ///< GL_RGBA16F: PQ/HLG-decoded linear BT.2020 video.
11770 GLuint hdr_linear_video_fbo = 0; ///< FBO writing into @ref hdr_linear_video_texture.
11771 GLuint hdr_encoded_texture = 0; ///< GL_RGBA16: final PQ-re-encoded output for readback.
11772 GLuint hdr_encoded_fbo = 0; ///< FBO writing into @ref hdr_encoded_texture.
11773 gl::ShaderProgram hdr_decode_shader; ///< PQ/HLG -> linear BT.2020 fullscreen pass.
11774 gl::ShaderProgram hdr_encode_shader; ///< Linear BT.2020 -> PQ (or HLG) fullscreen pass.
11775 gl::ShaderProgram display_flip_shader; ///< Display shader with optional Y-flip for windowed output.
11776 cv::Mat hdr_frame_mat; ///< Scratch CV_16UC4 RGBA frame for HDR decode.
11777 std::vector<gl::ShaderProgram> crossfadeShaders; ///< Available crossfade transition shaders (cycle with [ and ]).
11778 std::vector<std::string> crossfadeShaderNames; ///< Display names matching @ref crossfadeShaders by index.
11779 int crossfadeShaderIndex = 0; ///< Active index into @ref crossfadeShaders.
11780 float crossfadeAlpha = 1.0f; ///< Current blend factor (0 = old frame, 1 = new frame).
11781 bool crossfadeActive = false; ///< True while a crossfade transition is in progress.
11782 float crossfadeDuration = 0.5f; ///< Duration of the crossfade transition in seconds.
11783 std::chrono::steady_clock::time_point crossfadeStartTime; ///< Wall-clock time the current crossfade began.
11784 std::thread writerThread;
11785 std::atomic<bool> running{false};
11786 std::atomic<bool> captureRunning{false};
11787 std::atomic<bool> writerRunning{false};
11788 std::queue<FrameData> frameQueue;
11789 std::mutex queueMutex;
11790 std::condition_variable queueCondVar;
11791 std::thread captureThread;
11792 std::queue<cv::Mat> captureQueue;
11794 std::condition_variable captureQueueCondVar;
11795 std::chrono::steady_clock::time_point lastFrameTime = std::chrono::steady_clock::now();
11797 bool texture_cache = false;
11800 int cache_warmup_frames = 0; // Frames to skip before pushing into cache after load
11803 std::chrono::steady_clock::time_point media_timeline_start_time{};
11805 std::chrono::steady_clock::time_point last_graphics_progress_emit{};
11806 std::chrono::steady_clock::time_point last_video_progress_emit{};
11809 std::atomic<bool> finished{false};
11810 std::atomic<bool> copy_audio{false};
11811 std::atomic<bool> skip_audio_mux_on_exit{false};
11812 std::atomic<bool> isMuxing{false};
11813 std::atomic<bool> muxComplete{false};
11814 std::thread muxThread;
11815 float cameraYaw = 270.0f;
11816 float cameraPitch = 0.0f;
11819 bool oscillateScale = false;
11820 bool waveActive = false;
11821 float cameraDistance = 0.0f;
11822 float modelSize = 1.0f;
11823 float modelRenderScale = 1.0f;
11824 glm::vec3 modelCenterOffset = glm::vec3(0.0f);
11825 float modelRotX = 0.0f;
11826 float modelRotY = 0.0f;
11827 float modelRotZ = 0.0f;
11828 std::atomic<uint64_t> snapshotOffset{0};
11829 [[maybe_unused]] int gpu_cuda_device = 0;
11830 bool silent_mode = false;
11831 bool no_drop_mode = false;
11832#ifdef __APPLE__
11833 bool use_shader_cache_flag = false;
11834#else
11836#endif
11837 bool use_yuv = false;
11838 bool flip_output = false;
11840 bool png_video_mode = false;
11841 std::string png_video_dir;
11842 std::atomic<uint64_t> png_video_frame_counter{0};
11843 bool generate_mode = false;
11845 std::string generate_dir;
11846 std::atomic<uint64_t> generate_all_frames{0};
11847 std::atomic<uint64_t> generate_saved_counter{0};
11849 bool enableWatermark = false;
11850 bool display_filter = false;
11852 std::string watermark_text = "LostSideDead.biz"; ///< Active watermark text (overridden by --use-watermark).
11853 int watermark_r = 255; ///< Watermark color red.
11854 int watermark_g = 0; ///< Watermark color green.
11855 int watermark_b = 150; ///< Watermark color blue.
11856
11857 private:
11858 std::atomic<uint64_t> frames_dropped{0};
11859 int win_w = 0;
11860 int win_h = 0;
11861
11862 /**
11863 * @brief Read any remaining pixels from both PBOs and enqueue them.
11864 *
11865 * Called during destruction to ensure the last two frames that
11866 * were in-flight via double-buffered PBO readback are not lost.
11867 * Each PBO is mapped, its contents copied and vertically flipped,
11868 * then pushed into the writer queue.
11869 *
11870 * @param win GL window (provides width/height).
11871 */
11872 void flushPBOs(gl::GLWindow *win) {
11873 if (!pboIds[0])
11874 return;
11875 for (int i = 0; i < 2; i++) {
11876 glBindBuffer(GL_PIXEL_PACK_BUFFER, pboIds[i]);
11877 GLubyte *src = static_cast<GLubyte *>(glMapBuffer(GL_PIXEL_PACK_BUFFER, GL_READ_ONLY));
11878
11879 if (src) {
11880 std::vector<unsigned char> pixels(win->w * win->h * 4);
11881 std::memcpy(pixels.data(), src, pixels.size());
11882 glUnmapBuffer(GL_PIXEL_PACK_BUFFER);
11883
11884 std::vector<unsigned char> flipped_pixels(win->w * win->h * 4);
11885 for (int y = 0; y < win->h; ++y) {
11886 int src_row_start = y * win->w * 4;
11887 int dest_row_start = (win->h - 1 - y) * win->w * 4;
11888 std::copy(pixels.begin() + src_row_start, pixels.begin() + src_row_start + (win->w * 4), flipped_pixels.begin() + dest_row_start);
11889 }
11890
11891 FrameData fd;
11892 fd.pixels = std::move(flipped_pixels);
11893 fd.width = win->w;
11894 fd.height = win->h;
11895 fd.isSnapshot = false;
11896
11897 {
11898 std::lock_guard<std::mutex> lock(queueMutex);
11899 frameQueue.push(std::move(fd));
11900 }
11901 queueCondVar.notify_one();
11902 }
11903 glBindBuffer(GL_PIXEL_PACK_BUFFER, 0);
11904 }
11905 }
11906
11907 /**
11908 * @brief Create the off-screen FBO used for capture/recording.
11909 *
11910 * Allocates a GL_RGBA colour texture and a depth renderbuffer,
11911 * both sized to the requested dimensions, and attaches them to
11912 * a new framebuffer. The capture FBO is the target of all
11913 * shader rendering; its colour attachment (fboTexture) is then
11914 * blitted to the default framebuffer and read back via PBOs.
11915 *
11916 * @param width FBO width in pixels.
11917 * @param height FBO height in pixels.
11918 * @throws mx::Exception if the FBO completeness check fails.
11919 */
11920 void setupCaptureFBO(int width, int height) {
11921 win_w = width;
11922 win_h = height;
11923 glGenFramebuffers(1, &captureFBO);
11924 glBindFramebuffer(GL_FRAMEBUFFER, captureFBO);
11925
11926 glGenTextures(1, &fboTexture);
11927 glBindTexture(GL_TEXTURE_2D, fboTexture);
11928 glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, width, height, 0, GL_RGBA, GL_UNSIGNED_BYTE, nullptr);
11929
11930 glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
11931 glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
11932
11933 glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, fboTexture, 0);
11934
11935 glGenRenderbuffers(1, &depthBuffer);
11936 glBindRenderbuffer(GL_RENDERBUFFER, depthBuffer);
11937 glRenderbufferStorage(GL_RENDERBUFFER, GL_DEPTH_COMPONENT, width, height);
11938 glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, GL_RENDERBUFFER, depthBuffer);
11939
11940 if (glCheckFramebufferStatus(GL_FRAMEBUFFER) != GL_FRAMEBUFFER_COMPLETE) {
11941 glBindFramebuffer(GL_FRAMEBUFFER, 0);
11942 throw mx::Exception("FBO is not complete.");
11943 }
11944 glBindFramebuffer(GL_FRAMEBUFFER, 0);
11945
11946 if (preOverlayTexture == 0) {
11947 glGenTextures(1, &preOverlayTexture);
11948 }
11949 glBindTexture(GL_TEXTURE_2D, preOverlayTexture);
11950 glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, width, height, 0, GL_RGBA, GL_UNSIGNED_BYTE, nullptr);
11951 glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
11952 glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
11953 glBindTexture(GL_TEXTURE_2D, 0);
11954
11955 if (preOverlayFBO == 0) {
11956 glGenFramebuffers(1, &preOverlayFBO);
11957 }
11958 glBindFramebuffer(GL_FRAMEBUFFER, preOverlayFBO);
11959 glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, preOverlayTexture, 0);
11960 if (glCheckFramebufferStatus(GL_FRAMEBUFFER) != GL_FRAMEBUFFER_COMPLETE) {
11961 glBindFramebuffer(GL_FRAMEBUFFER, 0);
11962 throw mx::Exception("preOverlayFBO is not complete.");
11963 }
11964 glBindFramebuffer(GL_FRAMEBUFFER, 0);
11965 }
11966 /**
11967 * @brief Create a new GL texture from a cv::Mat (BGR → RGBA upload).
11968 *
11969 * Generates a texture, converts the frame from BGR to RGBA, and
11970 * uploads via glTexImage2D. Used to initialise the 8 cache
11971 * textures with blank frames at startup.
11972 *
11973 * @param frame Input cv::Mat (BGR, 8-bit).
11974 * @return Newly created GL texture ID.
11975 * @throws mx::Exception on any GL error.
11976 */
11977 GLuint loadTexture(cv::Mat &frame) {
11978 GLuint texture = 0;
11979 glGenTextures(1, &texture);
11980 GLenum error = glGetError();
11981 if (error != GL_NO_ERROR) {
11982 throw mx::Exception("OpenGL error: glGenTextures() returned " + std::to_string(error));
11983 }
11984
11985 glBindTexture(GL_TEXTURE_2D, texture);
11986 glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
11987 glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
11988 glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
11989 glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
11990
11991 cv::Mat temp;
11992 cv::cvtColor(frame, temp, cv::COLOR_BGR2RGBA);
11993 glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, temp.cols, temp.rows, 0, GL_RGBA, GL_UNSIGNED_BYTE, temp.ptr());
11994
11995 error = glGetError();
11996 if (error != GL_NO_ERROR) {
11997 throw mx::Exception("OpenGL error: glTexImage2D() returned " + std::to_string(error));
11998 }
11999 glBindTexture(GL_TEXTURE_2D, 0);
12000 return texture;
12001 }
12002
12003 /**
12004 * @brief Update an existing GL texture with new cv::Mat data (BGR → RGBA).
12005 *
12006 * Uses glTexSubImage2D when dimensions match (fast path) or
12007 * falls back to glTexImage2D when the frame size has changed
12008 * (reallocates the texture storage).
12009 *
12010 * @param texture GL texture ID to update.
12011 * @param frame New frame (BGR, 8-bit).
12012 */
12013 void updateTexture(GLuint texture, cv::Mat &frame) {
12014 glBindTexture(GL_TEXTURE_2D, texture);
12015 cv::Mat temp;
12016 cv::cvtColor(frame, temp, cv::COLOR_BGR2RGBA);
12017 GLint texWidth = 0, texHeight = 0;
12018 glGetTexLevelParameteriv(GL_TEXTURE_2D, 0, GL_TEXTURE_WIDTH, &texWidth);
12019 glGetTexLevelParameteriv(GL_TEXTURE_2D, 0, GL_TEXTURE_HEIGHT, &texHeight);
12020 if (texWidth != temp.cols || texHeight != temp.rows) {
12021 glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, temp.cols, temp.rows, 0, GL_RGBA, GL_UNSIGNED_BYTE, temp.ptr());
12022 } else {
12023 glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, temp.cols, temp.rows, GL_RGBA, GL_UNSIGNED_BYTE, temp.ptr());
12024 }
12025 glBindTexture(GL_TEXTURE_2D, 0);
12026 }
12027
12028 /**
12029 * @brief Update a GL texture with pre-converted RGBA data (no colour conversion).
12030 *
12031 * Faster than updateTexture() when the frame is already RGBA.
12032 * Does not handle size changes—caller must ensure dimensions match.
12033 *
12034 * @param texture GL texture ID.
12035 * @param frame cv::Mat in RGBA format.
12036 */
12037 void updateTextureRGBA(GLuint texture, cv::Mat &frame) {
12038 glBindTexture(GL_TEXTURE_2D, texture);
12039 glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, frame.cols, frame.rows, GL_RGBA, GL_UNSIGNED_BYTE, frame.ptr());
12040 glBindTexture(GL_TEXTURE_2D, 0);
12041 }
12042
12043 /**
12044 * @brief Start the background camera capture thread (camera mode only).
12045 *
12046 * Spawns a `std::thread` that continuously calls `cap.read()` and
12047 * pushes the flipped frame into `captureQueue`. The queue is
12048 * capped at 4 entries; when full the oldest frame is dropped
12049 * so the capture thread never blocks. The main thread pops
12050 * from this queue inside draw() under `captureQueueMutex`.
12051 *
12052 * The thread exits when `captureRunning` is set to false or
12053 * a read failure occurs.
12054 *
12055 * @see stopCaptureThread()
12056 */
12058 if (captureThread.joinable()) {
12059 return;
12060 }
12061 captureRunning = true;
12062 captureThread = std::thread([this]() {
12063 try {
12064 // Drain any frames buffered by the OS/driver during shader
12065 // loading. Without this, frames captured while the loading
12066 // screen was visible are the first ones pushed into the cache
12067 // ring, making samp1-samp8 shaders show loading-screen content.
12068 {
12069 cv::Mat drain;
12070 for (int i = 0; i < 8 && captureRunning; ++i) {
12071 if (!cap.read(drain) || drain.empty())
12072 break;
12073 }
12074 }
12075
12076 while (captureRunning) {
12077 cv::Mat localFrame;
12078 if (!cap.read(localFrame)) {
12079 mx::system_err << "acmx2: camera read failed.\n";
12080 captureRunning = false;
12081 running = false;
12082 break;
12083 }
12084 if (localFrame.empty()) {
12085 continue;
12086 }
12087 cv::flip(localFrame, localFrame, 0);
12088 {
12089 std::lock_guard<std::mutex> lock(captureQueueMutex);
12090 if (captureQueue.size() >= 4) {
12091 captureQueue.pop();
12092 }
12093 captureQueue.push(std::move(localFrame));
12094 }
12095 captureQueueCondVar.notify_one();
12096 }
12097 } catch (const std::exception &e) {
12098 mx::system_err << "acmx2: Capture thread exception: " << e.what() << "\n";
12099 captureRunning = false;
12100 running = false;
12101 }
12102 });
12103 }
12104
12105 /**
12106 * @brief Signal the capture thread to stop and wait for it to join.
12107 *
12108 * Sets `captureRunning = false`, wakes the condition variable
12109 * (in case the thread is waiting), and joins the thread.
12110 */
12112 captureRunning = false;
12113 captureQueueCondVar.notify_all();
12114 if (captureThread.joinable()) {
12115 captureThread.join();
12116 }
12117 }
12118
12119 /**
12120 * @brief Start the background writer thread (video recording + snapshots).
12121 *
12122 * Spawns a `std::thread` that dequeues FrameData from `frameQueue`
12123 * and either:
12124 * - Dispatches snapshot frames to the SnapshotThreadPool for async
12125 * PNG writing.
12126 * - Passes video frames to `writer.write()` for sequential output or
12127 * `writer.write_at_pts()` for clock-synchronised output.
12128 *
12129 * The thread blocks on `queueCondVar` when the queue is empty.
12130 * It exits when `writerRunning` is set to false and the queue is
12131 * drained.
12132 *
12133 * @see stopWriterThread()
12134 */
12136 if (writerThread.joinable())
12137 return;
12138 writerRunning = true;
12139 writerThread = std::thread([this]() {
12140 try {
12141 auto write_video_frame = [this](const FrameData &fd) {
12142 if (writer.is_open() || png_video_mode) {
12143 if (png_video_mode) {
12144 uint64_t frame_index = png_video_frame_counter.fetch_add(1);
12145 std::ostringstream frame_name;
12146 frame_name << png_video_dir << "/frame-" << std::setfill('0') << std::setw(8) << frame_index << ".png";
12147 std::string frame_path = frame_name.str();
12148 if (fd.isHdr) {
12149 png::SavePNG_RGBA16(frame_path.c_str(), fd.pixels.data(), fd.width, fd.height);
12150 } else {
12151 png::SavePNG_RGBA(frame_path.c_str(), const_cast<unsigned char *>(fd.pixels.data()), fd.width, fd.height);
12152 }
12153 } else if (fd.isHdr) {
12154 if (fd.usesTimelineClock) {
12155 writer.write_hdr_rgba16_at_pts(const_cast<unsigned char *>(fd.pixels.data()), static_cast<int64_t>(fd.timelineFrame));
12156 } else {
12157 writer.write_hdr_rgba16(const_cast<unsigned char *>(fd.pixels.data()));
12158 }
12159 } else if (!filename.empty() || !graphic.empty()) {
12160 if (fd.usesTimelineClock) {
12161 writer.write_at_pts(const_cast<unsigned char *>(fd.pixels.data()), static_cast<int64_t>(fd.timelineFrame));
12162 } else {
12163 writer.write(const_cast<unsigned char *>(fd.pixels.data()));
12164 }
12165 } else if (fd.usesTimelineClock) {
12166 writer.write_at_pts(const_cast<unsigned char *>(fd.pixels.data()), static_cast<int64_t>(fd.timelineFrame));
12167 } else {
12168 writer.write(const_cast<unsigned char *>(fd.pixels.data()));
12169 }
12170 }
12171
12172 if (generate_mode) {
12173 uint64_t all_idx = generate_all_frames.fetch_add(1);
12174 if (generate_interval > 0 && all_idx % static_cast<uint64_t>(generate_interval) == 0) {
12175 uint64_t saved_idx = generate_saved_counter.fetch_add(1);
12176 std::ostringstream frame_name;
12177 frame_name << generate_dir << "/frame-" << std::setfill('0') << std::setw(8) << saved_idx << ".png";
12178 std::string frame_path = frame_name.str();
12179 if (fd.isHdr) {
12180 png::SavePNG_RGBA16(frame_path.c_str(), fd.pixels.data(), fd.width, fd.height);
12181 } else {
12182 png::SavePNG_RGBA(frame_path.c_str(), const_cast<unsigned char *>(fd.pixels.data()), fd.width, fd.height);
12183 }
12184 }
12185 }
12186 };
12187
12188 // Drain queued frames before exiting. shutdown paths set
12189 // writerRunning=false first, then notify; if we only loop on
12190 // writerRunning we can leave tail frames unwritten.
12191 while (true) {
12192 FrameData fd;
12193 {
12194 std::unique_lock<std::mutex> lock(queueMutex);
12195 queueCondVar.wait(lock, [this]() { return !frameQueue.empty() || !writerRunning; });
12196
12197 if (!writerRunning && frameQueue.empty()) {
12198 break;
12199 }
12200
12201 fd = std::move(frameQueue.front());
12202 frameQueue.pop();
12203
12204 queueCondVar.notify_all();
12205 }
12206
12207 if (fd.isSnapshot) {
12208 uint64_t current_offset = snapshotOffset.fetch_add(1);
12209 std::string snap_prefix = prefix_path;
12210 snapshot_pool.enqueue([snap_prefix, fd, current_offset] {
12211 auto now1 = std::chrono::system_clock::now();
12212 std::time_t now_c = std::chrono::system_clock::to_time_t(now1);
12213 std::tm localTime{};
12214#ifdef _WIN32
12215 localtime_s(&localTime, &now_c);
12216#else
12217 localtime_r(&now_c, &localTime);
12218#endif
12219
12220 std::ostringstream oss;
12221 oss << std::put_time(&localTime, "%Y.%m.%d-%H.%M.%S");
12222 std::string snapshot_type = fd.isHdr ? "ACMX2.HDR.Snapshot" : "ACMX2.Snapshot";
12223#ifdef ACMX2_WITH_WEBP
12224 const bool write_webp = fd.isWebPSnapshot || fd.isHdr;
12225 const char *snap_ext = write_webp ? ".webp" : ".png";
12226#else
12227 const char *snap_ext = ".png";
12228#endif
12229 std::string name = snap_prefix + "/" + snapshot_type + "-" + oss.str() + "-" + std::to_string(fd.width) + "x" + std::to_string(fd.height) + "-" + std::to_string(current_offset) + snap_ext;
12230
12231 if (fd.isHdr) {
12232#ifdef ACMX2_WITH_WEBP
12233 if (!saveHdrWebPFromRgba16(name.c_str(), fd.pixels.data(), fd.width, fd.height, fd.hdrTrc)) {
12234 mx::system_err << "acmx2: ERROR: failed to write HDR WebP snapshot: " << name << "\n";
12235 }
12236#else
12237 png::SavePNG_RGBA16(name.c_str(), fd.pixels.data(), fd.width, fd.height);
12238#endif
12239 } else {
12240 if (fd.isWebPSnapshot) {
12241#ifdef ACMX2_WITH_WEBP
12242 if (!saveSdrWebPFromRgba8(name.c_str(), fd.pixels.data(), fd.width, fd.height)) {
12243 mx::system_err << "acmx2: ERROR: failed to write SDR WebP snapshot: " << name << "\n";
12244 }
12245#else
12246 png::SavePNG_RGBA(name.c_str(), const_cast<unsigned char *>(fd.pixels.data()), fd.width, fd.height);
12247#endif
12248 } else {
12249 png::SavePNG_RGBA(name.c_str(), const_cast<unsigned char *>(fd.pixels.data()), fd.width, fd.height);
12250 }
12251 }
12252
12253 mx::system_out << "acmx2: Took snapshot: " << name << "\n";
12254 fflush(stdout);
12255 });
12256 }
12257 if (fd.isRawSnapshot) {
12258 uint64_t current_offset = snapshotOffset.fetch_add(1);
12259 std::string snap_prefix = prefix_path;
12260 snapshot_pool.enqueue([snap_prefix, fd, current_offset] {
12261 auto now1 = std::chrono::system_clock::now();
12262 std::time_t now_c = std::chrono::system_clock::to_time_t(now1);
12263 std::tm localTime{};
12264#ifdef _WIN32
12265 localtime_s(&localTime, &now_c);
12266#else
12267 localtime_r(&now_c, &localTime);
12268#endif
12269 std::ostringstream oss;
12270 oss << std::put_time(&localTime, "%Y.%m.%d-%H.%M.%S");
12271 std::string snapshot_type = fd.isHdr ? "ACMX2.HDR.Snapshot" : "ACMX2.Raw";
12272 std::string name = snap_prefix + "/" + snapshot_type + "-" + oss.str() + "-" + std::to_string(fd.width) + "x" + std::to_string(fd.height) + "-" + std::to_string(current_offset) + ".raw";
12273
12274 size_t bpp = fd.isHdr ? 8 : 4;
12275 png::SaveRawBytes(name.c_str(), fd.pixels.data(), static_cast<size_t>(fd.width), static_cast<size_t>(fd.height), bpp);
12276
12277 mx::system_out << "acmx2: Saved raw frame: " << name << "\n";
12278 fflush(stdout);
12279 });
12280 }
12281#ifdef ACMX2_WITH_TIFF
12282 if (fd.isTiffSnapshot) {
12283 uint64_t current_offset = snapshotOffset.fetch_add(1);
12284 std::string snap_prefix = prefix_path;
12285 snapshot_pool.enqueue([snap_prefix, fd, current_offset] {
12286 auto now1 = std::chrono::system_clock::now();
12287 std::time_t now_c = std::chrono::system_clock::to_time_t(now1);
12288 std::tm localTime{};
12289#ifdef _WIN32
12290 localtime_s(&localTime, &now_c);
12291#else
12292 localtime_r(&now_c, &localTime);
12293#endif
12294 std::ostringstream oss;
12295 oss << std::put_time(&localTime, "%Y.%m.%d-%H.%M.%S");
12296 std::string snapshot_type = fd.isHdr ? "ACMX2.HDR.Snapshot" : "ACMX2.Snapshot";
12297 std::string name = snap_prefix + "/" + snapshot_type + "-" + oss.str() + "-" + std::to_string(fd.width) + "x" + std::to_string(fd.height) + "-" + std::to_string(current_offset) + ".tiff";
12298 bool ok = false;
12299 if (fd.isHdr) {
12300 ok = saveHdrTiffFromRgba16(name.c_str(), fd.pixels.data(), fd.width, fd.height, fd.hdrTrc);
12301 } else {
12302 ok = saveSdrTiffFromRgba8(name.c_str(), fd.pixels.data(), fd.width, fd.height);
12303 }
12304 if (!ok) {
12305 mx::system_err << "acmx2: ERROR: failed to write TIFF snapshot: " << name << "\n";
12306 } else {
12307 mx::system_out << "acmx2: Took snapshot: " << name << "\n";
12308 }
12309 fflush(stdout);
12310 });
12311 }
12312#endif
12313 const bool is_snapshot_task = fd.isSnapshot || fd.isRawSnapshot || fd.isTiffSnapshot;
12314 if (is_snapshot_task) {
12315 continue;
12316 }
12317
12318 write_video_frame(fd);
12319 }
12320 } catch (const std::exception &e) {
12321 mx::system_err << "acmx2: writer thread exception: " << e.what() << "\n";
12322 }
12323 writerRunning = false;
12324 });
12325 }
12326
12327 /**
12328 * @brief Start WAV audio capture if recording is configured and not already active.
12329 *
12330 * Called when the first valid source frame starts the shared media
12331 * timeline so captured audio and encoded video have the same origin.
12332 */
12334#ifdef AUDIO_ENABLED
12335 auto &recorder = audio_engine.recorder();
12336 if (audio_is_enabled && !file_audio_mode && !audio_record_file.empty() && !recorder.is_recording()) {
12338 mx::system_err << "acmx2: Error could not start audio recording to: " << audio_record_file << "\n";
12339 }
12340 }
12341#endif
12342 }
12343
12344 /**
12345 * @brief Start playback and recording timing on the first valid source frame.
12346 *
12347 * Writer setup, shader compilation, and camera startup can all render
12348 * before an input frame exists. This one-shot gate prevents those draws
12349 * from advancing file audio or entering the video writer.
12350 */
12353 return;
12354 }
12355
12357 media_timeline_start_time = std::chrono::steady_clock::now();
12358 frames_proc = 0;
12359#ifdef AUDIO_ENABLED
12362#endif
12363 mx::system_out << "acmx2: Media timeline started on first source frame\n";
12364 fflush(stdout);
12365 }
12366
12367 /**
12368 * @brief Determine if recorded-audio mux should run at shutdown.
12369 *
12370 * Returns true only when recorded audio mode is enabled, an output
12371 * file is set, and either recording is still active or the recorded
12372 * WAV file already exists.
12373 */
12374 bool needsMux() {
12375#ifdef AUDIO_ENABLED
12376 return audio_is_enabled && !file_audio_mode && !audio_record_file.empty() && !ofilename.empty() && (audio_engine.recorder().is_recording() || std::filesystem::exists(audio_record_file));
12377#else
12378 return false;
12379#endif
12380 }
12381
12382 /**
12383 * @brief Determine if source-track audio copy should run at shutdown.
12384 *
12385 * This applies to non-repeating video-file input with `--copy-audio`.
12386 */
12387 bool needsTransferAudio() { return !filename.empty() && !repeat && copy_audio && writer.is_open(); }
12388
12390#ifdef AUDIO_ENABLED
12391 /// @brief Check whether the output video needs file-audio muxing.
12392 /// @return @c true when file_audio_mode is active, recording was explicitly
12393 /// requested (used as mux opt-in), and an output file is set.
12394 return file_audio_mode && !audio_file_path.empty() && !audio_record_file.empty() && !ofilename.empty();
12395#else
12396 return false;
12397#endif
12398 }
12399
12400 /**
12401 * @brief Run ffmpeg synchronously to mux the recorded audio WAV into the video MP4.
12402 *
12403 * Builds an ffmpeg command line that copies the video stream,
12404 * encodes the audio as AAC 192 kbps, and trims to the video
12405 * duration. The output is written to a temporary file which
12406 * replaces the original on success.
12407 *
12408 * Called either from the mux thread (beginMuxing) or directly
12409 * from the destructor when no mux thread was launched.
12410 */
12411 void runMuxSync() {
12412#ifdef AUDIO_ENABLED
12413 if (!audio_is_enabled || audio_record_file.empty() || ofilename.empty())
12414 return;
12415 auto &recorder = audio_engine.recorder();
12416 if (!recorder.is_recording() && !std::filesystem::exists(audio_record_file)) {
12417 mx::system_out << "acmx2: recorded audio file not found, skipping recorded-audio mux: " << audio_record_file << "\n";
12418 fflush(stdout);
12419 return;
12420 }
12421 if (recorder.is_recording())
12422 recorder.stop();
12423 std::string out_ext = std::filesystem::path(ofilename).extension().string();
12424 if (out_ext.empty())
12425 out_ext = ".mp4";
12426 std::string tmp_out = ofilename + ".tmp" + out_ext;
12427 bool is_mp4_like = (out_ext == ".mp4" || out_ext == ".MP4" || out_ext == ".mov" || out_ext == ".MOV" || out_ext == ".m4v" || out_ext == ".M4V");
12428 int64_t fc = writer.get_frame_count();
12429 if (fc <= 0) {
12430 mx::system_err << "acmx2: no encoded video frames; skipping recorded-audio mux for: " << ofilename << "\n";
12431 fflush(stderr);
12432 return;
12433 }
12434 double video_duration = (fps > 0.0 && fc > 0) ? static_cast<double>(fc) / fps : 0.0;
12435 // Sequential video output may need a final duration correction when
12436 // the webcam delivered fewer frames than its configured FPS. The
12437 // normal live webcam path already carries wall-clock PTS, so rescaling
12438 // that stream would destroy its synchronization; only trim its audio
12439 // to the timestamped video duration.
12440 const double audio_duration = recorder.duration_seconds();
12441 double itsscale = 1.0;
12442 const bool timestamped_webcam = filename.empty() && graphic.empty() && !no_drop_mode && fps > 0.0;
12443 if (!timestamped_webcam && video_duration > 0.0 && audio_duration > 0.0) {
12444 const double s = audio_duration / video_duration;
12445 if (s >= 0.5 && s <= 2.0) {
12446 itsscale = s;
12447 }
12448 }
12449 const bool apply_itsscale = std::abs(itsscale - 1.0) > 0.001;
12450 std::ostringstream cmd;
12451 cmd << "ffmpeg -y";
12452 if (apply_itsscale) {
12453 cmd << " -itsscale " << std::fixed << std::setprecision(6) << itsscale;
12454 }
12455 cmd << " -i \"" << ofilename << "\" -i \"" << audio_record_file << "\" -map 0:v:0? -map 1:a:0?"
12456 << " -c:v copy -c:a aac -b:a 192k";
12457 if (!apply_itsscale && video_duration > 0.0) {
12458 // Without itsscale the original video duration is correct,
12459 // so cap audio to it to avoid trailing audio past picture.
12460 cmd << " -t " << std::fixed << std::setprecision(3) << video_duration;
12461 }
12462 if (is_mp4_like) {
12463 cmd << " -movflags +faststart";
12464 }
12465 cmd << " \"" << tmp_out << "\" 2>&1";
12466 mx::system_out << "acmx2: muxing recorded audio into video";
12467 if (apply_itsscale) {
12468 mx::system_out << " (A/V resync itsscale=" << std::fixed << std::setprecision(4) << itsscale << ", video=" << std::setprecision(3) << video_duration << "s, audio=" << audio_duration << "s)";
12469 }
12470 mx::system_out << "...\n";
12471 fflush(stdout);
12472 int ret = std::system(cmd.str().c_str());
12473 if (ret == 0) {
12474 std::remove(ofilename.c_str());
12475 std::rename(tmp_out.c_str(), ofilename.c_str());
12476 mx::system_out << "acmx2: muxed recorded audio from: " << audio_record_file << " to " << ofilename << "\n";
12477 if (std::remove(audio_record_file.c_str()) == 0) {
12478 mx::system_out << "acmx2: removed temporary recorded audio: " << audio_record_file << "\n";
12479 } else {
12480 mx::system_err << "acmx2: warning could not remove recorded audio file: " << audio_record_file << "\n";
12481 }
12482 } else {
12483 mx::system_err << "acmx2: ffmpeg mux failed (exit code " << ret << ")\n";
12484 std::remove(tmp_out.c_str());
12485 }
12486 fflush(stdout);
12487 fflush(stderr);
12488#endif
12489 }
12490
12491 /**
12492 * @brief Run ffmpeg synchronously to mux the audio file into the output video.
12493 *
12494 * Copies the video stream from the output file and encodes the audio
12495 * file track as AAC 192 kbps. Output is always limited to the shorter
12496 * stream. The video timeline is also capped to the original source-video
12497 * duration when that duration is known, preventing a longer audio file or
12498 * repeated/queued tail frame from extending the result.
12499 * The result is written to a temporary file which replaces the
12500 * original on success.
12501 */
12503#ifdef AUDIO_ENABLED
12504 if (!file_audio_mode || audio_file_path.empty() || ofilename.empty())
12505 return;
12506 std::string out_ext = std::filesystem::path(ofilename).extension().string();
12507 if (out_ext.empty())
12508 out_ext = ".mp4";
12509 std::string tmp_out = ofilename + ".tmp" + out_ext;
12510 bool is_mp4_like = (out_ext == ".mp4" || out_ext == ".MP4" || out_ext == ".mov" || out_ext == ".MOV" || out_ext == ".m4v" || out_ext == ".M4V");
12511 int64_t fc = writer.get_frame_count();
12512 if (fc <= 0) {
12513 mx::system_err << "acmx2: no encoded video frames; skipping file-audio mux for: " << ofilename << "\n";
12514 fflush(stderr);
12515 return;
12516 }
12517 double video_duration = (fps > 0.0 && fc > 0) ? static_cast<double>(fc) / fps : 0.0;
12518 double mux_duration = video_duration;
12519 if (!filename.empty() && fps > 0.0 && totalFrames > 0.0) {
12520 const double source_video_duration = totalFrames / fps;
12521 mux_duration = mux_duration > 0.0 ? std::min(mux_duration, source_video_duration) : source_video_duration;
12522 }
12523 std::ostringstream cmd;
12524 cmd << "ffmpeg -y -i \"" << ofilename << "\"";
12525 if (audio_repeat_mode) {
12526 cmd << " -stream_loop -1";
12527 }
12528 const std::vector<std::string> audioSources = file_audio_source_paths();
12529 std::filesystem::path concatPath;
12530 if (audioSources.size() > 1) {
12531 concatPath = std::filesystem::temp_directory_path() / ("acmx2-audio-" + std::to_string(std::chrono::steady_clock::now().time_since_epoch().count()) + ".ffconcat");
12532 std::ofstream concatFile(concatPath);
12533 concatFile << "ffconcat version 1.0\n";
12534 for (const std::string &source : audioSources) {
12535 std::string escapedSource;
12536 for (char value : source) {
12537 if (value == '\'')
12538 escapedSource += "'\\''";
12539 else
12540 escapedSource += value;
12541 }
12542 concatFile << "file '" << escapedSource << "'\n";
12543 }
12544 concatFile.close();
12545 cmd << " -f concat -safe 0 -i \"" << concatPath.string() << "\"";
12546 } else {
12547 const std::string audioSource = audioSources.empty() ? audio_file_path : audioSources.front();
12548 cmd << " -i \"" << audioSource << "\"";
12549 }
12550 cmd << " -map 0:v:0? -map 1:a:0?"
12551 << " -c:v copy -c:a aac -b:a 192k";
12552 if (mux_duration > 0.0) {
12553 cmd << " -t " << std::fixed << std::setprecision(6) << mux_duration;
12554 }
12555 cmd << " -shortest";
12556 if (is_mp4_like) {
12557 cmd << " -movflags +faststart";
12558 }
12559 cmd << " \"" << tmp_out << "\" 2>&1";
12560 mx::system_out << "acmx2: muxing audio file into video" << (audio_repeat_mode ? " (repeating)" : "") << " (shortest stream" << (mux_duration > 0.0 ? ", max " + std::to_string(mux_duration) + "s" : std::string()) << ")...\n";
12561 fflush(stdout);
12562 int ret = std::system(cmd.str().c_str());
12563 if (!concatPath.empty()) {
12564 std::error_code removeError;
12565 std::filesystem::remove(concatPath, removeError);
12566 }
12567 if (ret == 0) {
12568 std::remove(ofilename.c_str());
12569 std::rename(tmp_out.c_str(), ofilename.c_str());
12570 mx::system_out << "acmx2: muxed audio from: " << audio_file_path << " to " << ofilename << "\n";
12571 } else {
12572 mx::system_err << "acmx2: ffmpeg audio file mux failed (exit code " << ret << ")\n";
12573 std::remove(tmp_out.c_str());
12574 }
12575 fflush(stdout);
12576 fflush(stderr);
12577#endif
12578 }
12579
12580 /**
12581 * @brief Launch the asynchronous audio-mux thread and show a progress overlay.
12582 *
12583 * Signals both capture and writer threads to stop, then spawns
12584 * a new `muxThread` that:
12585 * 1. Joins the capture and writer threads.
12586 * 2. Closes the Writer (flushes the MP4 trailer).
12587 * 3. Calls runMuxSync() to invoke ffmpeg.
12588 * 4. Sets `muxComplete = true`.
12589 *
12590 * While `isMuxing` is true, draw() renders a "Muxing audio…"
12591 * overlay and skips normal frame processing. When muxComplete
12592 * becomes true, draw() joins the mux thread and quits the window.
12593 *
12594 * @param win GL window for the overlay display.
12595 */
12596 void beginMuxing(gl::GLWindow *win) {
12597 captureRunning = false;
12598 writerRunning = false;
12599#ifdef AUDIO_ENABLED
12600 // End live audio at the video capture boundary. Draining queued
12601 // frames and encoder packets must not add an audio-only tail.
12604 }
12605#endif
12606 queueCondVar.notify_all();
12607 captureQueueCondVar.notify_all();
12608 isMuxing = true;
12609 muxComplete = false;
12610 muxThread = std::thread([this]() {
12611 const bool shouldTransferAudio = !png_video_mode && !filename.empty() && !repeat && copy_audio;
12612#ifdef AUDIO_ENABLED
12613 const bool shouldRecordedMux = !png_video_mode && audio_is_enabled && !file_audio_mode && !audio_record_file.empty() && (audio_engine.recorder().is_recording() || std::filesystem::exists(audio_record_file));
12614 const bool shouldFileAudioMux = !png_video_mode && file_audio_mode && !audio_file_path.empty() && !audio_record_file.empty() && !ofilename.empty();
12615#else
12616 const bool shouldRecordedMux = false;
12617 const bool shouldFileAudioMux = false;
12618#endif
12619
12620 if (captureThread.joinable())
12621 captureThread.join();
12622 if (writerThread.joinable())
12623 writerThread.join();
12624 if (writer.is_open()) {
12625 writer.close();
12626 int64_t fc = writer.get_frame_count();
12627 double ts = (fps > 0.0) ? static_cast<double>(fc) / fps : 0.0;
12628 mx::system_out << "acmx2: wrote " << fc << " frames (" << static_cast<int>(ts / 3600) << ":" << static_cast<int>(ts / 60) % 60 << ":" << static_cast<int>(ts) % 60 << ") to file: " << ofilename << "\n";
12629 fflush(stdout);
12630 } else if (png_video_mode) {
12631 mx::system_out << "acmx2: wrote " << png_video_frame_counter.load() << " PNG frames to directory: " << png_video_dir << "\n";
12632 fflush(stdout);
12633 }
12634 if (shouldTransferAudio) {
12636 mx::system_out << "acmx2: copied audio track from: " << filename << " to " << ofilename << "\n";
12637 }
12638 if (shouldRecordedMux)
12639 runMuxSync();
12640 if (shouldFileAudioMux)
12642 muxComplete = true;
12643 });
12644 }
12645
12646 /**
12647 * @brief Stop the writer thread, close the Writer, and handle audio transfer.
12648 *
12649 * Sets `writerRunning = false`, wakes the condition variable,
12650 * joins the writer thread, then closes the Writer (which flushes
12651 * the MP4 container trailer). Logs the total duration and frame
12652 * count. If `copy_audio` is set and a video file was the input,
12653 * the audio track is copied from the input to the output via
12654 * `transfer_audio()`. Any active audio recording is stopped.
12655 */
12657 bool recording = writer.is_open();
12658 writerRunning = false;
12659#ifdef AUDIO_ENABLED
12662 }
12663#endif
12664 queueCondVar.notify_all();
12665 if (writerThread.joinable()) {
12666 writerThread.join();
12667 }
12668 if (recording) {
12669 writer.close();
12670 int64_t final_frame_count = writer.get_frame_count();
12671 double total_secs = (fps > 0.0) ? static_cast<double>(final_frame_count) / fps : 0.0;
12672 uint64_t hours = 0, minutes = 0, seconds = 0;
12673 hours = static_cast<uint64_t>(total_secs / 3600);
12674 minutes = static_cast<uint64_t>(total_secs / 60) % 60;
12675 seconds = static_cast<uint64_t>(total_secs) % 60;
12676 std::ostringstream timerStr;
12677 timerStr << std::setfill('0') << std::setw(2) << hours << ":" << std::setfill('0') << std::setw(2) << minutes << ":" << std::setfill('0') << std::setw(2) << seconds;
12678
12679 mx::system_out << "acmx2: " << " wrote " << timerStr.str() << " (" << final_frame_count << " frames) to file: " << ofilename << "\n";
12680 if (!skip_audio_mux_on_exit.load() && !filename.empty() && repeat == false && copy_audio) {
12682 mx::system_out << "acmx2: copied audio track from: " << filename << " to " << ofilename << "\n";
12683 }
12684 fflush(stdout);
12685 fflush(stderr);
12686 } else if (png_video_mode) {
12687 mx::system_out << "acmx2: wrote " << png_video_frame_counter.load() << " PNG frames to directory: " << png_video_dir << "\n";
12688 fflush(stdout);
12689 }
12690 if (generate_mode) {
12691 mx::system_out << "acmx2: --generate: saved " << generate_saved_counter.load() << " PNG frames to directory: " << generate_dir << "\n";
12692 fflush(stdout);
12693 }
12694 }
12695};
12696
12697/**
12698 * @class MainWindow
12699 * @brief Top-level SDL2/OpenGL window that hosts the ACView rendering object.
12700 *
12701 * Supports both visible and headless (silent) modes. In silent mode an
12702 * off-screen GL context is used for batch video processing without a window.
12703 */
12704class MainWindow : public gl::GLWindow {
12705 bool silent_mode = false;
12706
12707 static int eventFilter(void *userdata, SDL_Event *event) {
12708 if (event->type == SDL_QUIT || (event->type == SDL_KEYDOWN && event->key.keysym.sym == SDLK_ESCAPE)) {
12709 auto *win = static_cast<MainWindow *>(userdata);
12710 auto *view = static_cast<ACView *>(win->object.get());
12711 if (view && view->needsAsyncShutdown()) {
12712 view->requestStop();
12713 return 0;
12714 }
12715 }
12716 return 1;
12717 }
12718
12719 void initCommon(const MXArguments &args) {
12721 util.path = args.path;
12722
12723 if (!silent_mode) {
12724 SDL_Surface *ico = png::LoadPNG(util.getFilePath("data/win-icon.png").c_str());
12725 if (!ico) {
12726 throw mx::Exception("Could not load icon: " + util.getFilePath("data/win-icon.png"));
12727 }
12728 setWindowIcon(ico);
12729 SDL_FreeSurface(ico);
12730 }
12731
12732 setObject(new ACView(args));
12733 object->load(this);
12734 SDL_SetEventFilter(eventFilter, this);
12735 fflush(stdout);
12736 fflush(stderr);
12737 }
12738
12739 public:
12740 /**
12741 * @brief Construct a visible (windowed) MainWindow.
12742 *
12743 * Creates an SDL2/OpenGL window of the requested size, then
12744 * calls initCommon() to set up the rendering pipeline.
12745 *
12746 * @param args Parsed CLI arguments (resolution, asset path, etc.).
12747 * @param context_config Requested OpenGL context version.
12748 */
12749 MainWindow(const MXArguments &args, const OpenGLContextConfig &context_config) : gl::GLWindow("ACMX2", args.tw, args.th, false, gl::GLMode::DESKTOP, context_config.major, context_config.minor, args.fps_value <= 60.0), silent_mode(args.silent) { initCommon(args); }
12750
12751 /**
12752 * @brief Construct a headless (off-screen) MainWindow for silent batch processing.
12753 *
12754 * Uses gl::GLMode::DESKTOP to create an OpenGL context without a
12755 * visible window. Intended for `--silent` mode where video is
12756 * processed and recorded without any display.
12757 *
12758 * @param args Parsed CLI arguments.
12759 * @param context_config Requested OpenGL context version.
12760 * @param headless Unused disambiguator parameter.
12761 */
12762 MainWindow(const MXArguments &args, const OpenGLContextConfig &context_config, bool headless) : gl::GLWindow("ACMX2", args.tw, args.th, false, gl::GLMode::DESKTOP, context_config.major, context_config.minor, false), silent_mode(true) {
12763 static_cast<void>(headless);
12764 SDL_HideWindow(getWindow());
12765 initCommon(args);
12766 }
12767
12768 ~MainWindow() override { SDL_SetEventFilter(nullptr, nullptr); }
12769
12770 /**
12771 * @brief Per-frame callback: clear, draw ACView, swap buffers.
12772 *
12773 * Called by the GLWindow event loop. Clears the default framebuffer,
12774 * asks the ACView object to render one frame, then swaps and delays
12775 * to maintain the target frame rate.
12776 */
12777 void draw() override {
12778 glClearColor(0.f, 0.f, 0.0f, 1.0f);
12779 glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
12780 glViewport(0, 0, w, h);
12781#if defined(__linux__) || defined(__APPLE__) || defined(_WIN32)
12782 if (silent_mode && g_shutdown_requested.load(std::memory_order_relaxed)) {
12783 auto *view = static_cast<ACView *>(object.get());
12784 if (view) {
12785 static std::atomic<bool> reported{false};
12786 if (!reported.exchange(true)) {
12787 mx::system_out << "\nacmx2: Ctrl+C received - finishing current frame and "
12788 "flushing output file...\n";
12789 mx::system_out.flush();
12790 view->requestStopNoMux();
12791 }
12792 }
12793 this->quit();
12794 }
12795#endif
12796 object->draw(this);
12797 swap();
12798 // In headless/silent batch mode there is no user watching: skip the
12799 // frame-pacing delay() so processing runs at full GPU/encoder speed
12800 // instead of being throttled to the target playback FPS.
12801 if (!silent_mode) {
12802 delay();
12803 }
12804 }
12805
12806 /// @brief Placeholder—SDL events are forwarded to ACView::event() by libmx2.
12807 void event(SDL_Event &e) override {}
12808};
12809
12810/// @brief Verify CUDA device availability and print GPU info.
12811void checkDevices(bool list_only = false) {
12812#ifdef ACMX2_WITH_CUDA
12813 int device_count = 0;
12814 cudaError_t error = cudaGetDeviceCount(&device_count);
12815 if (error != cudaSuccess || device_count == 0) {
12816 std::cerr << "OpenCV Cuda Support not found." << std::endl;
12817 std::cerr << "Reason: " << cudaGetErrorString(error) << std::endl;
12818 std::cerr << "Check: Are NVIDIA drivers installed? Is the GPU seated?" << std::endl;
12819 exit(EXIT_FAILURE);
12820 } else {
12821 std::cout << "🚀 GPU Acceleration Active: " << device_count << " device(s) found." << std::endl;
12822 if (list_only) {
12823 for (int i = 0; i < device_count; ++i) {
12824 cudaSetDevice(i);
12825 cv::cuda::printShortCudaDeviceInfo(i);
12826 }
12827 } else {
12828 cv::cuda::printShortCudaDeviceInfo(cv::cuda::getDevice());
12829 }
12830 }
12831#else
12832 static_cast<void>(list_only);
12833 std::cerr << "acmx2: CUDA support was disabled at build time (ACMX2_WITH_CUDA not defined).\n";
12834#endif
12835}
12836
12837namespace {
12838 struct CliColors {
12839 bool enabled = false;
12840 std::string_view reset = "";
12841 std::string_view title = "";
12842 std::string_view section = "";
12843 std::string_view flag = "";
12844 std::string_view arg = "";
12845 std::string_view desc = "";
12846 std::string_view example = "";
12847 };
12848
12849 struct HelpEntry {
12850 std::string_view flags;
12851 std::string_view description;
12852 std::string_view example;
12853 };
12854
12856 const char *no_color = std::getenv("NO_COLOR");
12857 if (no_color && no_color[0] != '\0') {
12858 return false;
12859 }
12860
12861 const char *force = std::getenv("CLICOLOR_FORCE");
12862 if (force && force[0] != '0' && force[0] != '\0') {
12863 return true;
12864 }
12865
12866#if defined(__linux__) || defined(__APPLE__)
12867 if (!isatty(fileno(stdout))) {
12868 return false;
12869 }
12870#else
12871 return false;
12872#endif
12873
12874 const char *term = std::getenv("TERM");
12875 if (!term || std::strcmp(term, "dumb") == 0) {
12876 return false;
12877 }
12878 return true;
12879 }
12880
12882 CliColors c;
12884 if (c.enabled) {
12885 c.reset = "\033[0m";
12886 c.title = "\033[1;96m";
12887 c.section = "\033[1;93m";
12888 c.flag = "\033[1;92m";
12889 c.arg = "\033[36m";
12890 c.desc = "\033[0;97m";
12891 c.example = "\033[95m";
12892 }
12893 return c;
12894 }
12895
12896 template <typename Stream> void printSection(Stream &out, const CliColors &c, std::string_view name, const std::vector<HelpEntry> &entries) {
12897 out << c.section << "\n" << name << c.reset << "\n";
12898 for (const auto &entry : entries) {
12899 out << " " << c.flag << entry.flags << c.reset << "\n";
12900 out << " " << c.desc << entry.description << c.reset << "\n";
12901 if (!entry.example.empty()) {
12902 out << " " << c.example << "example: " << entry.example << c.reset << "\n";
12903 }
12904 }
12905 }
12906
12907 template <typename Stream> void printDetailedArguments(Stream &out) {
12908 const CliColors c = makeCliColors();
12909 out << c.title << "\nArguments" << c.reset << "\n";
12910 out << c.example << "Short and long forms are equivalent; values shown in <> are required." << c.reset << "\n";
12911
12912 printSection(out, c, "General", {{"-v, -h, --help, --version", "Show this information screen and keyboard controls.", "acmx2 --help"}, {"-p <path>, --path <path>", "Set assets root directory (shaders, data files, defaults).", "acmx2 --path ./data"}, {"-r <WxH>, --resolution <WxH>", "Set output/window resolution (for display and recording).", "acmx2 --resolution 1920x1080"}, {"-N, --fullscreen", "Start in fullscreen mode (Escape to exit fullscreen).", "acmx2 --fullscreen"}, {"--silent", "Run video or graphics-file rendering headlessly (no preview window).", "acmx2 -g image.png -o out.mp4 --duration 10 --silent"}, {"--duration <seconds>", "Auto-stop recording/output after elapsed seconds.", "acmx2 -i in.mp4 -o out.mp4 --duration 30"}, {"--max-size <MB>", "Auto-stop when output file size exceeds MB.", "acmx2 -i in.mp4 -o out.mp4 --max-size 500.0"}});
12913
12914 printSection(out, c, "Input Source", {{"-i <file>, --input <file>", "Input video file.", "acmx2 --input clip.mp4"}, {"-g <file>, --graphic <file>", "Input still image instead of camera/video.", "acmx2 --graphic frame.png"}, {"-d <idx>, --device <idx>", "Camera device index to open.", "acmx2 --device 0"}, {"-c <WxH>, --camera-res <WxH>", "Request camera capture resolution.", "acmx2 --camera-res 1280x720"}, {"--enumerate-device <idx>", "Print camera resolutions/formats supported by device and exit.", "acmx2 --enumerate-device 0"}, {"--use-yuv", "Prefer YUYV camera capture over MJPG for compatible devices.", "acmx2 --device 0 --use-yuv"}});
12915
12916 printSection(out,
12917 c,
12918 "Shaders And Visual Pipeline",
12919 {{"-s <library-dir>, --shaders <library-dir>", "Use a shader library directory (library.json preferred, index.txt fallback).", "acmx2 --shaders ./shaders"},
12920 {"-f <frag.glsl>, --fragment <frag.glsl>", "Use a single fragment shader file directly.", "acmx2 --fragment ./shaders/wave.glsl"},
12921 {"--shader <index>", "Select initial shader index from the active library.", "acmx2 --shaders ./shaders --shader 3"},
12922 {"--shader-pass <list>", "Run multiple shader indices per frame (comma-separated).", "acmx2 --shader-pass 0,4,7"},
12923 {"--playlist <file>", "Load shader playlist text file (one shader name per line).", "acmx2 --playlist live_set.txt"},
12924 {"--cross-fade <seconds>", "Set smooth transition time between playlist shader switches.", "acmx2 --playlist live_set.txt --cross-fade 1.25"},
12925 {"--autopilot-frames <N>", "Auto-switch to random playlist shader every N rendered frames (minimum 4).", "acmx2 --shaders ./shaders --autopilot-frames 240"},
12926 {"--autopilot-timeout <N>", "Alias for --autopilot-frames (minimum 4).", "acmx2 --shaders ./shaders --autopilot-timeout 240"},
12927 {"--autopilot-random <N>", "Use random autopilot interval 4..N frames for each J/Y autoplay switch.", "acmx2 --shaders ./shaders --autopilot-random 300"},
12928 {"--time-speed <mult>", "Scale shader time uniform speed (1.0 = normal).", "acmx2 --time-speed 0.5"},
12929 {"--normalized", "Advance time_f by a fixed output-frame interval instead of wall time.", "acmx2 --normalized --time-speed 0.5"},
12930 {"--build <library-path>", "Compile shader library into cache, then exit.", "acmx2 --build ./shaders"},
12931 {"--remove-broken <library-path>", "Compile-check each shader and remove failing manifest entries, then exit.", "acmx2 --remove-broken ./shaders"},
12932 {"--no-cache", "Disable shader binary cache and always compile at startup.", "acmx2 --no-cache"},
12933 {"--texture-cache", "Enable texture/frame cache for cache-aware shader effects.", "acmx2 --texture-cache"},
12934 {"--cache-delay <frames>", "Delay frame cache feed by N frames for temporal effects.", "acmx2 --texture-cache --cache-delay 6"},
12935 {"--texture-cache-size <N>", "Set texture cache ring buffer size (1-64, default 8).", "acmx2 --texture-cache --texture-cache-size 16"},
12936 {"--enable-3d", "Enable 3D object rendering pipeline.", "acmx2 --enable-3d"},
12937 {"--model <file>", "Load a custom 3D model file for the 3D scene.", "acmx2 --enable-3d --model scene.obj"},
12938 {"--flip", "Flip final output vertically before display/encode.", "acmx2 --flip"},
12939 {"--rotate <mode>", "Rotate input frames clockwise, 180 degrees, or counterclockwise.", "acmx2 --rotate clockwise"}});
12940
12941 printSection(out, c, "Texture Array Cache", {{"--texture-cache-array", "Store frame history in one sampler2DArray named history.", "acmx2 --texture-cache-array"}});
12942
12943 printSection(out,
12944 c,
12945 "DNN And ONNX Models",
12946 {{"--human <file>", "Load ONNX human segmentation model (e.g., pphumanseg .onnx) to isolate foreground person.", "acmx2 --human human_seg.onnx -i input.mp4 -o output.mp4"},
12947 {"--background", "When --human is used, apply shaders only to background; composite person on top.", "acmx2 --human model.onnx --background"},
12948 {"--black <threshold>", "Set mask black point / shadow crush threshold for color/segmentation masks (default: 0.35).", "acmx2 --human seg.onnx --black 0.25"},
12949 {"--white <threshold>", "Set mask white point / opacity saturation threshold for color/segmentation masks (default: 0.75).", "acmx2 --human seg.onnx --white 0.85"},
12950 {"--edge <file>", "Load ONNX edge detection model (e.g., Dexined .onnx) to replace frame with edge map.", "acmx2 --edge edges.onnx -i video.mp4 -o edges.mp4"},
12951 {"--onnx <file>", "Load generic ONNX model from YAML config file; replaces frame with model output.", "acmx2 --onnx bubble.yaml -i input.mp4 -o output.mp4"},
12952 {"--check-dnn", "Report whether this build has OpenCV DNN support enabled.", "acmx2 --check-dnn"}});
12953
12954 printSection(out, c, "GPU And CUDA", {{"--gpu-filter <list>", "Apply CUDA filter chain by index list (comma-separated).", "acmx2 --gpu-filter 1,12,18"}, {"--gpu-buffer <N>", "Set GPU temporal frame buffer size (4..32).", "acmx2 --gpu-buffer 12"}, {"--list-filters", "List all built-in GPU filters and their indices.", "acmx2 --list-filters"}, {"-m <idx>, --cuda-device <idx>", "Select CUDA device index to run processing on.", "acmx2 --cuda-device 0"}, {"--list-cuda-devices", "List CUDA devices visible to the runtime.", "acmx2 --list-cuda-devices"}, {"--check-cuda", "Report whether this build has CUDA support enabled.", "acmx2 --check-cuda"}});
12955
12956 printSection(out,
12957 c,
12958 "Recording And Encoding",
12959 {{"-o <file>, --output <file>", "Write processed video to output file.", "acmx2 -i in.mp4 -o out.mp4"},
12960 {"--png", "Video file mode: write output as PNG frame sequence in an output subdirectory.", "acmx2 -i in.mp4 -o out.mp4 --png"},
12961 {"--generate <N>", "Save a PNG frame every N frames in an output subdirectory (video or camera mode).", "acmx2 -i in.mp4 --generate 30"},
12962 {"-e <prefix>, --prefix <prefix>", "Snapshot filename prefix for captured frames.", "acmx2 --prefix snap/frame_"},
12963 {"-u <fps>, --fps <fps>", "Set output frame rate for recording.", "acmx2 --fps 60"},
12964 {"-b <crf>, --bitrate <crf>", "Legacy CRF quality option for encoder.", "acmx2 --bitrate 20"},
12965 {"--encode-preset <name>", "Encoder speed/quality preset (ultrafast .. veryslow).", "acmx2 --encode-preset fast"},
12966 {"--encode-tune <name>", "Tune encoder for content type or low latency.", "acmx2 --encode-tune film"},
12967 {"--encode-crf <0-51>", "Set encoder quality directly (lower = better quality/larger file).", "acmx2 --encode-crf 18"},
12968 {"--encode-codec <name>", "Select auto/software/nvenc or an exact installed FFmpeg encoder.", "acmx2 --encode-codec libx265"},
12969 {"--list-encoders", "List FFmpeg video encoders visible to MXWrite and exit.", "acmx2 --list-encoders"},
12970 {"--list-encoder-options <name>", "List AVOptions accepted by one exact encoder and exit.", "acmx2 --list-encoder-options libx265"},
12971 {"--encode-realtime", "Enable low-latency encoder settings for live pipelines.", "acmx2 --encode-realtime"},
12972 {"--no-drop", "File/graphics mode: never drop frames; ignored for webcams.", "acmx2 -i in.mp4 -o out.mp4 --no-drop"},
12973 {"--display-filter", "Show current shader/stack and GPU filter in upper-left corner.", "acmx2 --display-filter"},
12974 {"--use-watermark <text>", "Enable watermark with given text in recorded videos (upper-left).", "acmx2 --use-watermark \"My Channel\""},
12975 {"--use-watermark-color <r,g,b>", "Watermark text color as 0-255 components.", "acmx2 --use-watermark-color 255,255,0"},
12976 {"--copy-audio", "Mux input audio track into encoded output when possible.", "acmx2 -i in.mp4 -o out.mp4 --copy-audio"},
12977 {"-a, --repeat", "Loop video input source continuously.", "acmx2 -i loop.mp4 --repeat"}});
12978
12979 printSection(out, c, "Advanced Encoder Parameters", {{"--encode-params <string>", "Pass additional FFmpeg-style video encoder options through MXWrite.", "acmx2 --encode-codec hevc_nvenc --encode-params \"-preset p6 -tune lossless -profile:v rext -pix_fmt yuv444p\""}});
12980
12981#ifdef AUDIO_ENABLED
12982 printSection(out,
12983 c,
12984 "Audio Reactivity",
12985 {{"-w, --enable-audio", "Enable audio-reactive shader modulation.", "acmx2 --enable-audio"},
12986 {"-l <N>, --channels <N>", "Number of audio channels to capture/process.", "acmx2 --channels 2"},
12987 {"-q <value>, --sense <value>", "Set audio sensitivity multiplier for visual response.", "acmx2 --sense 1.4"},
12988 {"--audio-warm-rate <value>", "Startup audio warmup rate in 1/sec (0.5 ~= 2s fade-in, 1.0 ~= 1s, 0 disables warmup).", "acmx2 --enable-audio --audio-warm-rate 0.35"},
12989 {"-y, --pass-through", "Play live input or file audio through the selected output device.", "acmx2 --audio-file soundtrack.mp3 --pass-through"},
12990 {"--audio-input <device>", "Select input audio device name/id.", "acmx2 --audio-input \"USB Audio\""},
12991 {"--audio-output <device>", "Select pass-through output device name/id.", "acmx2 --audio-output \"Built-in Output\""},
12992 {"--list-devices", "List available audio input/output devices.", "acmx2 --list-devices"},
12993 {"--record-audio <wav-file>", "Record captured audio stream to a WAV file.", "acmx2 --record-audio take.wav"},
12994 {"--record-gain <0.0-2.0>", "Set recording gain multiplier (1.0 = unity).", "acmx2 --record-gain 1.2"},
12995 {"--audio-file <file>", "Use an audio file or M3U playlist as reactivity source instead of microphone input.", "acmx2 --audio-file soundtrack.m3u"},
12996 {"--audio-trunc", "Stop playback/output when the audio source reaches EOF.", "acmx2 --audio-file soundtrack.m3u --audio-trunc"},
12997 {"--audio-repeat", "Restart file audio or the full playlist at EOF.", "acmx2 --audio-file soundtrack.m3u --audio-repeat"},
12998 {"--enable-audio-buffers <N>", "Allocate one sampler1DArray with N spectrum-history layers (GPU-limited).", "acmx2 --enable-audio --enable-audio-buffers 8"},
12999 {"--check-audio", "Report whether this build has audio support enabled.", "acmx2 --check-audio"}});
13000#endif
13001
13002#ifdef MIDI_ENABLED
13003 printSection(out, c, "MIDI Control", {{"--midi-map <file>", "Load MIDI mapping configuration file.", "acmx2 --midi-map midi.midi_cfg"}, {"--midi-device <idx>", "Select MIDI input device index.", "acmx2 --midi-device 0"}, {"--list-midi", "List available MIDI input devices.", "acmx2 --list-midi"}, {"--check-midi", "Report whether this build has MIDI support enabled.", "acmx2 --check-midi"}});
13004#endif
13005
13006 printSection(out, c, "Runtime Overlay", {{"--disable-counter", "Hide timer and FPS overlay text.", "acmx2 --disable-counter"}});
13007 }
13008
13009 template <typename Stream> void printKeyboardControls(Stream &out) {
13010 const CliColors c = makeCliColors();
13011 out << c.title << "\nKeyboard Controls" << c.reset << "\n";
13012
13013 printSection(out,
13014 c,
13015 "Main",
13016 {{"Escape", "Quit.", ""},
13017 {"Ctrl+X", "Quit without audio mux.", ""},
13018 {"Up Arrow", "Crossfade to the previous shader (or playlist entry in playlist/autopilot mode).", ""},
13019 {"Down Arrow", "Crossfade to the next shader (or playlist entry in playlist/autopilot mode).", ""},
13020 {"Shift+Up Arrow", "In playlist/autopilot mode: change post-multipass shader backward.", ""},
13021 {"Shift+Down Arrow", "In playlist/autopilot mode: change post-multipass shader forward.", ""},
13022 {"Left Arrow", "Previous GPU filter (if enabled).", ""},
13023 {"Right Arrow", "Next GPU filter (if enabled).", ""},
13024 {"Space", "Enable/disable processing.", ""},
13025 {"L", "Toggle video freeze (Video/Image modes).", ""},
13026 {"P", "Toggle pause (Video/Image) or toggle shader playlist.", ""},
13027 {"J", "Toggle autopilot mode (requires playlist).", ""},
13028 {"Y", "Toggle sequential autopilot (cycles playlist in order, requires playlist).", ""},
13029 {"N", "Toggle random crossfade selection for autopilot shader switches.", ""},
13030 {"T", "Enable/disable time.", ""},
13031 {"U / I", "Step time when time is disabled.", ""},
13032 {"Page Up / Page Down", "Increase/decrease time speed.", ""},
13033 {"M", "Toggle multi-pass / multi-shader pass.", ""},
13034 {"F", "Toggle fullscreen.", ""},
13035 {"Q", "Toggle reactive time (if AUDIO_ENABLED).", ""},
13036 {"Insert", "Increase audio sensitivity.", ""},
13037 {"Delete", "Decrease audio sensitivity.", ""},
13038 {"End", "Toggle spectrum sensitivity scaling.", ""},
13039 {"Home", "Toggle audio delta time scaling.", ""},
13040 {"3", "Toggle 2D/3D mode.", ""}});
13041
13042 printSection(out, c, "Snapshots", {{"Z", "Save PNG snapshot (SDR 8-bit; HDR mode still outputs SDR PNG).", ""}, {"4", "Save TIFF snapshot (SDR: 8-bit RGBA; HDR: 16-bit RGBA; requires ACMX2_WITH_TIFF).", ""}, {"5", "Save lossless WebP snapshot (HDR is tone-mapped; requires ACMX2_WITH_WEBP).", ""}, {"6", "Save raw RGBA snapshot (HDR: 16-bit RGBA, otherwise 8-bit RGBA).", "ffplay -f rawvideo -pixel_format rgba64le -video_size WxH file.raw"}});
13043
13044 printSection(out, c, "3D Mode", {{"W / A / S / D", "Look around.", ""}, {"V", "Toggle view rotation.", ""}, {"O", "Toggle oscillation.", ""}, {"X", "Reset camera distance.", ""}, {"+ / -", "Increase/decrease camera distance.", ""}, {"B", "Increase movement speed.", ""}, {"N (held in 3D)", "Decrease movement speed.", ""}, {"C", "Toggle object wave.", ""}, {"E", "Enable/disable watermark.", ""}, {"]", "Increase model scale.", ""}, {"[", "Decrease model scale.", ""}, {". (period)", "Increase camera rotation speed.", ""}, {", (comma)", "Decrease camera rotation speed.", ""}});
13045 printSection(out, c, "Environment Variables", {{"ACMX2_PATH", "Default assets root directory (equivalent to --path). Used when --path is not specified.", "export ACMX2_PATH=/path/to/share/acmx2"}, {"ACMX2_SHADER_PATH", "Default shader library index file or directory (equivalent to --shaders). Used when neither --shaders nor --fragment is specified.", "export ACMX2_SHADER_PATH=/path/to/shaders"}});
13046 }
13047} // namespace
13048
13049/// @brief Print the program name, software brand, and project URL once.
13051 mx::system_out << PROGRAM_NAME << " " << VERSION_INFO << "\n";
13052 mx::system_out << "(C) 2026 " << VERSION_AUTHOR << "\n";
13053 mx::system_out << "https://lostsidedead.biz\n";
13054}
13055
13056/// @brief Print program information, arguments, and keyboard controls.
13057void printAbout(bool include_branding = true) {
13058 if (include_branding)
13059 printBranding();
13060 printDetailedArguments(mx::system_out);
13061 printKeyboardControls(mx::system_out);
13062}
13063
13064/**
13065 * @brief Application entry point.
13066 *
13067 * Parses command-line arguments with Argz, validates inputs, then either
13068 * builds a shader cache and exits or launches the main render loop.
13069 *
13070 * @param argc Argument count.
13071 * @param argv Argument vector.
13072 * @return EXIT_SUCCESS on clean exit, EXIT_FAILURE on error.
13073 */
13074int main(int argc, char **argv) {
13075 fflush(stdout);
13076 Argz<std::string> parser(argc, argv);
13077 parser.addOptionSingle('v', "Display help message")
13078 .addOptionSingle('h', "Display help message")
13079 .addOptionSingleValue('p', "assets path")
13080 .addOptionDoubleValue('P', "path", "assets path")
13081 .addOptionSingleValue('r', "Resolution WidthxHeight")
13082 .addOptionDoubleValue('R', "resolution", "Resolution WidthxHeight")
13083 .addOptionSingleValue('d', "Camera Device")
13084 .addOptionDoubleValue('D', "device", "Device Index")
13085 .addOptionSingleValue('c', "Camera Resolution")
13086 .addOptionDoubleValue('C', "camera-res", "Camera Resolution")
13087 .addOptionSingleValue('i', "Input file")
13088 .addOptionSingleValue('g', "Input Image")
13089 .addOptionDoubleValue('G', "graphic", "Input graphics file")
13090 .addOptionDoubleValue('I', "input", "Input file")
13091 .addOptionSingleValue('s', "Shader Library Index File")
13092 .addOptionDoubleValue('S', "shaders", "Shader Library Index File")
13093 .addOptionSingleValue('f', "Fragment Shader")
13094 .addOptionDoubleValue('F', "fragment", "Fragment Shader")
13095 .addOptionDoubleValue('H', "shader", "Shader Index")
13096 .addOptionDoubleValue(622, "shader-file", "Shader filename in the active library")
13097 .addOptionSingleValue('e', "Save Prefix")
13098 .addOptionDoubleValue('E', "prefix", "Save Prefix")
13099 .addOptionSingleValue('o', "output file")
13100 .addOptionDoubleValue('O', "output", "output file")
13101 .addOptionSingleValue('b', "Bitrate in CRF")
13102 .addOptionDoubleValue('B', "bitrate", "Bitrate in CRF")
13103 .addOptionSingleValue('u', "frames per second")
13104 .addOptionDoubleValue('U', "fps", "Frames per second")
13105 .addOptionSingle('a', "Repeat")
13106 .addOptionDouble('A', "repeat", "Video repeat")
13107 .addOptionSingle('n', "fullscreen")
13108 .addOptionDouble(256, "texture-cache", "Enable texture cache")
13109 .addOptionDoubleValue(257, "cache-delay", "Cache delay in frames")
13110 .addOptionDoubleValue(275, "texture-cache-size", "Ring buffer size for texture cache (default 8)")
13111 .addOptionDouble(276, "texture-cache-array", "Expose texture cache as sampler2DArray history")
13112 .addOptionDouble(258, "copy-audio", "Copy audio track")
13113 .addOptionDouble(259, "enable-3d", "Enable 3D cube")
13114 .addOptionDoubleValue(260, "model", "Model file")
13115 .addOptionDoubleValue(700, "human", "Human segmentation model (PPHS .onnx) -- isolate person via DNN")
13116 .addOptionDouble(701, "background", "With --human: apply shaders only to the background; composite person on top")
13117 .addOptionDoubleValue(702, "black", "Mask black point / shadow crush threshold (default 0.35)")
13118 .addOptionDoubleValue(703, "white", "Mask white point / opacity saturation threshold (default 0.75)")
13119 .addOptionDoubleValue(704, "edge", "Edge detection model (Dexined .onnx) -- replace frame with edge map")
13120 .addOptionDoubleValue(705, "onnx", "Generic ONNX model YAML config -- replace frame with DNN output")
13121 .addOptionDouble(621, "check-dnn", "Report whether OpenCV DNN support is compiled in")
13122 .addOptionDouble(261, "help", "print help info")
13123 .addOptionDouble(262, "version", "print version and help info")
13124 .addOptionDoubleValue(400, "gpu-filter", "GPU filter indices (comma-separated)")
13125 .addOptionDoubleValue(401, "gpu-buffer", "GPU frame buffer size (4-32)")
13126 .addOptionDouble(402, "list-filters", "List available GPU filters")
13127 .addOptionDouble(403, "disable-counter", "Disable timer and FPS counter overlay")
13128 .addOptionSingleValue('m', "CUDA device index")
13129 .addOptionDoubleValue('M', "cuda-device", "CUDA device index")
13130 .addOptionDouble(404, "list-cuda-devices", "List available CUDA devices")
13131 .addOptionDouble(415, "check-cuda", "Report whether CUDA support is compiled in")
13132#ifdef AUDIO_ENABLED
13133 .addOptionSingle('w', "Enable Audio Reactivity")
13134 .addOptionDouble('W', "enable-audio", "enabled audio reacitivty")
13135 .addOptionSingleValue('l', "Audio channels")
13136 .addOptionDoubleValue('L', "channels", "Audio channels")
13137 .addOptionSingleValue('q', "Audio Sensitivty")
13138 .addOptionDoubleValue('Q', "sense", "Audio sensitivty")
13139 .addOptionSingle('y', "Enable Audio Pass-through")
13140 .addOptionDouble('Y', "pass-through", "Enable audio pass through")
13141 .addOptionDoubleValue(300, "audio-input", "Audio input device")
13142 .addOptionDoubleValue(301, "audio-output", "Audio output device")
13143 .addOptionDouble(302, "list-devices", "list audio devices")
13144 .addOptionDoubleValue(303, "record-audio", "Record captured audio to WAV file")
13145 .addOptionDoubleValue(304, "record-gain", "Recording volume gain 0.0-2.0 (default: 1.0)")
13146 .addOptionDoubleValue(305, "audio-file", "Use an audio file or M3U playlist for reactivity instead of mic")
13147 .addOptionDouble(306, "audio-trunc", "Stop playback when the audio file reaches the end")
13148 .addOptionDoubleValue(307, "enable-audio-buffers", "Allocate a spectrum-history sampler1DArray with N layers")
13149 .addOptionDoubleValue(308, "audio-warm-rate", "Startup audio warmup rate (1/sec, default: 0.5)")
13150 .addOptionDouble(309, "audio-repeat", "Restart audio file playback at the end")
13151#endif
13152 .addOptionDouble('N', "fullscreen", "Fullscreen Window (Escape to quit)")
13153 .addOptionDouble(405, "silent", "Silent mode - process video without window, (video files only)")
13154 .addOptionDoubleValue(406, "shader-pass", "Shader pass indices (comma-separated, e.g. 0,1,2)")
13155 .addOptionDoubleValue(623, "shader-pass-files", "Shader pass filenames (length-prefixed)")
13156 .addOptionDoubleValue(407, "build", "Build shader cache for specified library path (compiles shaders and exits)")
13157 .addOptionDouble(408, "no-cache", "Disable shader caching (always recompile shaders)")
13158 .addOptionDoubleValue(416, "remove-broken", "Compile each shader in library path; remove failures from its manifest, then exit")
13159 .addOptionDoubleValue(409, "time-speed", "Constant time_f speed multiplier (default: 1.0)")
13160 .addOptionDouble(620, "normalized", "Use deterministic output-frame time_f advancement")
13161 .addOptionDoubleValue(410, "playlist", "Shader playlist text file (one shader name per line, P to toggle)")
13162 .addOptionDoubleValue(417, "autopilot-frames", "Autopilot frame interval; switch to a random playlist shader every N frames (minimum 4, J toggles)")
13163 .addOptionDoubleValue(420, "autopilot-timeout", "Alias for --autopilot-frames")
13164 .addOptionDoubleValue(418, "autopilot-random", "Autopilot random interval upper bound; each J/Y switch picks 4..N frames")
13165 .addOptionDoubleValue(419, "autiopilot-random", "Alias for --autopilot-random")
13166 .addOptionDoubleValue(411, "duration", "Recording duration in seconds (float); stop recording and exit after elapsed")
13167 .addOptionDoubleValue(610, "max-size", "Stop recording when output file exceeds size in MB (float)")
13168 .addOptionDouble(611, "png", "Video file mode: write PNG frames to output subdirectory instead of encoding video")
13169 .addOptionDoubleValue(612, "generate", "Save a PNG frame every N frames to an output subdirectory (video or camera mode)")
13170 .addOptionDouble(613, "interface-shm", "Enable Qt interface shared-memory control channel")
13171 .addOptionDoubleValue(412, "cross-fade", "Crossfade duration in seconds when switching playlist shaders (default: 0.5)")
13172 .addOptionDoubleValue(413, "enumerate-device", "List supported resolutions for a camera device index")
13173 .addOptionDouble(414, "use-yuv", "Use YUV (YUYV) camera format instead of MJPG")
13174 .addOptionDoubleValue(600, "encode-preset", "Encoder preset: ultrafast..veryslow or NVENC p1..p7 (default: medium)")
13175 .addOptionDoubleValue(601, "encode-tune", "Encoder tune: software tunes or NVENC hq,uhq,ll,ull,lossless (default: none)")
13176 .addOptionDoubleValue(602, "encode-crf", "Encoder CRF quality 0 (best) .. 51 (worst), default 18")
13177 .addOptionDoubleValue(603, "encode-codec", "Encoder policy or exact FFmpeg encoder name (default: auto)")
13178 .addOptionDouble(604, "encode-realtime", "Enable low-latency realtime encoding flags")
13179 .addOptionDouble(605, "flip", "Vertical flip output frames")
13180 .addOptionDoubleValue(617, "rotate", "Rotate input frames: clockwise, 180, or counterclockwise")
13181 .addOptionDouble(606, "no-drop", "File/graphics mode: never drop frames; ignored for webcams")
13182 .addOptionDouble(607, "display-filter", "Display current shader/stack and GPU filter in upper-left corner")
13183 .addOptionDoubleValue(608, "use-watermark", "Enable watermark with the given text in upper-left corner of recorded video")
13184 .addOptionDoubleValue(609, "use-watermark-color", "Watermark color as r,g,b each 0-255 (default: 255,0,150)")
13185 .addOptionDoubleValue(614, "encode-params", "Additional FFmpeg-style video encoder parameters passed through MXWrite")
13186 .addOptionDouble(615, "list-encoders", "List FFmpeg video encoders available to MXWrite")
13187 .addOptionDoubleValue(616, "list-encoder-options", "List options for an exact FFmpeg video encoder")
13188#ifdef MIDI_ENABLED
13189 .addOptionDoubleValue(500, "midi-map", "MIDI config file (.midi_cfg)")
13190 .addOptionDoubleValue(501, "midi-device", "MIDI input device index")
13191 .addOptionDouble(502, "list-midi", "List available MIDI input devices")
13192#endif
13193 ;
13194
13195 if (argc == 1) {
13196 printAbout();
13197 exit(EXIT_SUCCESS);
13198 }
13199
13200 for (int i = 1; i < argc; ++i) {
13201 if (std::string(argv[i]) == "--check-cuda") {
13202#ifdef ACMX2_WITH_CUDA
13203 std::cout << "CUDA: enabled" << std::endl;
13204#else
13205 std::cout << "CUDA: disabled" << std::endl;
13206#endif
13207 return EXIT_SUCCESS;
13208 }
13209 if (std::string(argv[i]) == "--check-midi") {
13210#ifdef MIDI_ENABLED
13211 std::cout << "MIDI: enabled" << std::endl;
13212#else
13213 std::cout << "MIDI: disabled" << std::endl;
13214#endif
13215 return EXIT_SUCCESS;
13216 }
13217 if (std::string(argv[i]) == "--check-audio") {
13218#ifdef AUDIO_ENABLED
13219 std::cout << "AUDIO: enabled" << std::endl;
13220#else
13221 std::cout << "AUDIO: disabled" << std::endl;
13222#endif
13223 return EXIT_SUCCESS;
13224 }
13225 if (std::string(argv[i]) == "--check-dnn") {
13226#ifdef ACMX2_WITH_DNN
13227 std::cout << "OpenCV DNN: enabled" << std::endl;
13228#else
13229 std::cout << "OpenCV DNN: disabled" << std::endl;
13230#endif
13231 return EXIT_SUCCESS;
13232 }
13233 }
13234
13235 printBranding();
13236
13237 Argument<std::string> arg;
13238 MXArguments args;
13239 int value = 0;
13240 try {
13241 while ((value = parser.proc(arg)) != -1) {
13242 switch (value) {
13243 case 'v':
13244 case 'h':
13245 case 261:
13246 case 262:
13247 printAbout(false);
13248 exit(EXIT_SUCCESS);
13249 break;
13250 case 'p':
13251 case 'P':
13252 args.path = arg.arg_value;
13253 break;
13254 case 'r':
13255 case 'R': {
13256 auto pos = arg.arg_value.find("x");
13257 if (pos == std::string::npos) {
13258 mx::system_err << "Error invalid resolution use WidthxHeight\n";
13259 mx::system_err.flush();
13260 exit(EXIT_FAILURE);
13261 }
13262 std::string left, right;
13263 left = arg.arg_value.substr(0, pos);
13264 right = arg.arg_value.substr(pos + 1);
13265 args.tw = atoi(left.c_str());
13266 args.th = atoi(right.c_str());
13267 args.sizev = cv::Size(args.tw, args.th);
13268 } break;
13269 case 'G':
13270 case 'g':
13271 args.graphic_file = arg.arg_value;
13272 break;
13273 case 'C':
13274 case 'c': {
13275 auto pos = arg.arg_value.find("x");
13276 if (pos == std::string::npos) {
13277 mx::system_err << "Error invalid camera resolution use WidthxHeight\n";
13278 mx::system_err.flush();
13279 exit(EXIT_FAILURE);
13280 }
13281 std::string left, right;
13282 left = arg.arg_value.substr(0, pos);
13283 right = arg.arg_value.substr(pos + 1);
13284 int xw = atoi(left.c_str());
13285 int xh = atoi(right.c_str());
13286 args.csize = cv::Size(xw, xh);
13287 } break;
13288 case 'd':
13289 case 'D':
13290 args.camera_device = atoi(arg.arg_value.c_str());
13291 break;
13292 case 's':
13293 case 'S':
13294 args.mode = 1;
13295 args.library = arg.arg_value;
13296 break;
13297 case 'F':
13298 case 'f':
13299 args.mode = 0;
13300 args.fragment = arg.arg_value;
13301 break;
13302 case 'H':
13303 args.shader_index = atoi(arg.arg_value.c_str());
13304 break;
13305 case 622:
13306 args.shader_file = arg.arg_value;
13307 break;
13308 case 'e':
13309 case 'E':
13310 args.prefix_path = arg.arg_value;
13311 break;
13312 case 'i':
13313 case 'I':
13314 args.filename = arg.arg_value;
13315 break;
13316 case 'o':
13317 case 'O':
13318 args.ofilename = arg.arg_value;
13319 break;
13320 case 'b':
13321 case 'B':
13322 args.crf = arg.arg_value;
13323 try {
13324 int v = std::stoi(arg.arg_value);
13325 if (v < 0)
13326 v = 0;
13327 if (v > 51)
13328 v = 51;
13329 args.encode_opts.crf = v;
13330 } catch (...) {
13331 }
13332 break;
13333 case 'u':
13334 case 'U':
13335 args.fps_value = atof(arg.arg_value.c_str());
13336 break;
13337 case 'a':
13338 case 'A':
13339 args.repeat = true;
13340 break;
13341 case 'n':
13342 case 'N':
13343 args.full = true;
13344 break;
13345 case 256:
13346 args.cache = true;
13347 mx::system_out << "acmx2: Texture cache enabled.\n";
13348 break;
13349 case 257:
13350 args.cache_delay = atoi(arg.arg_value.c_str());
13351 mx::system_out << "acmx2: Cache delay set to: " << args.cache_delay << "\n";
13352 break;
13353 case 275: {
13354 int sz = atoi(arg.arg_value.c_str());
13355 if (sz >= 1 && sz <= 64)
13356 args.cache_size = sz;
13357 mx::system_out << "acmx2: Texture cache size set to: " << args.cache_size << "\n";
13358 break;
13359 }
13360 case 276:
13361 args.cache = true;
13362 args.cache_array = true;
13363 mx::system_out << "acmx2: Texture cache array enabled as uniform history.\n";
13364 break;
13365 case 258:
13366 args.copy_audio = true;
13367 break;
13368 case 259:
13369 args.is3d = true;
13370 mx::system_out << "acmx2: 3D cube enabled.\n";
13371 break;
13372 case 260:
13373 args.model_file = arg.arg_value;
13374 break;
13375 case 700:
13376 args.human_model = arg.arg_value;
13377 break;
13378 case 701:
13379 args.human_background_only = true;
13380 break;
13381 case 702:
13382 args.human_black = static_cast<float>(std::stod(arg.arg_value));
13383 break;
13384 case 703:
13385 args.human_white = static_cast<float>(std::stod(arg.arg_value));
13386 break;
13387 case 704:
13388 args.edge_model = arg.arg_value;
13389 break;
13390 case 705:
13391 args.onnx_model = arg.arg_value;
13392 break;
13393 case 400: {
13394 args.gpu_filter_enabled = true;
13395 std::string list = arg.arg_value;
13396 size_t start = 0;
13397 while (true) {
13398 size_t pos = list.find(',', start);
13399 std::string tok = (pos == std::string::npos) ? list.substr(start) : list.substr(start, pos - start);
13400 if (!tok.empty()) {
13401 int idx = std::stoi(tok);
13402 if (idx >= 0 && idx < ac_gpu::AC_FILTER_MAX) {
13403 args.gpu_filter_indices.push_back(idx);
13404 } else {
13405 mx::system_err << "acmx2: Invalid GPU filter index: " << idx << " (max: " << ac_gpu::AC_FILTER_MAX - 1 << ")\n";
13406 }
13407 }
13408 if (pos == std::string::npos)
13409 break;
13410 start = pos + 1;
13411 }
13412 } break;
13413 case 401:
13414 args.gpu_frame_buffer_size = std::stoi(arg.arg_value);
13415 if (args.gpu_frame_buffer_size < 4)
13416 args.gpu_frame_buffer_size = 4;
13417 if (args.gpu_frame_buffer_size > 32)
13418 args.gpu_frame_buffer_size = 32;
13419 mx::system_out << "acmx2: GPU frame buffer size: " << args.gpu_frame_buffer_size << "\n";
13420 break;
13421 case 402:
13422 mx::system_out << "Available GPU Filters (" << ac_gpu::AC_FILTER_MAX << " total):\n";
13423 for (int i = 0; i < ac_gpu::AC_FILTER_MAX; ++i) {
13424 mx::system_out << " " << i << ": " << ac_gpu::filters[i].name << "\n";
13425 }
13426 exit(EXIT_SUCCESS);
13427 break;
13428 case 403:
13429 args.disable_counter = true;
13430 break;
13431 case 'm':
13432 case 'M':
13433 args.cuda_device = atoi(arg.arg_value.c_str());
13434 break;
13435 case 404:
13436 checkDevices(true);
13437 exit(EXIT_SUCCESS);
13438 break;
13439 case 415:
13440#ifdef ACMX2_WITH_CUDA
13441 std::cout << "CUDA: enabled\n";
13442#else
13443 std::cout << "CUDA: disabled\n";
13444#endif
13445 exit(EXIT_SUCCESS);
13446 break;
13447 case 621:
13448#ifdef ACMX2_WITH_DNN
13449 std::cout << "OpenCV DNN: enabled\n";
13450#else
13451 std::cout << "OpenCV DNN: disabled\n";
13452#endif
13453 exit(EXIT_SUCCESS);
13454 break;
13455#ifdef AUDIO_ENABLED
13456 case 'W':
13457 case 'w':
13458 args.audio_enabled = true;
13459 break;
13460 case 'l':
13461 case 'L':
13462 args.audio_channels = atoi(arg.arg_value.c_str());
13463 break;
13464 case 'Q':
13465 case 'q':
13466 args.audio_sensitivty = atof(arg.arg_value.c_str());
13467 break;
13468 case 'Y':
13469 case 'y':
13470 args.audio_pass_through = true;
13471 break;
13472 case 300:
13473 if (arg.arg_value == "default")
13474 args.audio_input = -1;
13475 else
13476 args.audio_input = atoi(arg.arg_value.c_str());
13477 break;
13478 case 301:
13479 if (arg.arg_value == "default")
13480 args.audio_output = -1;
13481 else
13482 args.audio_output = atoi(arg.arg_value.c_str());
13483 break;
13484 case 302:
13486 exit(EXIT_SUCCESS);
13487 break;
13488 case 303:
13489 args.record_audio_file = arg.arg_value;
13490 break;
13491 case 304:
13492 args.record_gain = static_cast<float>(atof(arg.arg_value.c_str()));
13493 break;
13494 case 305:
13495 args.audio_file = arg.arg_value;
13496 args.audio_enabled = true;
13497 break;
13498 case 306:
13499 args.audio_trunc = true;
13500 break;
13501 case 307: {
13502 int n = atoi(arg.arg_value.c_str());
13503 if (n < 0)
13504 n = 0;
13505 args.audio_buffers = n;
13506 } break;
13507 case 308: {
13508 float rate = static_cast<float>(atof(arg.arg_value.c_str()));
13509 if (!std::isfinite(rate) || rate < 0.0f) {
13510 mx::system_err << "acmx2: --audio-warm-rate must be >= 0.0\n";
13511 mx::system_err.flush();
13512 exit(EXIT_FAILURE);
13513 }
13514 args.audio_warm_rate = rate;
13515 } break;
13516 case 309:
13517 args.audio_repeat = true;
13518 break;
13519#endif
13520 case 405:
13521 args.silent = true;
13522 break;
13523 case 406: {
13524 std::string pass_list = arg.arg_value;
13525 size_t start = 0;
13526 while (true) {
13527 size_t pos = pass_list.find(',', start);
13528 std::string tok = (pos == std::string::npos) ? pass_list.substr(start) : pass_list.substr(start, pos - start);
13529 if (!tok.empty()) {
13530 try {
13531 int idx = std::stoi(tok);
13532 if (idx >= 0) {
13533 args.shader_pass_list.push_back(idx);
13534 }
13535 } catch (...) {
13536 mx::system_err << "acmx2: Warning: Invalid shader pass index: " << tok << "\n";
13537 }
13538 }
13539 if (pos == std::string::npos)
13540 break;
13541 start = pos + 1;
13542 }
13543 if (!args.shader_pass_list.empty()) {
13544 args.shader_pass_enabled = true;
13545 mx::system_out << "acmx2: Shader pass list enabled with " << args.shader_pass_list.size() << " passes\n";
13546 }
13547 break;
13548 }
13549 case 623: {
13550 const std::string passFiles = arg.arg_value;
13551 size_t start = 0;
13552 while (start < passFiles.size()) {
13553 const size_t separator = passFiles.find(':', start);
13554 if (separator == std::string::npos)
13555 break;
13556 size_t consumed = 0;
13557 size_t nameLength = 0;
13558 try {
13559 nameLength = std::stoull(passFiles.substr(start, separator - start), &consumed);
13560 } catch (...) {
13561 break;
13562 }
13563 if (consumed != separator - start || nameLength > passFiles.size() - separator - 1)
13564 break;
13565 const size_t nameStart = separator + 1;
13566 args.shader_pass_files.push_back(passFiles.substr(nameStart, nameLength));
13567 start = nameStart + nameLength;
13568 }
13569 if (start != passFiles.size()) {
13570 mx::system_err << "acmx2: Invalid --shader-pass-files payload\n";
13571 return EXIT_FAILURE;
13572 }
13573 break;
13574 }
13575 case 407:
13576 args.build_cache = true;
13577 args.build_library_path = arg.arg_value;
13578 break;
13579 case 416:
13580 args.remove_broken = true;
13581 args.remove_broken_path = arg.arg_value;
13582 break;
13583 case 408:
13584 args.use_shader_cache = false;
13585 mx::system_out << "acmx2: Shader caching disabled\n";
13586 break;
13587 case 409:
13588 args.time_speed = static_cast<float>(atof(arg.arg_value.c_str()));
13589 mx::system_out << "acmx2: Time speed set to: " << args.time_speed << "\n";
13590 break;
13591 case 620:
13592 args.normalized_time = true;
13593 mx::system_out << "acmx2: Normalized time enabled\n";
13594 break;
13595 case 410:
13596 args.playlist_file = arg.arg_value;
13597 mx::system_out << "acmx2: Playlist file: " << args.playlist_file << "\n";
13598 break;
13599 case 417:
13600 case 420:
13601 args.autopilot_frames = atoi(arg.arg_value.c_str());
13602 if (args.autopilot_frames < 4)
13603 args.autopilot_frames = 4;
13604 mx::system_out << "acmx2: Autopilot frames: " << args.autopilot_frames << "\n";
13605 break;
13606 case 418:
13607 case 419:
13608 args.autopilot_random_interval = true;
13609 args.autopilot_random_timeout = atoi(arg.arg_value.c_str());
13610 if (args.autopilot_random_timeout < 4)
13611 args.autopilot_random_timeout = 4;
13612 mx::system_out << "acmx2: Autopilot random interval enabled (4-" << args.autopilot_random_timeout << " frames)\n";
13613 break;
13614 case 411:
13615 args.duration = atof(arg.arg_value.c_str());
13616 if (args.duration > 0.0) {
13617 mx::system_out << "acmx2: Duration set to: " << args.duration << " seconds\n";
13618 }
13619 break;
13620 case 610:
13621 args.max_size_mb = atof(arg.arg_value.c_str());
13622 if (args.max_size_mb > 0.0) {
13623 mx::system_out << "acmx2: Max output size set to: " << std::fixed << std::setprecision(2) << args.max_size_mb << " MB\n";
13624 } else {
13625 args.max_size_mb = 0.0;
13626 }
13627 break;
13628 case 611:
13629 args.png_output = true;
13630 mx::system_out << "acmx2: --png enabled (video-file mode only; ignored for camera mode)\n";
13631 break;
13632 case 612: {
13633 int n = atoi(arg.arg_value.c_str());
13634 if (n < 1) {
13635 mx::system_err << "acmx2: --generate requires a positive integer frame interval\n";
13636 exit(EXIT_FAILURE);
13637 }
13638 args.generate_interval = n;
13639 mx::system_out << "acmx2: --generate " << n << ": will save a PNG frame every " << n << " frames\n";
13640 break;
13641 }
13642 case 613:
13643 args.interface_shm = true;
13644 mx::system_out << "acmx2: Qt shared-memory control channel enabled\n";
13645 break;
13646 case 412:
13647 args.cross_fade_duration = static_cast<float>(atof(arg.arg_value.c_str()));
13648 mx::system_out << "acmx2: Crossfade duration set to: " << args.cross_fade_duration << " seconds\n";
13649 break;
13650 case 413: {
13651#ifdef __linux__
13652 int dev_idx = atoi(arg.arg_value.c_str());
13653 std::string dev_path = "/dev/video" + std::to_string(dev_idx);
13654 int fd = open(dev_path.c_str(), O_RDWR);
13655 if (fd < 0) {
13656 mx::system_err << "acmx2: Cannot open " << dev_path << ": " << strerror(errno) << "\n";
13657 exit(EXIT_FAILURE);
13658 }
13659 v4l2_capability cap{};
13660 bool loopback_device = false;
13661 if (ioctl(fd, VIDIOC_QUERYCAP, &cap) == 0) {
13662 mx::system_out << "Device " << dev_idx << ": " << dev_path << "\n";
13663 mx::system_out << " Driver : " << cap.driver << "\n";
13664 mx::system_out << " Card : " << cap.card << "\n";
13665 mx::system_out << " Bus : " << cap.bus_info << "\n";
13666 const std::string driver(reinterpret_cast<const char *>(cap.driver));
13667 loopback_device = driver.find("v4l2loopback") != std::string::npos || driver.find("v4l2 loopback") != std::string::npos;
13668 }
13669
13670 double current_fps = 0.0;
13671 v4l2_streamparm stream_parameters{};
13672 stream_parameters.type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
13673 if (ioctl(fd, VIDIOC_G_PARM, &stream_parameters) == 0 && stream_parameters.parm.capture.timeperframe.numerator != 0) {
13674 const v4l2_fract &interval = stream_parameters.parm.capture.timeperframe;
13675 current_fps = static_cast<double>(interval.denominator) / interval.numerator;
13676 }
13677 v4l2_fmtdesc fmt{};
13678 fmt.type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
13679 fmt.index = 0;
13680 while (ioctl(fd, VIDIOC_ENUM_FMT, &fmt) == 0) {
13681 char fourcc[5] = {static_cast<char>(fmt.pixelformat & 0xFF), static_cast<char>((fmt.pixelformat >> 8) & 0xFF), static_cast<char>((fmt.pixelformat >> 16) & 0xFF), static_cast<char>((fmt.pixelformat >> 24) & 0xFF), '\0'};
13682 mx::system_out << "\n Format: " << fourcc << " (" << fmt.description << ")\n";
13683 v4l2_frmsizeenum fsize{};
13684 fsize.pixel_format = fmt.pixelformat;
13685 fsize.index = 0;
13686 while (ioctl(fd, VIDIOC_ENUM_FRAMESIZES, &fsize) == 0) {
13687 if (fsize.type == V4L2_FRMSIZE_TYPE_DISCRETE) {
13688 mx::system_out << " " << fsize.discrete.width << "x" << fsize.discrete.height;
13689 std::vector<double> frame_rates;
13690 auto append_frame_rate = [&frame_rates](double frame_rate) {
13691 if (frame_rate <= 0.0) {
13692 return;
13693 }
13694 const auto existing = std::find_if(frame_rates.begin(), frame_rates.end(), [frame_rate](double value) { return std::abs(value - frame_rate) < 0.05; });
13695 if (existing == frame_rates.end()) {
13696 frame_rates.push_back(frame_rate);
13697 }
13698 };
13699 v4l2_frmivalenum fival{};
13700 fival.pixel_format = fmt.pixelformat;
13701 fival.width = fsize.discrete.width;
13702 fival.height = fsize.discrete.height;
13703 fival.index = 0;
13704 while (ioctl(fd, VIDIOC_ENUM_FRAMEINTERVALS, &fival) == 0) {
13705 if (fival.type == V4L2_FRMIVAL_TYPE_DISCRETE) {
13706 if (fival.discrete.numerator != 0) {
13707 append_frame_rate(static_cast<double>(fival.discrete.denominator) / fival.discrete.numerator);
13708 }
13709 } else if (fival.type == V4L2_FRMIVAL_TYPE_STEPWISE || fival.type == V4L2_FRMIVAL_TYPE_CONTINUOUS) {
13710 if (fival.stepwise.min.numerator != 0) {
13711 append_frame_rate(static_cast<double>(fival.stepwise.min.denominator) / fival.stepwise.min.numerator);
13712 }
13713 if (fival.stepwise.max.numerator != 0) {
13714 append_frame_rate(static_cast<double>(fival.stepwise.max.denominator) / fival.stepwise.max.numerator);
13715 }
13716 }
13717 fival.index++;
13718 }
13719
13720 append_frame_rate(current_fps);
13721 if (loopback_device) {
13722 // v4l2loopback reports only its current interval,
13723 // but accepts consumer-selected time-per-frame
13724 // values. Include common real-time and constrained
13725 // high-speed camera rates for the interface.
13726 constexpr double LOOPBACK_FRAME_RATES[] = {24.0, 25.0, 30.0, 50.0, 60.0, 90.0, 120.0, 144.0, 240.0};
13727 for (double frame_rate : LOOPBACK_FRAME_RATES) {
13728 append_frame_rate(frame_rate);
13729 }
13730 }
13731
13732 std::sort(frame_rates.begin(), frame_rates.end(), std::greater<double>());
13733 bool first = true;
13734 for (double frame_rate : frame_rates) {
13735 mx::system_out << (first ? " @ " : ", ") << std::fixed << std::setprecision(1) << frame_rate << " fps";
13736 first = false;
13737 }
13738 mx::system_out << "\n";
13739 } else if (fsize.type == V4L2_FRMSIZE_TYPE_STEPWISE || fsize.type == V4L2_FRMSIZE_TYPE_CONTINUOUS) {
13740 mx::system_out << " " << fsize.stepwise.min_width << "x" << fsize.stepwise.min_height << " to " << fsize.stepwise.max_width << "x" << fsize.stepwise.max_height << " (step " << fsize.stepwise.step_width << "x" << fsize.stepwise.step_height << ")\n";
13741 break;
13742 }
13743 fsize.index++;
13744 }
13745 fmt.index++;
13746 }
13747 close(fd);
13748#else
13749 mx::system_out << "acmx2: --enumerate-device is only supported on Linux\n";
13750#endif
13751 fflush(stdout);
13752 exit(EXIT_SUCCESS);
13753 break;
13754 }
13755 case 414:
13756 args.use_yuv = true;
13757 mx::system_out << "acmx2: Using YUV (YUYV) camera format\n";
13758 break;
13759 case 600:
13760 args.encode_opts.preset = arg.arg_value;
13761 mx::system_out << "acmx2: Encoder preset: " << args.encode_opts.preset << "\n";
13762 break;
13763 case 601:
13764 args.encode_opts.tune = (arg.arg_value == "none") ? std::string{} : arg.arg_value;
13765 mx::system_out << "acmx2: Encoder tune: " << (args.encode_opts.tune.empty() ? "none" : args.encode_opts.tune) << "\n";
13766 break;
13767 case 602: {
13768 int v = atoi(arg.arg_value.c_str());
13769 if (v < 0)
13770 v = 0;
13771 if (v > 51)
13772 v = 51;
13773 args.encode_opts.crf = v;
13774 args.crf = std::to_string(v);
13775 mx::system_out << "acmx2: Encoder CRF: " << v << "\n";
13776 break;
13777 }
13778 case 603:
13779 args.encode_opts.codec = arg.arg_value;
13780 mx::system_out << "acmx2: Encoder codec: " << args.encode_opts.codec << "\n";
13781 break;
13782 case 604:
13783 args.encode_opts.realtime = true;
13784 mx::system_out << "acmx2: Encoder realtime mode enabled\n";
13785 break;
13786 case 605:
13787 args.flip_output = true;
13788 mx::system_out << "acmx2: Output frame flipping enabled\n";
13789 break;
13790 case 617: {
13791 std::string rotation = arg.arg_value;
13792 std::transform(rotation.begin(), rotation.end(), rotation.begin(), [](unsigned char character) { return static_cast<char>(std::tolower(character)); });
13793 if (rotation == "clockwise" || rotation == "cw" || rotation == "90" || rotation == "90cw") {
13795 mx::system_out << "acmx2: Input frame rotation: 90 degrees clockwise\n";
13796 } else if (rotation == "180") {
13798 mx::system_out << "acmx2: Input frame rotation: 180 degrees\n";
13799 } else if (rotation == "counterclockwise" || rotation == "ccw" || rotation == "90ccw" || rotation == "270") {
13801 mx::system_out << "acmx2: Input frame rotation: 90 degrees counterclockwise\n";
13802 } else {
13803 mx::system_err << "acmx2: --rotate requires clockwise, 180, or "
13804 "counterclockwise\n";
13805 mx::system_err.flush();
13806 exit(EXIT_FAILURE);
13807 }
13808 break;
13809 }
13810 case 606:
13811 args.no_drop = true;
13812 mx::system_out << "acmx2: --no-drop enabled (video mode)\n";
13813 break;
13814 case 607:
13815 args.display_filter = true;
13816 mx::system_out << "acmx2: --display-filter enabled\n";
13817 break;
13818 case 608:
13819 args.watermark_text = arg.arg_value;
13820 mx::system_out << "acmx2: --use-watermark text: \"" << args.watermark_text << "\"\n";
13821 break;
13822 case 609: {
13823 const std::string &v = arg.arg_value;
13824 int r = 255, g = 0, b = 150;
13825 size_t c1 = v.find(',');
13826 size_t c2 = (c1 == std::string::npos) ? std::string::npos : v.find(',', c1 + 1);
13827 if (c1 != std::string::npos && c2 != std::string::npos) {
13828 try {
13829 r = std::stoi(v.substr(0, c1));
13830 g = std::stoi(v.substr(c1 + 1, c2 - c1 - 1));
13831 b = std::stoi(v.substr(c2 + 1));
13832 } catch (...) {
13833 mx::system_err << "acmx2: --use-watermark-color: invalid value '" << v << "'; expected r,g,b\n";
13834 break;
13835 }
13836 args.watermark_r = std::clamp(r, 0, 255);
13837 args.watermark_g = std::clamp(g, 0, 255);
13838 args.watermark_b = std::clamp(b, 0, 255);
13839 mx::system_out << "acmx2: --use-watermark-color: " << args.watermark_r << "," << args.watermark_g << "," << args.watermark_b << "\n";
13840 } else {
13841 mx::system_err << "acmx2: --use-watermark-color: invalid value '" << v << "'; expected r,g,b\n";
13842 }
13843 break;
13844 }
13845 case 614:
13846 args.encode_opts.ffmpeg_options = arg.arg_value;
13847 mx::system_out << "acmx2: Extra FFmpeg encoder parameters: " << args.encode_opts.ffmpeg_options << "\n";
13848 break;
13849 case 615: {
13850 auto clean_field = [](std::string value) {
13851 std::replace_if(value.begin(), value.end(), [](char ch) { return ch == '\t' || ch == '\r' || ch == '\n'; }, ' ');
13852 return value;
13853 };
13854 mx::system_out << "MXWRITE_ENCODERS\t1\n";
13855 for (const EncoderInfo &encoder : available_video_encoders()) {
13856 mx::system_out << "ENCODER\t" << clean_field(encoder.name) << '\t' << clean_field(encoder.long_name) << '\t' << clean_field(encoder.codec_name) << '\t' << (encoder.hardware ? "hardware" : "software") << '\t' << (encoder.experimental ? "experimental" : "stable") << '\t' << clean_field(encoder.pixel_formats) << '\n';
13857 }
13858 mx::system_out.flush();
13859 return EXIT_SUCCESS;
13860 }
13861 case 616: {
13862 auto clean_field = [](std::string value) {
13863 std::replace_if(value.begin(), value.end(), [](char ch) { return ch == '\t' || ch == '\r' || ch == '\n'; }, ' ');
13864 return value;
13865 };
13866 const std::vector<EncoderOptionInfo> options = video_encoder_options(arg.arg_value);
13867 if (options.empty() && !avcodec_find_encoder_by_name(arg.arg_value.c_str())) {
13868 mx::system_err << "acmx2: encoder not found: " << arg.arg_value << '\n';
13869 return EXIT_FAILURE;
13870 }
13871 mx::system_out << "MXWRITE_ENCODER_OPTIONS\t1\t" << clean_field(arg.arg_value) << '\n';
13872 for (const EncoderOptionInfo &option : options) {
13873 mx::system_out << "OPTION\t" << clean_field(option.name) << '\t' << clean_field(option.type) << '\t' << clean_field(option.default_value) << '\t' << clean_field(option.minimum) << '\t' << clean_field(option.maximum) << '\t' << clean_field(option.choices) << '\t' << clean_field(option.help) << '\n';
13874 }
13875 mx::system_out.flush();
13876 return EXIT_SUCCESS;
13877 }
13878#ifdef MIDI_ENABLED
13879 case 500:
13880 args.midi_map_file = arg.arg_value;
13881 mx::system_out << "acmx2: MIDI map file: " << args.midi_map_file << "\n";
13882 break;
13883 case 501:
13884 args.midi_device = atoi(arg.arg_value.c_str());
13885 mx::system_out << "acmx2: MIDI device index: " << args.midi_device << "\n";
13886 break;
13887 case 502: {
13888 try {
13889 RtMidiIn midi;
13890 unsigned int ports = midi.getPortCount();
13891 if (ports == 0) {
13892 mx::system_out << "No MIDI input devices found.\n";
13893 } else {
13894 mx::system_out << "MIDI Input Devices (" << ports << "):\n";
13895 for (unsigned int i = 0; i < ports; ++i) {
13896 mx::system_out << " " << i << ": " << midi.getPortName(i) << "\n";
13897 }
13898 }
13899 } catch (RtMidiError &e) {
13900 mx::system_err << "MIDI error: " << e.getMessage() << "\n";
13901 }
13902 fflush(stdout);
13903 exit(EXIT_SUCCESS);
13904 break;
13905 }
13906#endif
13907 }
13908 }
13909 } catch (const ArgException<std::string> &e) {
13910 mx::system_err << e.text() << "\n";
13911 mx::system_err.flush();
13912 return EXIT_FAILURE;
13913 }
13914 checkDevices();
13915 if (args.path.empty()) {
13916 const char *env_path = std::getenv("ACMX2_PATH");
13917 if (env_path && env_path[0] != '\0') {
13918 args.path = env_path;
13919 mx::system_out << "acmx2: Using ACMX2_PATH environment variable: " << args.path << "\n";
13920 } else {
13921 const std::filesystem::path installed_assets = installed_assets_directory();
13922 if (!installed_assets.empty()) {
13923 args.path = installed_assets.string();
13924 mx::system_out << "acmx2: Using installed assets relative to executable: " << args.path << "\n";
13925 } else {
13926 args.path = ".";
13927 mx::system_out << "acmx2: Path name not provided, using current path...\n";
13928 }
13929 }
13930 }
13931 if (args.library.empty() && args.fragment.empty()) {
13932 const char *env_shader_path = std::getenv("ACMX2_SHADER_PATH");
13933 if (env_shader_path && env_shader_path[0] != '\0') {
13934 args.library = env_shader_path;
13935 args.mode = 1;
13936 mx::system_out << "acmx2: Using ACMX2_SHADER_PATH environment variable: " << args.library << "\n";
13937 }
13938 }
13939 if (args.library.empty() && args.fragment.empty()) {
13940 args.fragment = args.path + "/frag.glsl";
13941 }
13942 if (args.library.empty() && args.mode == 1) {
13943 args.library = args.path + "/filters";
13944 }
13945 if (args.remove_broken) {
13946 if (args.remove_broken_path.empty()) {
13947 mx::system_err << "acmx2: Error: --remove-broken requires a shader library path\n";
13948 mx::system_err.flush();
13949 return EXIT_FAILURE;
13950 }
13951 if (shaderManifestPath(args.remove_broken_path).empty()) {
13952 mx::system_err << "acmx2: Error: No library.json or index.txt found at: " << args.remove_broken_path << "\n";
13953 mx::system_err.flush();
13954 return EXIT_FAILURE;
13955 }
13956 try {
13957#if defined(__linux__) || defined(_WIN32)
13958 if (args.silent) {
13959#ifndef _WIN32
13960 set_environment_if_missing("SDL_VIDEODRIVER", "offscreen");
13961#endif
13962 set_environment_if_missing("SDL_AUDIODRIVER", "dummy");
13963 SDL_SetHint(SDL_HINT_JOYSTICK_ALLOW_BACKGROUND_EVENTS, "0");
13965 mx::system_out << "acmx2: remove-broken hidden build window enabled\n";
13966 }
13967#endif
13968 mx::system_out << "acmx2: Creating scan window for remove-broken...\n";
13969 fflush(stdout);
13970 const OpenGLContextConfig context_config = select_open_gl_context();
13971
13972 /**
13973 * @brief Headless GL context used exclusively by the
13974 * `--remove-broken` CLI flag.
13975 *
13976 * Mirrors `BuildWindow`: opens a minimal hidden 640x480 window
13977 * just long enough to get a valid OpenGL context, invokes
13978 * `ShaderLibrary::removeBrokenShaders()` once, and then exits.
13979 */
13980 class RemoveBrokenWindow : public gl::GLWindow {
13981 public:
13982 ShaderLibrary library;
13983 std::string lib_path;
13984 bool enable_3d;
13985 std::string assets_path;
13986 bool success = false;
13987 bool done = false;
13988 bool active = true;
13989
13990 RemoveBrokenWindow(const std::string &path, bool is3d, const std::string &assets, const OpenGLContextConfig &context_config, bool headless) : gl::GLWindow("ACMX2 Remove-Broken", 640, 480, false, gl::GLMode::DESKTOP, context_config.major, context_config.minor, false), lib_path(path), enable_3d(is3d), assets_path(assets) {
13991 if (headless)
13992 SDL_HideWindow(getWindow());
13994 util.path = assets_path;
13995 library.enableDualMode(enable_3d);
13996 }
13997
13998 void draw() override {
13999 glClearColor(0.0f, 0.0f, 0.0f, 1.0f);
14000 glClear(GL_COLOR_BUFFER_BIT);
14001 if (!done) {
14002 done = true;
14003 std::string vert_2d = util.getFilePath("data/vert.glsl");
14004 std::string vert_3d = util.getFilePath("data/vertex.glsl");
14005 mx::system_out << "acmx2: Scanning library: " << lib_path << "\n";
14006 mx::system_out << "acmx2: Mode: " << (enable_3d ? "2D+3D" : "2D only") << "\n";
14007 mx::system_out << "acmx2: OpenGL Renderer: " << safeGLString(GL_RENDERER) << "\n";
14008 mx::system_out << "acmx2: OpenGL Version: " << safeGLString(GL_VERSION) << "\n";
14009 fflush(stdout);
14010 success = library.removeBrokenShaders(this, lib_path, vert_2d, vert_3d);
14011 library.clear();
14012 active = false;
14013 }
14014 swap();
14015 }
14016
14017 void event(SDL_Event &) override {}
14018
14019 void scanLoop() {
14020 SDL_Event ev;
14021 while (active) {
14022 while (SDL_PollEvent(&ev)) {
14023 if (ev.type == SDL_QUIT)
14024 active = false;
14025 event(ev);
14026 }
14027 draw();
14028 }
14029 }
14030 };
14031
14032#if defined(__linux__) || defined(_WIN32)
14033 if (args.silent) {
14034 RemoveBrokenWindow rb_win(args.remove_broken_path, args.is3d, args.path, context_config, true);
14035 rb_win.scanLoop();
14036 return rb_win.success ? EXIT_SUCCESS : EXIT_FAILURE;
14037 }
14038#endif
14039 RemoveBrokenWindow rb_win(args.remove_broken_path, args.is3d, args.path, context_config, false);
14040 rb_win.scanLoop();
14041 return rb_win.success ? EXIT_SUCCESS : EXIT_FAILURE;
14042 } catch (const mx::Exception &e) {
14043 mx::system_err << "acmx2: Remove-broken failed: " << e.text() << "\n";
14044 mx::system_err.flush();
14045 return EXIT_FAILURE;
14046 } catch (std::exception &e) {
14047 mx::system_err << "acmx2: Remove-broken failed: " << e.what() << "\n";
14048 mx::system_err.flush();
14049 return EXIT_FAILURE;
14050 }
14051 }
14052
14053 if (args.build_cache) {
14054 if (args.build_library_path.empty()) {
14055 mx::system_err << "acmx2: Error: --build requires a shader library path\n";
14056 mx::system_err.flush();
14057 return EXIT_FAILURE;
14058 }
14059 if (shaderManifestPath(args.build_library_path).empty()) {
14060 mx::system_err << "acmx2: Error: No library.json or index.txt found at: " << args.build_library_path << "\n";
14061 mx::system_err.flush();
14062 return EXIT_FAILURE;
14063 }
14064
14065 try {
14066#if defined(__linux__) || defined(_WIN32)
14067 if (args.silent) {
14068#ifndef _WIN32
14069 set_environment_if_missing("SDL_VIDEODRIVER", "offscreen");
14070#endif
14071 set_environment_if_missing("SDL_AUDIODRIVER", "dummy");
14072 SDL_SetHint(SDL_HINT_JOYSTICK_ALLOW_BACKGROUND_EVENTS, "0");
14074 mx::system_out << "acmx2: hidden cache-build window enabled\n";
14075 }
14076#endif
14077 mx::system_out << "acmx2: Creating build window...\n";
14078 fflush(stdout);
14079 const OpenGLContextConfig context_config = select_open_gl_context();
14080
14081 /**
14082 * @brief Headless-style GLWindow used exclusively by the `--build` CLI flag.
14083 *
14084 * Creates a minimal 640×480 hidden window just long enough to
14085 * establish an OpenGL context, compile every shader in the
14086 * library, serialise the resulting GPU program binaries to
14087 * disk via ShaderLibrary::buildShaderCache(), then exit.
14088 *
14089 * The window's draw() fires exactly once (guarded by
14090 * `build_done`), performs the full cache build, and then
14091 * sets `active = false` so buildLoop() terminates.
14092 */
14093 class BuildWindow : public gl::GLWindow {
14094 public:
14095 ShaderLibrary library; ///< Temporary shader library for compilation.
14096 std::string lib_path; ///< Path to the shader source directory.
14097 bool enable_3d; ///< Whether to include 3-D vertex shaders.
14098 std::string assets_path; ///< Base asset path (for locating vert.glsl).
14099 bool success = false; ///< True if buildShaderCache() succeeded.
14100 bool build_done = false; ///< Guard: ensures draw() builds only once.
14101 bool active = true; ///< Controls the buildLoop() pump.
14102
14103 /*
14104 * @brief Construct a build window and configure the shader library.
14105 * @param path Path to the shader source directory.
14106 * @param is3d Include 3-D shaders in the cache.
14107 * @param assets Base asset path for vertex shader lookup.
14108 */
14109 BuildWindow(const std::string &path, bool is3d, const std::string &assets, int tex_cache_size, bool use_array, const OpenGLContextConfig &context_config, bool headless) : gl::GLWindow("ACMX2 Shader Builder", 640, 480, false, gl::GLMode::DESKTOP, context_config.major, context_config.minor, false), lib_path(path), enable_3d(is3d), assets_path(assets) {
14110 if (headless)
14111 SDL_HideWindow(getWindow());
14113 mx::system_out << "acmx2: Window created, setting up...\n";
14114 fflush(stdout);
14115 util.path = assets_path;
14116 library.enableDualMode(enable_3d);
14117 library.setCacheSize(tex_cache_size > 0 ? tex_cache_size : 8);
14118 library.setHistoryTextureArray(use_array);
14119 }
14120
14121 /**
14122 * @brief Single-shot draw: compile all shaders, write cache, then signal exit.
14123 *
14124 * On the first (and only) call, resolves vertex shader paths,
14125 * invokes ShaderLibrary::buildShaderCache(), clears GPU
14126 * resources, and sets `active = false` to break the pump.
14127 */
14128 void draw() override {
14129 glClearColor(0.0f, 0.0f, 0.0f, 1.0f);
14130 glClear(GL_COLOR_BUFFER_BIT);
14131
14132 if (!build_done) {
14133 build_done = true;
14134
14135 // Display logo.png while the build runs
14136 static constexpr const char *kBldLogoVert = "#version 330 core\n"
14137 "layout(location = 0) in vec3 aPos;\n"
14138 "layout(location = 1) in vec2 aTex;\n"
14139 "out vec2 tc;\n"
14140 "void main() { gl_Position = vec4(aPos, 1.0); tc = aTex; }\n";
14141 static constexpr const char *kBldLogoFrag = "#version 330 core\n"
14142 "in vec2 tc;\n"
14143 "out vec4 color;\n"
14144 "uniform sampler2D samp;\n"
14145 "void main() { color = texture(samp, tc); }\n";
14146 gl::ShaderProgram logo_sh;
14147 gl::GLSprite logo_sp;
14148 std::string logo_path = util.getFilePath("data/logo.png");
14149 if (std::filesystem::exists(logo_path)) {
14150 GLuint logo_tex = 0;
14151 try {
14152 int lw = 0, lh = 0;
14153 logo_tex = gl::loadTexture(logo_path, lw, lh);
14154 if (logo_tex && logo_sh.loadProgramFromText(kBldLogoVert, kBldLogoFrag)) {
14155 logo_sp.initSize(w, h);
14156 logo_sp.setName("samp");
14157 logo_sp.setShader(&logo_sh);
14158 float scale = std::min(static_cast<float>(w) / lw, static_cast<float>(h) / lh);
14159 int dw = static_cast<int>(lw * scale);
14160 int dh = static_cast<int>(lh * scale);
14161 int lx = (w - dw) / 2;
14162 int ly = (h - dh) / 2;
14163 logo_sp.initWithTexture(&logo_sh, logo_tex, lx, ly, dw, dh);
14164 logo_tex = 0;
14165 logo_sp.draw();
14166 }
14167 } catch (...) {
14168 }
14169 if (logo_tex) {
14170 glDeleteTextures(1, &logo_tex);
14171 }
14172 }
14173 swap();
14174 SDL_PumpEvents();
14175
14176 std::string vert_2d = util.getFilePath("data/vert.glsl");
14177 std::string vert_3d = util.getFilePath("data/vertex.glsl");
14178
14179 mx::system_out << "acmx2: Building shader cache for: " << lib_path << "\n";
14180 mx::system_out << "acmx2: Mode: " << (enable_3d ? "2D+3D" : "2D only") << "\n";
14181 mx::system_out << "acmx2: OpenGL Renderer: " << safeGLString(GL_RENDERER) << "\n";
14182 mx::system_out << "acmx2: OpenGL Version: " << safeGLString(GL_VERSION) << "\n";
14183 fflush(stdout);
14184 success = library.buildShaderCache(this, lib_path, vert_2d, vert_3d);
14185 library.clear();
14186 active = false;
14187 }
14188
14189 swap();
14190 }
14191
14192 /// @brief No-op; build mode ignores all user input.
14193 void event(SDL_Event &e) override {}
14194 /**
14195 * @brief Minimal SDL event pump that drives draw() until `active` is cleared.
14196 *
14197 * Polls SDL events (honouring SDL_QUIT), calls draw() once per
14198 * iteration, and exits when the build is complete.
14199 */
14200 void buildLoop() {
14201 SDL_Event ev;
14202 while (active) {
14203 while (SDL_PollEvent(&ev)) {
14204 if (ev.type == SDL_QUIT) {
14205 active = false;
14206 }
14207 event(ev);
14208 }
14209 draw();
14210 }
14211 }
14212 };
14213
14214#if defined(__linux__) || defined(_WIN32)
14215 if (args.silent) {
14216 BuildWindow build_win(args.build_library_path, args.is3d, args.path, args.cache_size, args.cache_array, context_config, true);
14217 build_win.buildLoop();
14218 return build_win.success ? EXIT_SUCCESS : EXIT_FAILURE;
14219 }
14220#endif
14221 BuildWindow build_win(args.build_library_path, args.is3d, args.path, args.cache_size, args.cache_array, context_config, false);
14222 build_win.buildLoop();
14223
14224 return build_win.success ? EXIT_SUCCESS : EXIT_FAILURE;
14225 } catch (const mx::Exception &e) {
14226 mx::system_err << "acmx2: Build failed: " << e.text() << "\n";
14227 mx::system_err.flush();
14228 return EXIT_FAILURE;
14229 } catch (std::exception &e) {
14230 mx::system_err << "acmx2: Build failed: " << e.what() << "\n";
14231 mx::system_err.flush();
14232 return EXIT_FAILURE;
14233 }
14234 }
14235
14236 try {
14237 const std::vector<std::string> requestedShaderFiles = args.mode == 1 && (!args.shader_file.empty() || !args.shader_pass_files.empty()) ? sortedShaderLibraryEntries(args.library) : std::vector<std::string>{};
14238 if (args.mode == 1 && !args.shader_file.empty()) {
14239 const int selectedIndex = shaderIndexForFile(requestedShaderFiles, args.shader_file);
14240 if (selectedIndex < 0) {
14241 mx::system_err << "acmx2: Shader file is not present in the active library: " << args.shader_file << "\n";
14242 return EXIT_FAILURE;
14243 }
14244 args.shader_index = selectedIndex;
14245 }
14246 if (args.mode == 1 && !args.shader_pass_files.empty()) {
14247 args.shader_pass_list.clear();
14248 for (const std::string &shaderFile : args.shader_pass_files) {
14249 const int passIndex = shaderIndexForFile(requestedShaderFiles, shaderFile);
14250 if (passIndex < 0) {
14251 mx::system_err << "acmx2: Shader pass file is not present in the active library: " << shaderFile << "\n";
14252 return EXIT_FAILURE;
14253 }
14254 args.shader_pass_list.push_back(passIndex);
14255 }
14256 args.shader_pass_enabled = !args.shader_pass_list.empty();
14257 }
14258 args.slib = std::make_tuple(args.mode, (args.mode == 0) ? args.fragment : args.library, (args.mode == 0) ? 0 : args.shader_index);
14259 // Texture cache works in video, graphics, and camera modes.
14260
14261 if (args.silent) {
14262 if (args.filename.empty() && args.graphic_file.empty()) {
14263 mx::system_err << "acmx2: Error: --silent mode requires a video (-i/--input) "
14264 "or graphics (-g/--graphic) input file\n";
14265 mx::system_err << " Silent mode does not support camera input.\n";
14266 mx::system_err.flush();
14267 return EXIT_FAILURE;
14268 }
14269 if (args.ofilename.empty()) {
14270 mx::system_err << "acmx2: Error: --silent mode requires an output file (-o/--output)\n";
14271 mx::system_err.flush();
14272 return EXIT_FAILURE;
14273 }
14274 if (!args.graphic_file.empty() && args.duration <= 0.0) {
14275 mx::system_err << "acmx2: Error: silent graphics mode requires a positive "
14276 "maximum duration (--duration <seconds>)\n";
14277 mx::system_err.flush();
14278 return EXIT_FAILURE;
14279 }
14280 mx::system_out << "acmx2: Silent mode enabled - processing without window\n";
14281
14282 // In headless/batch mode stdout is usually a pipe (tee, logger,
14283 // CI capture). Force line buffering so every progress line is
14284 // delivered as soon as it's written, regardless of the child
14285 // process's default fully-buffered pipe mode.
14286 std::setvbuf(stdout, nullptr, _IOLBF, 0);
14287
14288 // True headless mode: force SDL to use the 'offscreen' video
14289 // driver so the process runs without any X11 / Wayland display
14290 // server. The offscreen driver uses EGL surfaceless rendering to
14291 // provide an OpenGL context. Use setenv with overwrite=0 so a
14292 // user who explicitly set SDL_VIDEODRIVER (e.g. to force a
14293 // specific backend for debugging) is respected. Also disable
14294 // joystick / gamecontroller / audio subsystems that can block or
14295 // fail on a pure headless server.
14296#ifndef _WIN32
14297 set_environment_if_missing("SDL_VIDEODRIVER", "offscreen");
14298#endif
14299 set_environment_if_missing("SDL_AUDIODRIVER", "dummy");
14300 SDL_SetHint(SDL_HINT_JOYSTICK_ALLOW_BACKGROUND_EVENTS, "0");
14301#ifdef _WIN32
14302 mx::system_out << "acmx2: Headless: using a hidden native Windows OpenGL "
14303 "window\n";
14304#else
14305 mx::system_out << "acmx2: Headless: SDL_VIDEODRIVER=" << (getenv("SDL_VIDEODRIVER") ? getenv("SDL_VIDEODRIVER") : "(unset)") << ", SDL_AUDIODRIVER=" << (getenv("SDL_AUDIODRIVER") ? getenv("SDL_AUDIODRIVER") : "(unset)") << "\n";
14306#endif
14307 // Install Ctrl+C / SIGTERM / SIGHUP handlers so batch/headless runs
14308 // can be interrupted cleanly: the writer flushes, mp4 trailer is
14309 // written, and the partial output file stays playable.
14311 mx::system_out << "acmx2: Headless: signal handlers installed\n";
14312 }
14313
14314 if (args.png_output && !args.filename.empty() && args.ofilename.empty()) {
14315 mx::system_err << "acmx2: Error: --png in video-file mode requires -o/--output to derive the PNG frame directory\n";
14316 mx::system_err.flush();
14317 return EXIT_FAILURE;
14318 }
14319
14320 // Create file-input windows at the source's native dimensions when
14321 // --resolution was not supplied. Some window managers do not reliably
14322 // apply an SDL resize immediately after an OpenGL window is created,
14323 // so determine the initial size before constructing MainWindow.
14324 if (!args.graphic_file.empty() && !args.sizev.has_value()) {
14325 const cv::Mat graphic_size_probe = cv::imread(args.graphic_file);
14326 if (graphic_size_probe.empty()) {
14327 mx::system_err << "acmx2: Error: graphics file not found or unreadable: " << args.graphic_file << "\n";
14328 mx::system_err.flush();
14329 return EXIT_FAILURE;
14330 }
14331
14332 args.tw = graphic_size_probe.cols;
14333 args.th = graphic_size_probe.rows;
14335 std::swap(args.tw, args.th);
14336 }
14337 mx::system_out << "acmx2: Graphics window initial size: " << args.tw << "x" << args.th << "\n";
14338 } else if (!args.filename.empty() && !args.sizev.has_value()) {
14339 const std::optional<cv::Size> video_size = probe_video_size(args.filename);
14340 if (video_size.has_value()) {
14341 args.tw = video_size->width;
14342 args.th = video_size->height;
14344 std::swap(args.tw, args.th);
14345 }
14346 mx::system_out << "acmx2: Video window initial size: " << args.tw << "x" << args.th << "\n";
14347 } else {
14348 mx::system_out << "acmx2: Could not probe video dimensions before window "
14349 "creation; using the startup fallback size\n";
14350 }
14351 }
14352
14353 SDL_SetHint(SDL_HINT_VIDEO_X11_NET_WM_BYPASS_COMPOSITOR, "0");
14354 SDL_SetHint("SDL_VIDEO_WAYLAND_WMCLASS", "acmx2");
14355 SDL_SetHint("SDL_VIDEO_X11_WMCLASS", "ACMX2");
14356
14357 const OpenGLContextConfig context_config = select_open_gl_context();
14358
14359 if (args.silent) {
14360 MainWindow main_window(args, context_config, true);
14361 main_window.loop();
14362 } else {
14363 MainWindow main_window(args, context_config);
14364 main_window.loop();
14365 }
14366 } catch (const mx::Exception &e) {
14367 mx::system_err << "acmx2: Exception: " << e.text() << "\n";
14368 mx::system_err.flush();
14369 return EXIT_FAILURE;
14370 } catch (std::exception &e) {
14371 mx::system_err << "acmx2: Exception: " << e.what() << "\n";
14372 mx::system_err.flush();
14373 return EXIT_FAILURE;
14374 } catch (...) {
14375 mx::system_err << "acmx2: Unknown exception occurred.\n";
14376 mx::system_err.flush();
14377 return EXIT_FAILURE;
14378 }
14379 return EXIT_SUCCESS;
14380}
float hlgToLinearScalar(float e)
void transfer_audio(std::string_view, std::string_view)
Copy the audio track from one media file to another via FFmpeg.
static bool isEditorPreviewForShader(const std::filesystem::path &loaded_shader, const std::filesystem::path &requested_path)
GLsizei bufSize
GLsizei GLsizei GLenum void * binary
static std::string shaderManifestPath(const std::string &library_path)
static OpenGLContextConfig select_open_gl_context()
void checkDevices(bool list_only=false)
Verify CUDA device availability and print GPU info.
static bool isEditorPreviewPath(const std::filesystem::path &library_path, const std::filesystem::path &requested_path)
constexpr const char * kDisplayFragPassthrough
bool convertBt2020Yuv10LimitedToRgba16(const AVFrame *src, cv::Mat &out)
constexpr const char * kHdrEncodeFrag
static std::optional< cv::Size > probe_video_size(const std::string &filename)
Read a video's coded dimensions without opening a decoder.
int main(int argc, char **argv)
Application entry point.
void printBranding()
Print the program name, software brand, and project URL once.
static bool compute_shader_supported
True when the active ACMX2 context can run OpenGL compute shaders.
static void update_compute_shader_support()
Update the feature flag from the real context rather than only the probe.
#define CHECK_CUDA(call)
float pqToLinearScalar(float e)
static std::optional< std::string > normalizeShaderIndexEntry(const std::string &raw)
std::vector< uint16_t > toneMapHdrRgba16ToSrgbRgba16(const unsigned char *hdr_pixels, int w, int h, int hdr_trc)
static std::filesystem::path installed_assets_directory()
Locate the installed ACMX2 asset root relative to the executable.
static bool isValidCustomUniformName(const std::string &name)
static bool isComputeShaderFile(const std::string &path)
static bool writeJsonShaderManifest(const std::string &path, const std::vector< std::string > &entries, const std::vector< ShaderManifestData::CustomUniform > &custom_uniforms, std::string &error)
static std::string safeGLString(GLenum name)
static void print_open_gl_uniform_limits()
Print the active driver's shader-uniform capacity in one line.
ShaderProgramKind
constexpr std::size_t FFT_SIZE
static PFNGLPROGRAMBINARYPROC_LOCAL glProgramBinaryFunc
ShaderManifestFormat
static bool loadShaderManifest(const std::string &library_path, ShaderManifestData &manifest, std::string &error)
constexpr const char * kMuxOverlayFrag
static bool collectShaderLibraryEntries(const std::string &library_path, std::vector< std::string > &shader_files, std::string &error)
void printAbout(bool include_branding=true)
Print program information, arguments, and keyboard controls.
static std::vector< std::string > sortedShaderLibraryEntries(const std::string &library_path)
static bool resolveShaderPathInLibrary(const std::string &library_path, const std::string &relative_path, std::string &resolved_full_path)
GLsizei GLsizei * length
static std::filesystem::path executable_directory()
Return the directory containing the running ACMX2 executable.
GLsizei GLsizei GLenum * binaryFormat
FrameRotation
static PFNGLGETPROGRAMBINARYPROC_LOCAL glGetProgramBinaryFunc
constexpr const char * kHdrVertPassthrough
constexpr const char * kHdrDecodeFrag
std::vector< unsigned char > toneMapHdrRgba16ToSdrRgba8(const std::vector< unsigned char > &hdr_pixels, int w, int h, int hdr_trc)
uint16_t linearToSrgb16(float v)
static bool set_environment_if_missing(const char *name, const char *value)
unsigned char linearToSrgb8(float v)
static int shaderIndexForFile(const std::vector< std::string > &shader_files, const std::string &shader_file)
bool loadProgramBinaryFunctions()
Dynamically load glGetProgramBinary / glProgramBinary via SDL.
constexpr const char * kDisplayVertFlip
typedef void(APIENTRYP PFNGLGETPROGRAMBINARYPROC_LOCAL)(GLuint program
float clamp01f(float v)
double file_audio_playback_time()
Return the current output-device playback timestamp in seconds.
bool file_audio_has_output_clock()
Check whether real-time output playback can provide the master clock.
void file_audio_close()
Stop file-audio playback and release decoder/sample resources.
std::vector< std::string > file_audio_source_paths()
Return the successfully decoded source tracks in playback order.
bool file_audio_open(const std::string &filepath)
Open and fully decode an audio file or M3U playlist to mono float PCM at 44.1 kHz.
void file_audio_set_repeat(bool enabled)
Enable or disable looping of the currently decoded audio file.
bool file_audio_enable_output(int output_device)
Configure real-time playback of the decoded file through an output device.
bool file_audio_is_active()
Return true while decoded file-audio samples remain.
void file_audio_process_frame(double video_fps, acmx2::audio::AudioAnalyzer &analyzer)
Advance one video-frame worth of samples and update audio analysis.
std::string file_audio_current_source_path()
Return the source track at the current playback position.
File-based audio input for audio-reactive shaders.
#define VERSION_AUTHOR
#define VERSION_INFO
#define PROGRAM_NAME
Core rendering object—drives the capture → filter → shade → record pipeline.
float audio_warmup_envelope
Startup fade for audio-driven uniforms/textures.
bool autopilot_sequential
When true, autopilot advances through the playlist in order instead of randomly (toggle via SDLK_y).
void runFileAudioMuxSync()
Run ffmpeg synchronously to mux the audio file into the output video.
std::string generate_dir
std::atomic< bool > muxComplete
float cameraYaw
int generate_interval
uint64_t recording_pbo_timeline_frame[2]
void pollMidi(gl::GLWindow *win)
Drain all pending MIDI messages and dispatch mapped actions.
float modelRotX
void convertResourcesToHdr(int w, int h)
One-time conversion of existing GL resources from SDR (8-bit) to HDR (16-bit) formats.
bool playlist_enabled
void ensureHdrResources(int w, int h)
Lazily allocate the HDR decode/encode textures, FBOs and shader programs.
bool enableWatermark
int64_t getFrameCount()
Return the current frame count (writer count or display count).
bool autopilot_random_crossfade
When true, autopilot picks a random crossfade shader on each switch.
void cleanupMidi()
Close the MIDI input port and free the RtMidiIn instance.
void resetAutopilotInterval()
std::atomic< bool > running
void updateTextureRGBA(GLuint texture, cv::Mat &frame)
Update a GL texture with pre-converted RGBA data (no colour conversion).
int raw_snapshot_state
glm::vec3 modelCenterOffset
float updateAudioWarmupEnvelope()
Advance and return the startup warmup envelope in [0,1].
void runMuxSync()
Run ffmpeg synchronously to mux the recorded audio WAV into the video MP4.
std::atomic< uint64_t > generate_all_frames
uint64_t last_graphics_progress_frame
size_t frames_proc
std::condition_variable queueCondVar
void runHdrEncodePass(GLuint src_tex, int w, int h)
Run the linear BT.2020 -> PQ/HLG encode pass from src_tex into hdr_encoded_texture.
FrameCache frame_cache
void appendRecordingTitleSuffix(std::ostringstream &stream)
std::optional< uintmax_t > getOutputFileSizeBytes() const
cv::VideoCapture cap
int hdr_upload_tex_w
Current GL size of camera_texture in HDR upload mode.
double fps
bool silent_mode
void autopilotRandomSwitch(gl::GLWindow *win)
Pick a random entry from the active playlist and apply it.
int tiff_snapshot_state
std::chrono::steady_clock::time_point lastMidiButtonTime
int watermark_b
Watermark color blue.
std::atomic< bool > isMuxing
std::condition_variable captureQueueCondVar
std::string audio_record_file
void bindPassHistoryTextures(size_t shader_index)
Bind history textures required by a cache-aware pass.
bool autopilot_random_interval
When true, choose a new interval from [4, autopilot_random_timeout] after each switch.
int cache_delay
int crossfadeShaderIndex
Active index into crossfadeShaders.
bool is3d_enabled
int audio_input_device
void startCaptureThread()
Start the background camera capture thread (camera mode only).
std::vector< PlaylistNode > playlist_tree
int gpu_cuda_device
std::chrono::steady_clock::time_point last_video_progress_emit
void beginCrossfade(gl::GLWindow *win)
Start a crossfade transition from the current frame.
void generateRandomMultipass(gl::GLWindow *win)
GLuint hdr_encoded_texture
GL_RGBA16: final PQ-re-encoded output for readback.
ac_gpu::GPUFilter * d_filterList
bool oscillateScale
std::map< std::pair< unsigned char, unsigned char >, int > knobFrameCount
std::optional< cv::Size > sizev
std::chrono::steady_clock::time_point sessionStartTime
cv::Mat graphic_frame
SnapshotThreadPool snapshot_pool
void updateShaderNameCache()
Refresh the cached shader name string for the HUD overlay.
int fpsFrameCount
GLuint hdr_linear_video_texture
GL_RGBA16F: PQ/HLG-decoded linear BT.2020 video.
int autopilot_counter
Frames elapsed since last autopilot switch.
float gpu_alpha
GLuint muxDummyTex
mx::Font waterFont
float modelRenderScale
FFMpegVideoReader ffmpeg_reader
void flushPBOs(gl::GLWindow *win)
Read any remaining pixels from both PBOs and enqueue them.
std::atomic< bool > copy_audio
SpectrumTexture spectrumTex
1D FFT magnitude texture for shaders.
GLuint preOverlayFBO
FBO whose color attachment is preOverlayTexture; used to save/restore fboTexture around the overlay+w...
std::chrono::steady_clock::time_point lastFrameTime
std::string m_file
bool gpu_filtersChanged
bool use_ffmpeg_reader
Writer writer
std::optional< cv::Size > sizec
gl::ShaderProgram fshader3d
GLuint passFBO[2]
float cameraDistance
gl::ShaderProgram muxOverlayShader
bool counter_disabled
bool needsFileAudioMux()
void stopCaptureThread()
Signal the capture thread to stop and wait for it to join.
std::string watermark_text
Active watermark text (overridden by –use-watermark).
void emitSilentGraphicsProgress(bool complete)
Emit bounded progress updates for duration-limited headless graphics rendering.
void generateRandomMultipassLong(gl::GLWindow *win)
bool saved_pass_enabled
std::vector< MidiCode > midiCodes
bool gpu_filter_enabled
int watermark_r
Watermark color red.
gl::ShaderProgram hdr_decode_shader
PQ/HLG -> linear BT.2020 fullscreen pass.
std::string prefix_path
std::atomic< bool > finished
std::thread writerThread
bool recording_pbo_uses_timeline_clock[2]
void runHdrDecodePass(int w, int h)
Run the PQ/HLG -> linear BT.2020 decode pass, sampling camera_texture and writing into hdr_linear_vid...
std::vector< int > saved_pass_list_before_random
std::thread captureThread
int gpu_frame_index
GLuint crossfadePrevTexture
Snapshot of the previous frame used as the blend source.
double totalFrames
void appendSilentProgressFileSize(std::ostream &stream) const
Append the current encoded output size to a silent progress line.
float cameraPitch
std::atomic< bool > skip_audio_mux_on_exit
gl::ShaderProgram fshader
bool no_drop_mode
bool source_frame_ready
float modelSize
bool rotation_swaps_dimensions() const
std::chrono::steady_clock::time_point last_graphics_progress_emit
int audio_buffer_count
Allocated history-array layer count.
float mux_time_f
bool shader_pass_enabled
bool input_is_hdr
Active HDR pipeline for this input.
bool spectrum_scale_by_sense
When true, scale spectrum 1D buffer by audio sensitivity.
uint64_t decoded_video_frame_count
bool isFrozen
void autopilotSequentialAdvance(gl::GLWindow *win)
Advance to the next entry in the active playlist (wrapping).
int watermark_g
Watermark color green.
unsigned int frame_counter
std::string playlist_file
float crossfadeDuration
Duration of the crossfade transition in seconds.
std::atomic< uint64_t > frames_dropped
size_t saved_shader_index_before_random
void requestStopNoMux()
int hdr_upload_tex_h
Current GL size of camera_texture in HDR upload mode.
bool random_multipass_mode
std::string getTimeString()
Format the current session time as HH:MM:SS.
std::string filename
bool shaderLocked
EncodeOptions encode_opts
bool file_audio_mode
True when audio comes from a file instead of RtAudio.
GLuint fboTexture
SDL_Keycode midiKeyToSDL(int code)
Convert an ACMX2 virtual key code to an SDL_Keycode.
std::vector< ac_gpu::Filter > gpu_filters
std::queue< FrameData > frameQueue
int pboNextIndex
std::atomic< uint64_t > generate_saved_counter
std::string lastMidiButton
GLuint crossfadeFBO
FBO used for the crossfade compositing pass.
int last_progress_percent
double max_size_limit_mb
ShaderLibrary library
bool isPaused
bool recording_pbo_primed
void uploadHdrFrame(const cv::Mat &rgba16)
Upload a 16-bit-per-channel RGBA frame (from FFMpegVideoReader::readHdr()) into camera_texture.
std::atomic< uint64_t > snapshotOffset
void startMediaTimelineIfReady()
Start playback and recording timing on the first valid source frame.
std::string audio_file_path
Path to the audio file used for file_audio_mode.
virtual void event(gl::GLWindow *win, SDL_Event &e) override
Handle SDL keyboard events (shader navigation, mode toggles, etc.).
virtual void load(gl::GLWindow *win) override
Called once by the GLWindow to initialise capture, shaders, FBOs, and recording.
float modelRotY
bool display_filter
void updateTexture(GLuint texture, cv::Mat &frame)
Update an existing GL texture with new cv::Mat data (BGR → RGBA).
GLuint captureFBO
int waterFontSize
GLuint passTexture[2]
FrameRotation frame_rotation
int gpu_frame_dir
std::mt19937 autopilot_rng
std::vector< int > shader_pass_list
GLuint camera_texture
unsigned char ** d_ptrList
std::atomic< bool > captureRunning
int gpu_current_filter_index
double max_size_limit_bytes
std::mutex captureQueueMutex
void ensureCrossfadeFBO(int width, int height)
Lazily create the crossfade FBO and its two textures.
float modelRotZ
std::thread muxThread
bool use_shader_cache_flag
std::string cached_shader_name
float audio_warmup_rate
Warmup envelope slope in 1/sec (higher = faster ramp).
static const char * midiKeyName(int code)
Map a virtual key code to a human-readable short name.
gl::ShaderProgram display_flip_shader
Display shader with optional Y-flip for windowed output.
std::map< std::pair< unsigned char, unsigned char >, unsigned char > knobState
double displayFPS
int snapshot_state
~ACView() override
Destroy the rendering object: stop threads, release GPU resources.
RtMidiIn * midiIn
bool png_video_mode
std::chrono::steady_clock::time_point fpsLastTime
gl::GLSprite sprite
float crossfadeAlpha
Current blend factor (0 = old frame, 1 = new frame).
int autopilot_frames
Frames between random switches in autopilot mode (0 = unset).
int pass_target_width
bool texture_cache_array
int cache_warmup_frames
virtual void draw(gl::GLWindow *win) override
Called every frame: capture, GPU-filter, shade, composite, record, display overlay.
int activePlaylistSize() const
std::chrono::steady_clock::time_point audio_warmup_last_tick
bool crossfadeActive
True while a crossfade transition is in progress.
void ensurePassTargets(gl::GLWindow *win)
Allocate the shared fragment/compute ping-pong targets on demand.
void stopWriterThread()
Stop the writer thread, close the Writer, and handle audio transfer.
bool generate_mode
bool texture_cache
std::tuple< int, std::string, int > flib
bool audio_repeat_mode
When true, restart file audio when its samples are exhausted.
double duration_limit
bool audio_trunc_mode
When true, stop playback when file audio samples are exhausted.
std::string crf
void startAudioRecordingIfNeeded()
Start WAV audio capture if recording is configured and not already active.
std::map< std::pair< unsigned char, unsigned char >, unsigned char > knobPrevValue
void beginMuxing(gl::GLWindow *win)
Launch the asynchronous audio-mux thread and show a progress overlay.
void generateRandomMultipassShort(gl::GLWindow *win)
SpectrumHistory spectrumHistory
Runtime-sized FFT history array.
int playlist_index
std::string png_video_dir
int pass_target_height
bool flip_output
std::string graphic
float cameraRotationSpeed
cv::Mat newFrame
std::atomic< uint64_t > png_video_frame_counter
std::vector< std::string > crossfadeShaderNames
Display names matching crossfadeShaders by index.
GLuint pboIds[2]
int gpu_square_size
void maybeRandomizeAutopilotCrossfade()
GLuint hdr_linear_video_fbo
FBO writing into hdr_linear_video_texture.
int hdr_snapshot_state
void setupCaptureFBO(int width, int height)
Create the off-screen FBO used for capture/recording.
void hdrReadback(GLuint src_linear_tex, int w, int h, std::vector< unsigned char > &out)
HDR readback: run the encode pass and read 16-bit PQ-encoded RGB pixels back into out.
void rotate_frame(cv::Mat &frame)
GLuint depthBuffer
GLuint crossfadeTexture
Colour attachment of crossfadeFBO (blended output).
float movementSpeed
std::vector< gl::ShaderProgram > crossfadeShaders
Available crossfade transition shaders (cycle with [ and ]).
GLuint hdr_encoded_fbo
FBO writing into hdr_encoded_texture.
GLuint loadTexture(cv::Mat &frame)
Create a new GL texture from a cv::Mat (BGR → RGBA upload).
void injectKey(SDL_Keycode key, gl::GLWindow *win)
Synthesise an SDL_KEYUP event and dispatch it through event().
acmx2::audio::AudioEngine audio_engine
int autopilot_interval_frames
Active interval currently used by autopilot tick.
bool viewRotationActive
void drawMidiOverlay(gl::GLWindow *win, mx::Font &font, int startY)
Draw the MIDI status overlay (active indicator, knob bars, last button).
bool media_timeline_started
cv::Mat gpuFilteredFrame
bool needsMux()
Determine if recorded-audio mux should run at shutdown.
void initMidi(const std::string &mapFile, int deviceIndex)
Open a MIDI input port and load the key→action mapping file.
cv::Mat hdr_frame_mat
Scratch CV_16UC4 RGBA frame for HDR decode.
void resetAudioWarmupEnvelope()
Reset the startup envelope used to tame initial audio intensity.
std::vector< int > playlist_indices
int audio_output_device
std::unique_ptr< ac_gpu::DynamicFrameBuffer > gpu_frame_buffer
std::chrono::steady_clock::time_point media_timeline_start_time
bool autopilot_enabled
Toggle autopilot via SDLK_j when playlist is enabled.
bool midiOpen
std::string ofilename
void startWriterThread()
Start the background writer thread (video recording + snapshots).
mx::Font overlayFont
mx::Model cube
TextureUploader tex_uploader
std::vector< int > saved_pass_list
int gpu_alpha_dir
bool audio_is_enabled
bool needsAsyncShutdown()
bool waveActive
int hdr_resource_w
Width of HDR intermediate/encoded textures.
int autopilot_random_timeout
Inclusive upper bound for random autopilot interval.
bool runComputePass(gl::GLWindow *win, size_t shader_index, GLuint input_texture, GLuint output_texture)
std::atomic< bool > writerRunning
bool needsTransferAudio()
Determine if source-track audio copy should run at shutdown.
void emitSilentVideoProgress(bool complete)
Emit progress for headless video rendering.
int hdr_resource_h
Height of HDR intermediate/encoded textures.
std::mutex queueMutex
ACView(const MXArguments &args)
Construct the rendering object from parsed CLI arguments.
int camera_index
GLuint preOverlayTexture
Snapshot of fboTexture taken before drawing the watermark/display-filter overlay, used to restore the...
int input_hdr_trc
AVColorTransferCharacteristic (PQ/HLG/BT2020).
gl::ShaderProgram hdr_encode_shader
Linear BT.2020 -> PQ (or HLG) fullscreen pass.
gl::GLSprite muxOverlaySprite
std::queue< cv::Mat > captureQueue
void applyCrossfade(gl::GLWindow *win, GLuint currentTexture)
Render the crossfade blend for the current frame.
void requestStop()
bool saved_pass_enabled_before_random
std::chrono::steady_clock::time_point crossfadeStartTime
Wall-clock time the current crossfade began.
double getFps() const
const std::vector< uint8_t > & getHdrMasteringDisplay() const
bool readHdr(cv::Mat &out_rgba16)
AVFrame * decoded_frame
AVPixelFormat sws_src_format_hdr
AVCodecContext * codec_ctx
int getHdrPrimaries() const
std::vector< uint8_t > hdr_content_light
AVFormatContext * format_ctx
bool isHwDecodeEnabled() const
bool read(cv::Mat &out_bgr)
SwsContext * sws_ctx
bool skip()
Decode and discard one frame without transferring or converting it.
static AVPixelFormat getHwFormat(AVCodecContext *ctx, const AVPixelFormat *pix_fmts)
int getHdrBitDepth() const
void enableCudaHwDecode(const AVCodec *codec)
int getWidth() const
int getHdrColorRange() const
int getHdrColorspace() const
int getHeight() const
int64_t getCurrentFrame() const
bool isHdr() const
double getFrameCount() const
AVBufferRef * hw_device_ctx
int getHdrTransfer() const
AVPixelFormat sws_src_format
const std::vector< uint8_t > & getHdrContentLight() const
AVPixelFormat hw_pix_fmt
SwsContext * sws_ctx_hdr
Separate SwsContext for the HDR read path (RGBA64LE output).
bool open(const std::string &filename, bool prefer_cuda)
std::vector< uint8_t > hdr_mastering_display
Fixed-capacity ring buffer of GL textures for temporal shaders.
void pushFromTexture(GLuint src_tex, int w, int h)
GPU-side push from an existing GL texture into the next ring slot.
std::vector< GLuint > textures
bool isFull() const
True when the ring is fully populated.
std::size_t size() const
Number of frames currently retained (0 ≤ n ≤ capacity).
std::size_t head
GLuint historyTexture() const
Array texture containing the physical history ring layers.
void pushFromFBO(GLuint src_fbo, int w, int h)
GPU-side push from a source FBO into the next ring slot.
GLuint textureAt(std::size_t index) const
Return the GL texture at logical index index.
GLuint history_texture
Optional array-backed history ring.
void fill(const cv::Mat &frame)
Pre-fill every slot with copies of a single frame.
FrameCache(const FrameCache &)=delete
FrameCache & operator=(const FrameCache &)=delete
std::size_t count
bool hasStorage() const
bool use_history_array
int oldestLayer() const
Physical array layer corresponding to logical history index zero.
void push(const cv::Mat &frame)
Upload frame (BGR) into the next ring slot.
void finishPush()
void allocateHistoryTexture()
Allocate the optional array texture using the cache's dimensions and pixel format.
void init(int w, int h, bool hdr=false)
Allocate the ring of GL textures sized to w x h.
GLuint scratch_fbo
Scratch FBO used by pushFromTexture().
std::size_t num_frames
void cleanup()
Release all GL textures and reset the ring state.
std::size_t capacity() const
Maximum number of frames retained by the ring.
FrameCache(std::size_t num, bool use_array=false)
Primary ACMX desktop UI.
void draw() override
Per-frame callback: clear, draw ACView, swap buffers.
static int eventFilter(void *userdata, SDL_Event *event)
MainWindow(const MXArguments &args, const OpenGLContextConfig &context_config)
Construct a visible (windowed) MainWindow.
~MainWindow() override
void event(SDL_Event &e) override
Placeholder—SDL events are forwarded to ACView::event() by libmx2.
void initCommon(const MXArguments &args)
MainWindow(const MXArguments &args, const OpenGLContextConfig &context_config, bool headless)
Construct a headless (off-screen) MainWindow for silent batch processing.
Manages the complete collection of compiled GLSL shader programs.
void setAudioWarmupEnvelope(float env)
Set startup audio warmup envelope in [0,1] for uniform scaling.
bool timeActive() const
bool get3D() const
Return true if currently in 3D mode.
void loadProgram(gl::GLWindow *win, const std::string text)
Compile a single fragment shader with the 2D (and optionally 3D) vertex shader.
std::string getFullShaderName(const std::vector< int > &pass_list)
Get a display string including the active shader and the pass list.
bool loadFromCache(gl::GLWindow *win, const std::string &library_path, mx::Font &loadingFont, const std::string &vert_2d="", const std::string &vert_3d="")
Attempt to load all shader programs from the binary cache file.
float getAmpUntouched() const
size_t size()
Number of programs in the currently active set (2D or 3D).
bool isCompute(size_t idx) const
Return whether a library slot contains an executable compute program.
bool loadProgramWithSize(gl::ShaderProgram *prog, const std::string &vert_path, const std::string &frag_path) const
Compile a shader from file pair, injecting #define SIZE N into the fragment source.
std::unordered_map< int, ProgramData > program_names_2d
void is3D(bool is3d)
Switch between the 2D and 3D program vectors.
void enableDualMode(bool enable)
Enable or disable dual-mode compilation (2D + 3D vertex shaders).
void setFPS(float fps_value)
Upload the current frame-rate to the active shader's iFrameRate uniform.
void setHistoryHead(int layer)
Set the physical array layer that represents logical history zero.
void setMidiSlider(int idx, float val)
Set a MIDI slider value (index 0–3, value 0.0–1.0).
std::unique_ptr< gl::ShaderProgram > makePassthroughProgram(const std::string &vert_path)
Compile a minimal passthrough fragment shader as a stand-in.
void setSpectrumHistoryHead(int layer)
Set the physical array layer containing the newest FFT frame.
gl::ShaderProgram * getShader(size_t idx)
Get a shader by index from the active set (2D or 3D).
int cache_size
Active runtime size of the texture-cache ring buffer.
std::vector< std::regex > custom_uniform_references
void update(gl::GLWindow *win)
Per-frame update: advance time_f, upload all uniforms to the active shader.
std::string injectShaderSize(const std::string &frag_path, int size, bool use_array) const
Read a fragment shader file and inject #define SIZE N immediately after its #version directive.
void setupProgramUniforms(gl::GLWindow *win, gl::ShaderProgram *prog, std::unordered_map< int, ProgramData > &names, size_t pos, const std::string &text)
Query and store all uniform locations for a compiled shader program.
std::unique_ptr< gl::ShaderProgram > compileProgramForReload(const std::string &vert_path, const std::string &frag_path, std::string &error) const
Compile a replacement program while retaining complete driver diagnostics.
gl::ShaderProgram * getShader3D(size_t idx)
Get a 3D shader by index (regardless of current 2D/3D mode).
void inc()
Advance to the next shader in the library (clamped at the end).
glm::vec4 inc_value
std::vector< std::unique_ptr< gl::ShaderProgram > > programs_3d
void updateShaderUniforms(gl::GLWindow *win, size_t idx)
Upload all uniforms for an arbitrary shader in the active set.
float midi_slider[4]
MIDI CC slider values (0.0–1.0) for shader uniforms slider1–slider4.
float getAmp() const
int audioBufferCount() const
Number of spectrum history array layers currently configured.
std::unordered_map< int, ProgramData > program_names_3d
float audio_warmup_envelope
Startup ramp [0..1] used to soften initial audio-reactive intensity.
bool isCache2D(size_t idx) const
Check whether the 2D shader at idx is a texture-cache shader.
void event(SDL_Event &e)
Reserved for future SDL event handling inside the library.
void dec()
Move to the previous shader in the library (clamped at zero).
size_t size2d()
Number of 2D programs.
std::string getShaderNameByIndex(size_t idx)
Get the stem name of a shader by its numeric index.
int spectrum_history_head
Physical layer containing the newest spectrum.
void loadProgramsWithCache(gl::GLWindow *win, const std::string &text, mx::Font &loadingFont)
Try loading a full shader library from the binary cache, with fallback.
void setUniform(const std::string &name, int value)
Bind a cache sampler slot for texture-cache shaders.
std::unique_ptr< gl::ShaderProgram > makeComputeProgram(const std::string &compute_path, std::string &error) const
Compile and link a standalone OpenGL compute program.
void setVideoFPS(double fps)
Set the frame rate used by normalized time advancement.
void toggleAudioDelta()
Toggle delta-time scaling for audio-reactive mode.
void setTimeSpeed(float speed)
Set the absolute time_f advancement speed multiplier.
void setNormalizedTime(bool enabled)
Select fixed-per-frame or elapsed real-time advancement.
glm::vec4 inc_valuex
size_t size3d()
Number of 3D programs.
uint64_t preparedFragmentHash(const std::string &frag_path) const
glm::vec4 random_var
void uploadAcidCamUniforms(const ProgramData &n, size_t idx)
void decTimeSpeed(float step)
Decrease the time_f speed multiplier by step, clamped to zero.
void setCustomUniformValues(const std::vector< ShaderManifestData::CustomUniform > &uniforms)
bool isCache()
Check whether the currently active shader is a texture-cache shader.
void clear()
Remove all compiled shader programs and reset the library index.
bool isBypassed() const
Return true if shader processing is currently bypassed.
void activeTime(bool t)
Enable or disable automatic wall-clock time advancement.
void enableCache(bool enable)
Enable or disable use of the ac::ShaderProgram binary-cache wrapper.
void useProgram()
Activate the current shader program on the GPU (glUseProgram).
int findShaderByName(const std::string &name)
Look up a shader index by its stem name.
void setAudioBufferCount(int n)
Configure the runtime depth of spectrum_history.
void uploadCustomUniforms(const ProgramData &data) const
Upload configured custom uniforms to the GPU for the given program.
void toggleBypass()
Toggle the shader bypass flag.
bool audioDelta() const
void stepAcidCamUniforms()
Advance acidcamGL-compatible colour-alpha oscillators and random values.
void decTime(float value)
Step time_f backward manually (when auto-time is paused).
bool buildShaderCache(gl::GLWindow *win, const std::string &library_path, const std::string &vert_2d, const std::string &vert_3d, mx::Font *loadingFont=nullptr)
Build the on-disk shader binary cache for all shaders in a library.
bool removeBrokenShaders(gl::GLWindow *win, const std::string &library_path, const std::string &vert_2d, const std::string &vert_3d)
Compile every shader in a library and rewrite its manifest without the ones that fail to compile.
bool reloadProgram(gl::GLWindow *win, size_t shader_index, const std::string &fragment_path, std::string &error)
Recompile one library slot and atomically replace it on success.
std::vector< ShaderProgramKind > program_kinds
void setIndex(size_t i)
Set the active shader by index.
std::vector< std::regex > custom_uniform_declarations
gl::ShaderProgram * shader()
Return a raw pointer to the currently active ShaderProgram.
std::vector< ShaderManifestData::CustomUniform > custom_uniforms
bool dispatchCompute2D(gl::GLWindow *win, size_t idx, GLuint input_texture, GLuint output_texture)
Run a compute library slot as a full-frame image pass.
int audio_buffer_count
Runtime depth of the spectrum history array.
void incTimeSpeed(float step)
Increase the time_f speed multiplier by step.
int cacheSize() const
Active cache ring size as seen by the shader-compile path.
void incTime(float value)
Step time_f forward manually (when auto-time is paused).
size_t index()
Return the current shader index.
void setCacheSize(int size)
Set the active texture-cache ring length.
std::string getFullShaderName()
Get a display string for the currently active shader.
std::unique_ptr< gl::ShaderProgram > makeProgram()
void toggle3D()
Toggle between 2D and 3D rendering modes.
const acmx2::audio::AudioAnalyzer * audio_analyzer
void loadPrograms(gl::GLWindow *win, const std::string &text, mx::Font &loadingFont)
Compile every shader listed in the preferred manifest.
void audioTime(bool t)
Enable or disable audio-reactive time advancement.
void setHistoryTextureArray(bool enabled)
Select the cache sampler representation before compiling shaders.
ShaderLibrary(const acmx2::audio::AudioAnalyzer *analyzer=nullptr)
static std::string shaderInfoLog(GLuint shader)
bool isDualMode() const
Return whether dual mode (2D + 3D) is active.
bool timeAudio() const
std::vector< std::unique_ptr< gl::ShaderProgram > > programs_2d
static std::string shaderCacheFilePath(const std::string &assets_path, const std::string &library_path, int cache_size=8, bool use_array=false)
Resolve the on-disk path for the shader binary cache file.
gl::ShaderProgram * getShader2D(size_t idx)
Get a 2D shader by index (regardless of current 2D/3D mode).
static std::string programInfoLog(GLuint program)
bool compileShaderCacheEntry(const std::string &shader_file, const std::string &full_path, const std::string &vert_2d, const std::string &vert_3d, bool include_3d, ShaderCacheEntry &entry)
Compile one shader and replace a single binary-cache entry.
void updateShaderUniforms2D(gl::GLWindow *win, size_t idx)
Upload all uniforms for a shader in the 2D set specifically.
A fixed-size thread pool used for writing PNG snapshots asynchronously.
bool stop
When true, workers exit after draining the queue.
~SnapshotThreadPool()
Drain remaining tasks and join all worker threads.
std::condition_variable condition
Signalled when a task is enqueued or pool stops.
SnapshotThreadPool(size_t threads)
Construct a pool with a fixed number of worker threads.
std::vector< std::thread > workers
Persistent worker threads.
std::mutex queue_mutex
Protects tasks and stop.
void enqueue(F &&f)
Submit a callable for asynchronous execution.
std::queue< std::function< void()> > tasks
FIFO of pending PNG-write tasks.
Runtime-sized ring buffer stored in one 1D array texture.
static constexpr int TEXTURE_UNIT
Texture unit assigned to the complete spectrum history array.
GLuint texture_id
One GL_TEXTURE_1D_ARRAY object.
int write_idx
Next physical layer to overwrite.
int newestLayer() const
Physical layer containing the newest spectrum frame.
int bins
Texels per layer (== FFT_SIZE / 2).
std::vector< float > scaled_buf
Scratch for sensitivity-scaled magnitudes.
SpectrumHistory(const acmx2::audio::AudioAnalyzer &analyzer)
int layer_count
Runtime-selected array depth.
int count() const
Number of history layers currently allocated.
void cleanup()
Delete the array texture and reset state.
void update(float scale=0.0f)
Push the current FFT magnitudes into the head of the ring buffer.
int init(int requested_count)
Allocate one array texture with requested_count history layers.
const acmx2::audio::AudioAnalyzer & analyzer
void bind() const
Bind the complete spectrum history array to its single texture unit.
Manages a 1D OpenGL texture that holds the FFT frequency-magnitude spectrum.
acmx2::audio::AudioAnalyzer & analyzer
void bind(int unit=SPECTRUM_TEXTURE_UNIT) const
Bind the spectrum texture to a specific texture unit.
void update(float scale)
std::vector< float > scaled_buf
Scratch buffer for sensitivity-scaled magnitudes.
static constexpr int SPECTRUM_TEXTURE_UNIT
Texture unit reserved for the spectrum (units 0–8 are taken).
int bins
Number of texels (== FFT_SIZE / 2).
SpectrumTexture(acmx2::audio::AudioAnalyzer &analyzer)
void update()
Compute a fresh FFT and upload the magnitudes into the texture.
void init()
Create the 1D texture and set its sampling parameters.
void cleanup()
Delete the OpenGL texture and reset state.
GLuint textureID
OpenGL name for the 1D texture.
Host-zero-copy CUDA-to-OpenGL texture transfer via direct image interop.
int height
Current texture height in pixels.
GLuint textureID
OpenGL texture receiving the frame data.
int width
Current texture width in pixels.
void cleanup()
Release all GPU resources (CUDA registration, stream, texture).
void init(int w, int h)
Create (or recreate) the GL texture and CUDA image registration.
Owns the audio-reactive analysis state shared by live and file audio.
const std::vector< float > & spectrum() const
void set_sensitivity(float sensitivity)
unsigned int sample_rate() const
AudioMetrics metrics() const
static constexpr int spectrum_bin_count()
void set_sample_rate(unsigned int sample_rate)
RAII owner for the RtAudio stream, analysis pipeline, and WAV recorder.
AudioRecorder & recorder()
AudioAnalyzer & analyzer()
unsigned int input_channels() const
bool open(const AudioStreamConfig &config)
cv::Mat hardenedAlphaMask(const cv::Mat &image, const cv::Mat &mask, float blackPoint, float whitePoint)
Definition dnn.cpp:690
cv::Mat isolateBody(const cv::Mat &image, const cv::Mat &mask, float blackPoint, float whitePoint)
Definition dnn.cpp:698
constexpr int AC_FILTER_MAX
Filter filters[1]
bool loadComputeProgramBinaryFromCache(const std::string &source, GLuint &program)
Restore a compute-only program binary keyed by source and GL driver.
Definition program.cpp:241
bool saveComputeProgramBinaryToCache(const std::string &source, GLuint program)
Save a linked compute-only program to its stage-specific cache key.
Definition program.cpp:243
constexpr std::size_t FFT_SIZE
constexpr std::uint32_t kShaderSelectionMagic
constexpr std::uint32_t kShaderSelectionMaxShaderName
constexpr std::uint32_t kShaderSelectionMaxPassCount
constexpr std::uint32_t kShaderSelectionMaxUniformName
constexpr std::uint32_t kShaderSelectionVersion
constexpr std::uint32_t kShaderSelectionMaxWatermarkText
constexpr std::uint32_t kShaderSelectionMaxReloadPath
constexpr const char * kShaderSelectionShmName
constexpr const char * kShaderSelectionSemaphoreName
constexpr std::uint32_t kShaderSelectionMaxGpuFilterCount
constexpr std::uint32_t kShaderSelectionMaxCustomUniforms
constexpr std::uint32_t kShaderSelectionMaxAudioFilePath
std::atomic< bool > g_shutdown_requested
void printSection(Stream &out, const CliColors &c, std::string_view name, const std::vector< HelpEntry > &entries)
void printDetailedArguments(Stream &out)
void printKeyboardControls(Stream &out)
unsigned char b1
unsigned char b2
unsigned char b0
std::vector< int > shader_indices
A captured RGBA pixel buffer queued for the writer thread.
bool isHdr
True when pixels holds 16-bit PQ/HLG-encoded BT.2020 RGBA (8 bytes/pixel).
int width
Frame width in pixels.
bool isWebPSnapshot
True if this frame should be saved as a WebP snapshot.
bool isTiffSnapshot
True if this frame should be saved as a TIFF snapshot (16-bit HDR, 8-bit SDR).
int hdrTrc
AVColorTransferCharacteristic (PQ=16, HLG=18) when isHdr.
bool usesTimelineClock
Frame timing follows an external real-time clock.
std::vector< unsigned char > pixels
RGBA pixel data (SDR 8-bit RGBA, or HDR 16-bit RGBA when isHdr).
bool isRawSnapshot
True if this frame should be saved as a raw RGBA file.
bool isSnapshot
True if this frame should be saved as a PNG snapshot.
uint64_t timelineFrame
Clock position expressed in nominal video frames.
int height
Frame height in pixels.
Parsed command-line arguments for the ACMX2 application.
float cross_fade_duration
Crossfade duration in seconds when switching playlist shaders (default: 0.5).
float human_black
–black: mask black point (shadow crush threshold).
bool remove_broken
True when --remove-broken <path> was specified.
std::optional< cv::Size > sizev
std::string audio_file
std::vector< int > gpu_filter_indices
int autopilot_random_timeout
Upper bound (inclusive) for randomized autopilot interval.
FrameRotation frame_rotation
Optional input-frame rotation.
bool no_drop
In video mode, pace frame production to encoder throughput.
std::string graphic_file
bool use_shader_cache
std::string watermark_text
User watermark text (–use-watermark). When non-empty, watermark is enabled.
double max_size_mb
unsigned int audio_channels
std::string library
std::string edge_model
Dexined edge-detection ONNX model path (–edge). Empty when disabled.
bool flip_output
Vertical flip output frames when set (e.g., for HDR correction).
std::string remove_broken_path
Library path passed to --remove-broken.
std::string filename
bool shader_pass_enabled
std::string record_audio_file
bool audio_repeat
When true, restart file audio from the beginning at EOF.
std::string build_library_path
int gpu_frame_buffer_size
float audio_warm_rate
Startup warmup envelope rate (1/sec). 0.5 ~= 2s to full strength.
float audio_sensitivty
bool human_background_only
When true (with –human), shaders apply only to the background; person composited on top.
bool audio_pass_through
std::string ofilename
bool autopilot_random_interval
When true, randomize autopilot frame interval after each switch.
std::vector< std::string > shader_pass_files
std::string fragment
std::tuple< int, std::string, int > slib
int watermark_g
Watermark green channel (0-255).
std::string playlist_file
std::string model_file
float human_white
–white: mask white point (opacity saturation threshold).
int audio_buffers
Requested spectrum-history array depth.
EncodeOptions encode_opts
std::string onnx_model
Generic OnnxWrapper YAML config path (–onnx). Empty when disabled.
bool png_output
Video-file mode only: write PNG frames to a subdirectory instead of encoding video.
std::string shader_file
std::vector< int > shader_pass_list
std::string prefix_path
int watermark_b
Watermark blue channel (0-255).
int autopilot_frames
Frames between random shader switches in autopilot mode (0 = disabled).
std::string human_model
PPHS human-segmentation ONNX model path (–human). Empty when disabled.
std::string midi_map_file
bool gpu_filter_enabled
bool audio_trunc
When true, stop playback when file audio reaches the end.
int watermark_r
Watermark red channel (0-255), default magenta-pink.
std::string path
bool display_filter
Display current shader/stack and GPU filter overlay in upper-left.
std::string crf
bool interface_shm
Enable interface shared-memory control channel (Qt launcher use).
int generate_interval
Save a PNG frame every N frames to a subdirectory (video or camera mode, 0 = disabled).
std::optional< cv::Size > csize
One shader's precompiled binary data for the on-disk cache.
uint64_t source_hash
FNV-1a-64 hash of the fragment source.
std::vector< char > binary_2d
GL program binary (2D vertex shader).
GLenum format_2d
GL binary format token for 2D.
GLenum format_3d
GL binary format token for 3D.
ShaderProgramKind kind
bool failed
True if this shader failed to compile; a passthrough program is substituted at load time to preserve ...
std::string shader_name
Stem of the .glsl filename.
std::vector< char > binary_3d
GL program binary (3D vertex shader).
Container for the full on-disk shader cache file.
static constexpr uint32_t CACHE_VERSION
Current format version.
bool load(const std::string &path)
Deserialise the shader cache from a binary file.
std::vector< ShaderCacheEntry > entries
std::string gl_version
static constexpr uint32_t CACHE_MAGIC
File magic: "SHDR".
std::string gl_renderer
bool save(const std::string &path) const
Serialise the entire shader cache to a binary file.
Cached uniform locations for a single compiled shader program.
std::vector< GLint > texture_array_loc
GLint slider_loc[4]
Locations of optional uniform float slider1..slider4;.
GLint spectrum_history_head_loc
Newest physical array layer.
GLint spectrum_zero_loc
Current-frame spectrum0 alias.
GLint history_head_loc
Location of the array ring's oldest physical layer.
std::vector< GLint > custom_uniform_locs
GLint spectrum_history_size_loc
Runtime array depth.
GLint spectrum_loc
Live sampler1D spectrum.
GLint history_loc
Location of optional uniform sampler2DArray history;.
GLint spectrum_history_loc
sampler1DArray spectrum_history.
GLint texture_array_base_loc
Base location of textures[0] for bulk glUniform1iv assignment.
std::vector< std::string > entries
ShaderManifestFormat format
std::vector< CustomUniform > custom_uniforms
std::string name
char selected_shader_name[kShaderSelectionMaxShaderName]
std::int32_t gpu_filter_indices[kShaderSelectionMaxGpuFilterCount]
char audio_file_path[kShaderSelectionMaxAudioFilePath]
char shader_pass_names[kShaderSelectionMaxPassCount][kShaderSelectionMaxShaderName]
char custom_uniform_names[kShaderSelectionMaxCustomUniforms][kShaderSelectionMaxUniformName]
char reload_shader_path[kShaderSelectionMaxReloadPath]
std::int32_t shader_pass_indices[kShaderSelectionMaxPassCount]
char watermark_text[kShaderSelectionMaxWatermarkText]
float custom_uniform_values[kShaderSelectionMaxCustomUniforms]