ACMX 2.139.0
Dual-Backend Real-Time GPU Video Synthesis
Loading...
Searching...
No Matches
library-builder.cpp
Go to the documentation of this file.
1#include "library-builder.hpp"
2
4#include "custom_style.hpp"
5#include "shader-manifest.hpp"
6
7#include <QCheckBox>
8#include <QDialogButtonBox>
9#include <QDir>
10#include <QDirIterator>
11#include <QFile>
12#include <QFileDialog>
13#include <QFileInfo>
14#include <QFutureWatcher>
15#include <QHBoxLayout>
16#include <QLabel>
17#include <QListWidget>
18#include <QMessageBox>
19#include <QPushButton>
20#include <QSaveFile>
21#include <QSettings>
22#include <QTemporaryDir>
23#include <QVBoxLayout>
24#include <QtConcurrent>
25
26namespace {
27 constexpr int SOURCE_PATH_ROLE = Qt::UserRole;
28
29 struct ExportResult {
30 bool success = false;
31 QStringList exportedNames;
32 QString error;
33 };
34
35 bool is_shader_file(const QFileInfo &fileInfo, acmx2::Backend backend) {
36 const QString suffix = fileInfo.suffix();
37 if (!fileInfo.isFile() || !fileInfo.isReadable())
38 return false;
39 if (suffix.compare("comp", Qt::CaseInsensitive) == 0)
40 return true;
41 return suffix.compare(backend == acmx2::Backend::Acmxvk ? "frag" : "glsl", Qt::CaseInsensitive) == 0;
42 }
43
44 QString normalized_path(const QString &path) {
45 QFileInfo fileInfo(path);
46 const QString canonicalPath = fileInfo.canonicalFilePath();
47 return canonicalPath.isEmpty() ? fileInfo.absoluteFilePath() : canonicalPath;
48 }
49
50 QString unique_export_name(const QString &fileName, const QStringList &usedNames) {
51 if (!usedNames.contains(fileName, Qt::CaseInsensitive))
52 return fileName;
53
54 const QFileInfo fileInfo(fileName);
55 const QString stem = fileInfo.completeBaseName();
56 const QString suffix = fileInfo.suffix();
57 for (int number = 2;; ++number) {
58 const QString candidate = QString("%1-%2.%3").arg(stem).arg(number).arg(suffix);
59 if (!usedNames.contains(candidate, Qt::CaseInsensitive))
60 return candidate;
61 }
62 }
63
64 bool copy_shader(const QString &sourcePath, const QString &destinationPath, QString &error) {
65 if (normalized_path(sourcePath) == normalized_path(destinationPath))
66 return true;
67
68 QFile source(sourcePath);
69 if (!source.open(QIODevice::ReadOnly)) {
70 error = QObject::tr("Could not read %1: %2").arg(sourcePath, source.errorString());
71 return false;
72 }
73
74 QSaveFile destination(destinationPath);
75 if (!destination.open(QIODevice::WriteOnly)) {
76 error = QObject::tr("Could not write %1: %2").arg(destinationPath, destination.errorString());
77 return false;
78 }
79
80 while (!source.atEnd()) {
81 const QByteArray block = source.read(64 * 1024);
82 if (block.isEmpty() && source.error() != QFile::NoError) {
83 error = QObject::tr("Could not finish reading %1: %2").arg(sourcePath, source.errorString());
84 destination.cancelWriting();
85 return false;
86 }
87 if (destination.write(block) != block.size()) {
88 error = QObject::tr("Could not finish writing %1: %2").arg(destinationPath, destination.errorString());
89 destination.cancelWriting();
90 return false;
91 }
92 }
93
94 if (!destination.commit()) {
95 error = QObject::tr("Could not finish writing %1: %2").arg(destinationPath, destination.errorString());
96 return false;
97 }
98 return true;
99 }
100
101 ExportResult export_shader_library(const QString &directory, bool replacingLibrary, const QStringList &sourcePaths, acmx2::Backend backend) {
102 ExportResult result;
103 QStringList usedFragmentNames;
104 QStringList usedComputeNames;
105 if (!replacingLibrary) {
106 auto reserveExisting = [&](const QDir &outputDirectory, QStringList &usedNames, bool computeDirectory) {
107 const QStringList existingFiles = outputDirectory.entryList(QDir::Files);
108 for (const QString &existingFile : existingFiles) {
109 const QFileInfo existingInfo(outputDirectory.filePath(existingFile));
110 if (!is_shader_file(existingInfo, backend))
111 continue;
112 if (backend == acmx2::Backend::Acmxvk) {
113 const bool compute = existingInfo.suffix().compare("comp", Qt::CaseInsensitive) == 0;
114 if (compute != computeDirectory)
115 continue;
116 }
117 bool isSelectedSource = false;
118 for (const QString &sourcePath : sourcePaths) {
119 if (normalized_path(existingInfo.absoluteFilePath()) == normalized_path(sourcePath)) {
120 isSelectedSource = true;
121 break;
122 }
123 }
124 if (!isSelectedSource)
125 usedNames.append(existingFile);
126 }
127 };
128 reserveExisting(QDir(directory), usedFragmentNames, false);
129 if (backend == acmx2::Backend::Acmxvk)
130 reserveExisting(QDir(QDir(directory).filePath("compute")), usedComputeNames, true);
131 }
132
133 QTemporaryDir stagingDirectory;
134 if (backend == acmx2::Backend::Acmxvk && !stagingDirectory.isValid()) {
135 result.error = QObject::tr("Could not create a temporary ACMXVK manifest directory.");
136 return result;
137 }
138
139 for (const QString &sourcePath : sourcePaths) {
140 const QFileInfo sourceInfo(sourcePath);
141 if (!is_shader_file(sourceInfo, backend)) {
142 result.error = QObject::tr("A source shader is now missing or unreadable:\n%1").arg(QDir::toNativeSeparators(sourcePath));
143 return result;
144 }
145
146 const bool compute = sourceInfo.suffix().compare("comp", Qt::CaseInsensitive) == 0;
147 QStringList &usedNames = backend == acmx2::Backend::Acmxvk && compute ? usedComputeNames : usedFragmentNames;
148 const QString preferredName = backend == acmx2::Backend::Acmxvk ? sourceInfo.completeBaseName() + (compute ? QStringLiteral(".comp") : QStringLiteral(".frag")) : sourceInfo.fileName();
149 const QString exportName = unique_export_name(preferredName, usedNames);
150 const QString relativeName = backend == acmx2::Backend::Acmxvk && compute ? QStringLiteral("compute/") + exportName : exportName;
151 const QString destinationPath = QDir(directory).filePath(relativeName);
152 if (!QDir().mkpath(QFileInfo(destinationPath).absolutePath())) {
153 result.error = QObject::tr("Could not create shader directory: %1").arg(QFileInfo(destinationPath).absolutePath());
154 return result;
155 }
156 if (!copy_shader(sourcePath, destinationPath, result.error))
157 return result;
158 result.exportedNames.append(relativeName);
159 usedNames.append(exportName);
160
161 if (backend == acmx2::Backend::Acmxvk) {
162 const QString stagingPath = QDir(stagingDirectory.path()).filePath(relativeName);
163 if (!QDir().mkpath(QFileInfo(stagingPath).absolutePath())) {
164 result.error = QObject::tr("Could not create temporary shader directory: %1").arg(QFileInfo(stagingPath).absolutePath());
165 return result;
166 }
167 if (!copy_shader(sourcePath, stagingPath, result.error))
168 return result;
169 }
170 }
171
172 if (backend == acmx2::Backend::Acmxvk) {
174 if (!acmx2::create_acmxvk_source_manifest(stagingDirectory.path(), QDir(directory).filePath(QStringLiteral("library.json")), manifestResult, result.error)) {
175 return result;
176 }
177 } else {
179 return result;
180 }
181 }
182
183 result.success = true;
184 return result;
185 }
186} // namespace
187
189 setWindowTitle(tr("%1 Shader Library Builder").arg(acmx2::backend_name(backend)));
190 setMinimumSize(720, 520);
191
192 auto *layout = new QVBoxLayout(this);
193 auto *intro = new QLabel(this);
194 intro->setText(backend == acmx2::Backend::Acmxvk ? tr("Add Vulkan fragment (.frag) and compute (.comp) sources. "
195 "Compute files export beneath compute/, and the native ACMXVK "
196 "generator creates a source library.json with custom-uniform "
197 "metadata.")
198 : tr("Add fragment (.glsl) and compute (.comp) shaders. The list "
199 "stays sorted automatically and exports as a portable "
200 "library.json."));
201 intro->setWordWrap(true);
202 layout->addWidget(intro);
203
204 auto *addRow = new QHBoxLayout();
205 auto *addFilesButton = new QPushButton(tr("Add Files..."), this);
206 auto *addFolderButton = new QPushButton(tr("Add Folder..."), this);
207 auto *openButton = new QPushButton(tr("Open Library..."), this);
208 recursiveCheck = new QCheckBox(tr("Include subfolders"), this);
209 recursiveCheck->setChecked(true);
210 addRow->addWidget(addFilesButton);
211 addRow->addWidget(addFolderButton);
212 addRow->addWidget(openButton);
213 addRow->addStretch();
214 addRow->addWidget(recursiveCheck);
215 layout->addLayout(addRow);
216
217 shaderList = new QListWidget(this);
218 shaderList->setAlternatingRowColors(false);
219 shaderList->setSortingEnabled(true);
220 shaderList->setSelectionMode(QAbstractItemView::ExtendedSelection);
221 shaderList->setToolTip(tr("Shaders are kept in alphabetical order."));
222 layout->addWidget(shaderList, 1);
223
224 auto *editRow = new QHBoxLayout();
225 removeButton = new QPushButton(tr("Remove"), this);
226 auto *clearButton = new QPushButton(tr("Clear"), this);
227 editRow->addWidget(removeButton);
228 editRow->addStretch();
229 editRow->addWidget(clearButton);
230 layout->addLayout(editRow);
231
232 summaryLabel = new QLabel(this);
233 layout->addWidget(summaryLabel);
234
235 auto *buttonBox = new QDialogButtonBox(QDialogButtonBox::Close, this);
236 exportButton = buttonBox->addButton(tr("Export Library..."), QDialogButtonBox::ActionRole);
237 layout->addWidget(buttonBox);
238
239 connect(addFilesButton, &QPushButton::clicked, this, &LibraryBuilderDialog::addFiles);
240 connect(addFolderButton, &QPushButton::clicked, this, &LibraryBuilderDialog::addFolder);
241 connect(openButton, &QPushButton::clicked, this, &LibraryBuilderDialog::openLibrary);
242 connect(removeButton, &QPushButton::clicked, this, &LibraryBuilderDialog::removeSelected);
243 connect(clearButton, &QPushButton::clicked, this, &LibraryBuilderDialog::clearShaders);
244 connect(exportButton, &QPushButton::clicked, this, &LibraryBuilderDialog::exportLibrary);
245 connect(buttonBox, &QDialogButtonBox::rejected, this, &QDialog::reject);
246 connect(shaderList, &QListWidget::itemSelectionChanged, this, &LibraryBuilderDialog::updateControls);
247
250}
251
253
255 return backend == acmx2::Backend::Acmxvk ? tr("Shader files (*.frag *.comp);;Fragment shaders "
256 "(*.frag);;Compute shaders (*.comp)")
257 : tr("Shader files (*.glsl *.comp);;Fragment shaders "
258 "(*.glsl);;Compute shaders (*.comp)");
259}
260
262 QSettings settings("LostSideDead");
263 const QString sourceKey = acmx2::backend_settings_key(backend, "library_builder_source_dir");
264 const QString startDir = settings.value(sourceKey).toString();
265 const QStringList files = QFileDialog::getOpenFileNames(this, tr("Add Shader Files"), startDir, shaderFilter());
266 if (files.isEmpty())
267 return;
268
269 settings.setValue(sourceKey, QFileInfo(files.first()).absolutePath());
270 addShaderFiles(files);
271}
272
274 QSettings settings("LostSideDead");
275 const QString sourceKey = acmx2::backend_settings_key(backend, "library_builder_source_dir");
276 const QString startDir = settings.value(sourceKey).toString();
277 const QString directory = QFileDialog::getExistingDirectory(this, tr("Add Shader Folder"), startDir);
278 if (directory.isEmpty())
279 return;
280
281 settings.setValue(sourceKey, directory);
282 const auto flags = recursiveCheck->isChecked() ? QDirIterator::Subdirectories : QDirIterator::NoIteratorFlags;
283 const QStringList nameFilters = backend == acmx2::Backend::Acmxvk ? QStringList{QStringLiteral("*.frag"), QStringLiteral("*.comp")} : QStringList{QStringLiteral("*.glsl"), QStringLiteral("*.comp")};
284 QDirIterator iterator(directory, nameFilters, QDir::Files, flags);
285 QStringList files;
286 while (iterator.hasNext())
287 files.append(iterator.next());
288 files.sort(Qt::CaseInsensitive);
289
290 const int added = addShaderFiles(files);
291 if (files.isEmpty()) {
292 QMessageBox::information(this, tr("No Shaders Found"), (backend == acmx2::Backend::Acmxvk ? tr("No .frag or .comp files were found in %1.") : tr("No .glsl or .comp files were found in %1.")).arg(QDir::toNativeSeparators(directory)));
293 } else if (added == 0) {
294 QMessageBox::information(this, tr("No Shaders Added"), tr("All shaders in that folder are already in the list."));
295 }
296}
297
299 QSettings settings("LostSideDead");
300 const QString sourceKey = acmx2::backend_settings_key(backend, "library_builder_source_dir");
301 const QString startDir = settings.value(sourceKey).toString();
302 const QString directory = QFileDialog::getExistingDirectory(this, tr("Open Shader Library"), startDir);
303 if (directory.isEmpty())
304 return;
305
306 QString metadataError;
307 const std::optional<acmx2::Backend> manifestBackend = acmx2::shader_manifest_backend(directory, metadataError);
308 if (!metadataError.isEmpty()) {
309 QMessageBox::critical(this, tr("Could Not Open Library"), metadataError);
310 return;
311 }
312 if (manifestBackend && *manifestBackend != backend) {
313 QMessageBox::warning(this, tr("Wrong Library Backend"), tr("This library targets %1, but the builder is in %2 mode.").arg(acmx2::backend_name(*manifestBackend), acmx2::backend_name(backend)));
314 return;
315 }
317 const std::optional<acmx2::ShaderLibraryType> libraryType = acmx2::shader_manifest_library_type(directory, metadataError);
318 if (!metadataError.isEmpty()) {
319 QMessageBox::critical(this, tr("Could Not Open Library"), metadataError);
320 return;
321 }
322 if (libraryType && *libraryType == acmx2::ShaderLibraryType::Runtime) {
323 QMessageBox::warning(this,
324 tr("Runtime Library Not Supported"),
325 tr("The ACMXVK library builder accepts source libraries with "
326 ".frag and .comp files, not compiled SPIR-V runtime "
327 "libraries."));
328 return;
329 }
330 }
331
332 QStringList shaderNames;
333 QString error;
334 if (!acmx2::load_shader_manifest(directory, shaderNames, error)) {
335 QMessageBox::critical(this, tr("Could Not Open Library"), error);
336 return;
337 }
338
339 if (shaderList->count() > 0 && QMessageBox::question(this, tr("Replace Current List"), tr("Replace the current shader list with this library?")) != QMessageBox::Yes) {
340 return;
341 }
342
343 shaderList->clear();
344 QStringList missing;
345 for (const QString &shaderName : shaderNames) {
346 const QString path = QDir(directory).filePath(shaderName);
347 if (!addShader(path, false))
348 missing.append(shaderName);
349 }
350 settings.setValue(sourceKey, directory);
352
353 if (!missing.isEmpty()) {
354 QMessageBox::warning(this,
355 tr("Some Shaders Were Skipped"),
356 (backend == acmx2::Backend::Acmxvk ? tr("%1 entries were missing, unreadable, duplicated, or not "
357 ".frag/.comp files.")
358 : tr("%1 entries were missing, unreadable, duplicated, or not "
359 ".glsl/.comp files."))
360 .arg(missing.size()));
361 }
362}
363
364bool LibraryBuilderDialog::addShader(const QString &filePath, bool showErrors) {
365 const QFileInfo fileInfo(filePath);
366 if (!is_shader_file(fileInfo, backend)) {
367 if (showErrors) {
368 QMessageBox::warning(this, tr("Invalid Shader"), (backend == acmx2::Backend::Acmxvk ? tr("%1 is not a readable .frag or .comp file.") : tr("%1 is not a readable .glsl or .comp file.")).arg(QDir::toNativeSeparators(filePath)));
369 }
370 return false;
371 }
372
373 const QString sourcePath = normalized_path(filePath);
374 for (int index = 0; index < shaderList->count(); ++index) {
375 if (shaderList->item(index)->data(SOURCE_PATH_ROLE).toString().compare(sourcePath, Qt::CaseSensitive) == 0) {
376 return false;
377 }
378 }
379
380 const bool compute = fileInfo.suffix().compare("comp", Qt::CaseInsensitive) == 0;
381 auto *item = new QListWidgetItem(QString("%1 [%2]").arg(fileInfo.fileName(), compute ? tr("Compute") : tr("Fragment")), shaderList);
382 item->setData(SOURCE_PATH_ROLE, sourcePath);
383 item->setToolTip(QDir::toNativeSeparators(sourcePath));
384 return true;
385}
386
387int LibraryBuilderDialog::addShaderFiles(const QStringList &filePaths) {
388 int added = 0;
389 for (const QString &filePath : filePaths)
390 added += addShader(filePath, filePaths.size() == 1) ? 1 : 0;
392 return added;
393}
394
396 QStringList paths;
397 for (QListWidgetItem *item : shaderList->selectedItems())
398 paths.append(item->data(SOURCE_PATH_ROLE).toString());
399 return paths;
400}
401
403 const QStringList selectedPaths = selectedSourcePaths();
404 for (int row = shaderList->count() - 1; row >= 0; --row) {
405 if (selectedPaths.contains(shaderList->item(row)->data(SOURCE_PATH_ROLE).toString())) {
406 delete shaderList->takeItem(row);
407 }
408 }
410}
411
413 if (shaderList->count() == 0)
414 return;
415 if (QMessageBox::question(this, tr("Clear Shader List"), tr("Remove all shaders from the builder?")) == QMessageBox::Yes) {
416 shaderList->clear();
418 }
419}
420
422 if (exportInProgress || shaderList->count() == 0)
423 return;
424
425 QSettings settings("LostSideDead");
426 const QString exportKey = acmx2::backend_settings_key(backend, "library_builder_export_dir");
427 const QString startDir = settings.value(exportKey).toString();
428 const QString directory = QFileDialog::getExistingDirectory(this, tr("Choose Library Export Folder"), startDir, QFileDialog::ShowDirsOnly | QFileDialog::DontResolveSymlinks);
429 if (directory.isEmpty())
430 return;
431
432 const QString manifestPath = QDir(directory).filePath("library.json");
433 const bool replacingLibrary = QFileInfo::exists(manifestPath);
434 if (replacingLibrary) {
435 QString backendError;
436 const std::optional<acmx2::Backend> existingBackend = acmx2::shader_manifest_backend(directory, backendError);
437 if (!backendError.isEmpty()) {
438 QMessageBox::critical(this, tr("Invalid Existing Library"), backendError);
439 return;
440 }
441 if (existingBackend && *existingBackend != backend) {
442 QMessageBox::warning(this,
443 tr("Wrong Export Backend"),
444 tr("The selected folder contains a %1 library. Choose a "
445 "different folder for the %2 export.")
446 .arg(acmx2::backend_name(*existingBackend), acmx2::backend_name(backend)));
447 return;
448 }
449 }
450 if (replacingLibrary && QMessageBox::question(this,
451 tr("Replace Existing Library"),
452 tr("This folder already contains library.json. Replace "
453 "the manifest and matching shader files?")) != QMessageBox::Yes) {
454 return;
455 }
456
457 QStringList sourcePaths;
458 for (int row = 0; row < shaderList->count(); ++row) {
459 sourcePaths.append(shaderList->item(row)->data(SOURCE_PATH_ROLE).toString());
460 }
461
462 exportInProgress = true;
464 exportButton->setText(tr("Exporting..."));
465
466 auto *watcher = new QFutureWatcher<ExportResult>(this);
467 connect(watcher, &QFutureWatcher<ExportResult>::finished, this, [this, watcher, directory, exportKey]() {
468 const ExportResult result = watcher->result();
469 watcher->deleteLater();
470 exportInProgress = false;
471 exportButton->setText(tr("Export Library..."));
473
474 if (!result.success) {
475 QMessageBox::critical(this, tr("Export Failed"), result.error);
476 return;
477 }
478
479 QSettings settings("LostSideDead");
480 settings.setValue(exportKey, directory);
481 emit libraryExported(directory);
482 QMessageBox::information(this, tr("Library Exported"), tr("Exported %1 shaders and library.json to:\n%2").arg(result.exportedNames.size()).arg(QDir::toNativeSeparators(directory)));
483 });
484 const acmx2::Backend exportBackend = backend;
485 watcher->setFuture(QtConcurrent::run([directory, replacingLibrary, sourcePaths, exportBackend]() { return export_shader_library(directory, replacingLibrary, sourcePaths, exportBackend); }));
486}
487
489 int fragmentCount = 0;
490 int computeCount = 0;
491 for (int row = 0; row < shaderList->count(); ++row) {
492 const QString path = shaderList->item(row)->data(SOURCE_PATH_ROLE).toString();
493 if (QFileInfo(path).suffix().compare("comp", Qt::CaseInsensitive) == 0)
494 ++computeCount;
495 else
496 ++fragmentCount;
497 }
498
499 summaryLabel->setText(tr("%1 shaders — %2 fragment, %3 compute").arg(shaderList->count()).arg(fragmentCount).arg(computeCount));
500 const bool hasSelection = !shaderList->selectedItems().isEmpty();
501 removeButton->setEnabled(hasSelection);
502 exportButton->setEnabled(!exportInProgress && shaderList->count() > 0);
503}
bool addShader(const QString &filePath, bool showErrors=true)
QString shaderFilter() const
acmx2::Backend selectedBackend() const
Return the backend whose source format this dialog exports.
void libraryExported(const QString &directory)
Emitted after a complete library has been exported successfully.
QStringList selectedSourcePaths() const
int addShaderFiles(const QStringList &filePaths)
LibraryBuilderDialog(acmx2::Backend backend, QWidget *parent=nullptr)
Dialog for assembling portable shader libraries.
std::optional< ShaderLibraryType > shader_manifest_library_type(const QString &directory, QString &error)
Read and validate an optional top-level library_type value.
bool create_acmxvk_source_manifest(const QString &rootDirectory, const QString &outputPath, AcmxvkSourceManifestResult &result, QString &error)
Scan an ACMXVK source tree and atomically generate its library.json.
Backend
Definition backend.hpp:8
QString backend_settings_key(Backend backend, const QString &setting)
Definition backend.hpp:14
void applyCustomStyleIfEnabled(QWidget *widget)
bool load_shader_manifest(const QString &directory, QStringList &shaders, QString &error)
Load shader filenames from the preferred manifest in a directory.
QString backend_name(Backend backend)
Definition backend.hpp:12
bool create_shader_manifest(const QString &directory, ShaderManifestFormat format, const QStringList &shaders, QString &error)
Create a new manifest in the requested format.
std::optional< Backend > shader_manifest_backend(const QString &directory, QString &error)
Read an optional top-level backend hint from library.json.
bool is_shader_file(const QFileInfo &fileInfo, acmx2::Backend backend)
ExportResult export_shader_library(const QString &directory, bool replacingLibrary, const QStringList &sourcePaths, acmx2::Backend backend)
QString unique_export_name(const QString &fileName, const QStringList &usedNames)
bool copy_shader(const QString &sourcePath, const QString &destinationPath, QString &error)
QString normalized_path(const QString &path)
Optional JSON and legacy text shader-library manifests.