Skip to content

Commit 5ac482c

Browse files
authored
Harden project editing reliability (#74)
* Harden project editing persistence * Fix build verification paths * Address project reliability review
1 parent 231d6f4 commit 5ac482c

11 files changed

Lines changed: 436 additions & 62 deletions

.github/workflows/build.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,7 @@ jobs:
127127
128128
- name: Test (Linux)
129129
if: matrix.platform == 'linux'
130-
run: cd build && ctest --output-on-failure
130+
run: ctest --test-dir .build_tmp/arch/Release --output-on-failure
131131
env:
132132
QT_QPA_PLATFORM: offscreen
133133

@@ -139,7 +139,7 @@ jobs:
139139
140140
- name: Test (macOS)
141141
if: matrix.platform == 'macos'
142-
run: cd .build_tmp/xcode/Release && ctest --output-on-failure
142+
run: ctest --test-dir .build_tmp/xcode/Release --output-on-failure
143143
env:
144144
QT_QPA_PLATFORM: offscreen
145145

@@ -155,7 +155,7 @@ jobs:
155155
- name: Test (Windows)
156156
if: matrix.platform == 'windows'
157157
shell: msys2 {0}
158-
run: cd build && ctest --output-on-failure
158+
run: ctest --test-dir .build_tmp/msys2/Release --output-on-failure
159159
env:
160160
QT_QPA_PLATFORM: offscreen
161161

CMakeLists.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
cmake_minimum_required(VERSION 3.21)
2-
project(AviQtl VERSION 0.4.0 LANGUAGES CXX C)
2+
project(AviQtl VERSION 0.5.6 LANGUAGES CXX C)
33

44
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/bin)
55
set(QT_QML_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/qml)

check.py

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -241,13 +241,11 @@ def run_clazy(files: list[Path], args: argparse.Namespace, root: Path) -> int:
241241
print(f"{BOLD}{YELLOW}--- Clazy (Qt Anti-Patterns) ---{RESET}")
242242

243243
if not args.build_dir:
244-
print(f"{RED}Error: --build-dir is required to run Clazy.{RESET}")
245-
return 1
244+
return skip_tool("Clazy", "--build-dir was not provided")
246245

247246
cc_json = Path(args.build_dir) / "compile_commands.json"
248247
if not cc_json.exists():
249-
print(f"{RED}Error: {cc_json} not found.{RESET}")
250-
return 1
248+
return skip_tool("Clazy", f"{cc_json} not found")
251249

252250
if args.full:
253251
clazy_checks = ["level1", "level2"]
@@ -282,8 +280,11 @@ def run_clang_tidy(files: list[Path], args: argparse.Namespace, root: Path) -> i
282280
print(f"{BOLD}{YELLOW}--- Clang-Tidy (General & Static Analyzer) ---{RESET}")
283281

284282
if not args.build_dir:
285-
print(f"{RED}Error: --build-dir is required to run Clang-Tidy.{RESET}")
286-
return 1
283+
return skip_tool("Clang-Tidy", "--build-dir was not provided")
284+
285+
cc_json = Path(args.build_dir) / "compile_commands.json"
286+
if not cc_json.exists():
287+
return skip_tool("Clang-Tidy", f"{cc_json} not found")
287288

288289
# Include Qt-specific checks
289290
if args.full:
@@ -312,12 +313,12 @@ def run_clang_tidy(files: list[Path], args: argparse.Namespace, root: Path) -> i
312313
def print_summary(returncode: int, xml_path: Path | None) -> None:
313314
print()
314315
if returncode == 0:
315-
if NON_BLOCKING_WARNINGS:
316-
print(f"{BOLD}{GREEN} Quality checks complete: No blocking errors{RESET}")
316+
if NON_BLOCKING_WARNINGS or SKIPPED_TOOLS:
317+
print(f"{BOLD}{GREEN}OK Quality checks complete: No blocking errors{RESET}")
317318
else:
318-
print(f"{BOLD}{GREEN} Quality checks complete: No reported issues{RESET}")
319+
print(f"{BOLD}{GREEN}OK Quality checks complete: No reported issues{RESET}")
319320
else:
320-
print(f"{BOLD}{RED} Quality checks complete: Warnings/errors found (count {returncode}){RESET}")
321+
print(f"{BOLD}{RED}FAILED Quality checks complete: Warnings/errors found (count {returncode}){RESET}")
321322
if SKIPPED_TOOLS:
322323
print(f"{YELLOW} Skipped: {', '.join(SKIPPED_TOOLS)}{RESET}")
323324
if NON_BLOCKING_WARNINGS:
@@ -327,6 +328,11 @@ def print_summary(returncode: int, xml_path: Path | None) -> None:
327328
print(f"{CYAN} XML Report: {xml_path}{RESET}")
328329

329330
def main() -> int:
331+
if hasattr(sys.stdout, "reconfigure"):
332+
sys.stdout.reconfigure(errors="replace")
333+
if hasattr(sys.stderr, "reconfigure"):
334+
sys.stderr.reconfigure(errors="replace")
335+
330336
parser = argparse.ArgumentParser(
331337
description="Run cppcheck across AviQtl project"
332338
)

core/src/project_serializer.cpp

Lines changed: 93 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,15 +14,21 @@
1414
#include <QSaveFile>
1515
#include <QUrl>
1616
#include <algorithm>
17+
#include <cmath>
1718

1819
namespace AviQtl::Core {
1920

20-
inline constexpr int PROJECT_VERSION = 2;
21+
inline constexpr int PROJECT_VERSION = 3;
2122

2223
namespace {
2324
constexpr int kMaxDimension = 32768;
2425
constexpr double kMaxFps = 1000.0;
2526
constexpr int kMaxSampleRate = 192000;
27+
constexpr double kMaxGridBpm = 1000.0;
28+
constexpr double kMaxGridOffset = 86400.0;
29+
constexpr int kMaxGridInterval = 1'000'000;
30+
constexpr int kMaxGridSubdivision = 128;
31+
constexpr int kMaxMagneticSnapRange = 100;
2632

2733
int clampDimension(int value, int fallback) {
2834
return (value <= 0 || value > kMaxDimension) ? fallback : value;
@@ -33,6 +39,15 @@ double clampFps(double value, double fallback) {
3339
int clampSampleRate(int value, int fallback) {
3440
return (value <= 0 || value > kMaxSampleRate) ? fallback : value;
3541
}
42+
double clampPositiveGridValue(double value, double maximum, double fallback) {
43+
return (!std::isfinite(value) || value <= 0.0 || value > maximum) ? fallback : value;
44+
}
45+
double clampGridOffset(double value, double fallback) {
46+
return (!std::isfinite(value) || value < 0.0 || value > kMaxGridOffset) ? fallback : value;
47+
}
48+
int clampPositiveGridValue(int value, int maximum, int fallback) {
49+
return (value <= 0 || value > maximum) ? fallback : value;
50+
}
3651
} // namespace
3752

3853
static QString toRelativePath(const QString &absolutePath, const QString &baseDir) {
@@ -76,6 +91,44 @@ static void convertMediaPaths(QVariantMap &params, const QString &baseDir, bool
7691
}
7792
}
7893

94+
static void convertEffectMediaPath(const QString &effectId, QVariantMap &params, const QString &baseDir, bool toRelative) {
95+
QString pathKey;
96+
if (effectId == QLatin1String("video") || effectId == QLatin1String("image")) {
97+
pathKey = QStringLiteral("path");
98+
} else if (effectId == QLatin1String("audio")) {
99+
pathKey = QStringLiteral("source");
100+
} else {
101+
return;
102+
}
103+
104+
auto pathIt = params.find(pathKey);
105+
if (pathIt == params.end() || pathIt->toString().isEmpty()) {
106+
return;
107+
}
108+
*pathIt = toRelative ? toRelativePath(pathIt->toString(), baseDir) : toAbsolutePath(pathIt->toString(), baseDir);
109+
}
110+
111+
static auto layerSetToJson(const QSet<int> &layers) -> QJsonArray {
112+
QList<int> sortedLayers(layers.cbegin(), layers.cend());
113+
std::sort(sortedLayers.begin(), sortedLayers.end());
114+
QJsonArray result;
115+
for (int layer : std::as_const(sortedLayers)) {
116+
result.append(layer);
117+
}
118+
return result;
119+
}
120+
121+
static auto layerSetFromJson(const QJsonValue &value) -> QSet<int> {
122+
QSet<int> result;
123+
for (const QJsonValue &layer : value.toArray()) {
124+
const int index = layer.toInt(-1);
125+
if (index >= 0 && index <= 127) {
126+
result.insert(index);
127+
}
128+
}
129+
return result;
130+
}
131+
79132
auto ProjectSerializer::save(const QString &fileUrl, const UI::TimelineService *timeline, const UI::ProjectService *project, QString *errorMessage) -> bool {
80133
QString path = QUrl(fileUrl).toLocalFile();
81134
if (path.isEmpty()) {
@@ -104,6 +157,16 @@ auto ProjectSerializer::save(const QString &fileUrl, const UI::TimelineService *
104157
sObj.insert(QStringLiteral("fps"), scene.fps);
105158
sObj.insert(QStringLiteral("start"), scene.startFrame);
106159
sObj.insert(QStringLiteral("duration"), scene.totalFrames);
160+
sObj.insert(QStringLiteral("nestedDuration"), scene.durationFrames);
161+
sObj.insert(QStringLiteral("lockedLayers"), layerSetToJson(scene.lockedLayers));
162+
sObj.insert(QStringLiteral("hiddenLayers"), layerSetToJson(scene.hiddenLayers));
163+
sObj.insert(QStringLiteral("gridMode"), scene.gridMode);
164+
sObj.insert(QStringLiteral("gridBpm"), scene.gridBpm);
165+
sObj.insert(QStringLiteral("gridOffset"), scene.gridOffset);
166+
sObj.insert(QStringLiteral("gridInterval"), scene.gridInterval);
167+
sObj.insert(QStringLiteral("gridSubdivision"), scene.gridSubdivision);
168+
sObj.insert(QStringLiteral("enableSnap"), scene.enableSnap);
169+
sObj.insert(QStringLiteral("magneticSnapRange"), scene.magneticSnapRange);
107170
scenesArray.append(sObj);
108171
}
109172
root.insert(QStringLiteral("scenes"), scenesArray);
@@ -143,7 +206,9 @@ auto ProjectSerializer::save(const QString &fileUrl, const UI::TimelineService *
143206
eObj.insert(QStringLiteral("id"), eff->id());
144207
eObj.insert(QStringLiteral("name"), eff->name());
145208
eObj.insert(QStringLiteral("enabled"), eff->isEnabled());
146-
eObj.insert(QStringLiteral("params"), QJsonObject::fromVariantMap(eff->params()));
209+
QVariantMap effectParams = eff->params();
210+
convertEffectMediaPath(eff->id(), effectParams, projectDir, true);
211+
eObj.insert(QStringLiteral("params"), QJsonObject::fromVariantMap(effectParams));
147212
eObj.insert(QStringLiteral("keyframes"), QJsonObject::fromVariantMap(eff->keyframeTracks()));
148213
effArray.append(eObj);
149214
}
@@ -194,8 +259,15 @@ auto ProjectSerializer::load(const QString &fileUrl, UI::TimelineService *timeli
194259
return false;
195260
}
196261

197-
auto jsonData = file.readAll();
198-
QJsonDocument doc = QJsonDocument::fromJson(jsonData);
262+
const QByteArray jsonData = file.readAll();
263+
QJsonParseError parseError;
264+
const QJsonDocument doc = QJsonDocument::fromJson(jsonData, &parseError);
265+
if (parseError.error != QJsonParseError::NoError || !doc.isObject()) {
266+
if (errorMessage != nullptr) {
267+
*errorMessage = parseError.error != QJsonParseError::NoError ? parseError.errorString() : QStringLiteral("Project root must be a JSON object");
268+
}
269+
return false;
270+
}
199271
QJsonObject root = doc.object();
200272

201273
const int version = root.value(QStringLiteral("version")).toInt(1);
@@ -237,6 +309,18 @@ auto ProjectSerializer::load(const QString &fileUrl, UI::TimelineService *timeli
237309
scene.startFrame = sobj.value(QStringLiteral("start")).toInt(0);
238310
const int totalFrames = sobj.value(QStringLiteral("duration")).toInt(AviQtl::kDefaultTotalFrames);
239311
scene.totalFrames = totalFrames > 0 ? totalFrames : AviQtl::kDefaultTotalFrames;
312+
if (version >= 3) {
313+
scene.durationFrames = std::max(0, sobj.value(QStringLiteral("nestedDuration")).toInt(0));
314+
scene.lockedLayers = layerSetFromJson(sobj.value(QStringLiteral("lockedLayers")));
315+
scene.hiddenLayers = layerSetFromJson(sobj.value(QStringLiteral("hiddenLayers")));
316+
scene.gridMode = sobj.value(QStringLiteral("gridMode")).toString(QStringLiteral("Auto"));
317+
scene.gridBpm = clampPositiveGridValue(sobj.value(QStringLiteral("gridBpm")).toDouble(120.0), kMaxGridBpm, 120.0);
318+
scene.gridOffset = clampGridOffset(sobj.value(QStringLiteral("gridOffset")).toDouble(0.0), 0.0);
319+
scene.gridInterval = clampPositiveGridValue(sobj.value(QStringLiteral("gridInterval")).toInt(10), kMaxGridInterval, 10);
320+
scene.gridSubdivision = clampPositiveGridValue(sobj.value(QStringLiteral("gridSubdivision")).toInt(4), kMaxGridSubdivision, 4);
321+
scene.enableSnap = sobj.value(QStringLiteral("enableSnap")).toBool(true);
322+
scene.magneticSnapRange = clampPositiveGridValue(sobj.value(QStringLiteral("magneticSnapRange")).toInt(10), kMaxMagneticSnapRange, 10);
323+
}
240324
tempScenes.append(scene);
241325
maxSceneId = std::max(scene.id, maxSceneId);
242326
}
@@ -292,7 +376,11 @@ auto ProjectSerializer::load(const QString &fileUrl, UI::TimelineService *timeli
292376
continue;
293377
}
294378
QString displayName = meta.name.isEmpty() ? eObj.value(QStringLiteral("name")).toString() : meta.name;
295-
auto *eff = new UI::EffectModel(effId, displayName, meta.kind, meta.categories, eObj.value(QStringLiteral("params")).toObject().toVariantMap(), meta.qmlSource, meta.uiDefinition, timeline);
379+
QVariantMap effectParams = eObj.value(QStringLiteral("params")).toObject().toVariantMap();
380+
if (version >= 2) {
381+
convertEffectMediaPath(effId, effectParams, projectDir, false);
382+
}
383+
auto *eff = new UI::EffectModel(effId, displayName, meta.kind, meta.categories, effectParams, meta.qmlSource, meta.uiDefinition, timeline);
296384
eff->setEnabled(eObj.value(QStringLiteral("enabled")).toBool(true));
297385
auto it = eObj.find(QStringLiteral("keyframes"));
298386
if (it != eObj.end()) {

0 commit comments

Comments
 (0)