Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,7 @@ if(BUILD_EDIT_PATH_RECORDER)
video-path-pilot/job_pipeline.py
video-path-pilot/media_reconstruct.py
video-path-pilot/normalize_sample.py
video-path-pilot/reliability.py
video-path-pilot/validate_sample.py
video-path-pilot/validate_video_path.py
video-path-pilot/sample.schema.json
Expand Down
64 changes: 64 additions & 0 deletions documentation.md
Original file line number Diff line number Diff line change
Expand Up @@ -933,3 +933,67 @@ interactive acceptance, privacy policy, and broader real-project coverage.
Update this file whenever the recorder architecture, event schema, acceptance
results, known limitations, build/run procedure, or roadmap changes. Do not
erase failed experiments; document what was attempted and why it changed.

## Windows reliability and recovery hardening

The July 2026 Windows editor sessions exposed three independent failure
classes. Several launches failed before Kdenlive reached GUI-ready without
retaining the process error; one session emitted thousands of invalid H.264 NAL
unit errors; and several otherwise normal exit-code-zero sessions were rejected
because their recorder stream lacked `session.end`. The optional
`mltopenfx.dll` also failed MLT registration because it did not match the
packaged MLT ABI. The Windows package excludes that optional module until an
ABI-matched build can be verified.

The supervisor now records its editor executable, arguments, QProcess error
text, exit code, and exit-status classification in `session.json`. A missing
`session.end` is treated as a recorder-lifecycle failure rather than proof of a
native crash. On Windows, EditPath configures per-user Windows Error Reporting
LocalDumps for both `EditPath.exe` and `kdenlive.exe`; dump files are written
under the active session's `crash-dumps/` directory. This does not require
administrator access, although a managed Windows policy may disable it.

Kdenlive already saves a modified recorder project every 30 seconds when no
modal dialog is active. EditPath adds an independent recovery layer through
`video-path-pilot/reliability.py`. Once per supervisor heartbeat it validates
`edit.kdenlive`, creates an atomic content-addressed recovery copy when the
project changed, and retains the latest ten periodic copies plus milestone
copies. Synchronous milestone snapshots are attempted when Kdenlive exits and
before dataset finalization. Invalid or truncated XML never replaces a
known-good recovery copy.

Recovery files have this layout:

```text
session_.../
edit.kdenlive
recovery/
manifest.json
project-000001.kdenlive
project-000002.kdenlive
```

`select-recovery` validates the main project, Kdenlive backups, and EditPath
snapshots and reports the newest valid candidate. Recovery evidence is never
removed because rendering, validation, reconstruction, or packaging failed.
Unsaved in-memory changes made less than the Kdenlive save interval before a
hard process kill remain a limitation.

The recorder's **Export Diagnostics** action creates
`EditPath-Diagnostics-<session>.zip` beside the session directory. It contains
sanitized console and supervisor logs, manifests, raw event streams, recovery
inventory, crash dumps when present, system/build metadata, and a
machine-readable `diagnosis.json`. User home paths are redacted and original
media is not copied. Referenced media is probed and decode-checked when the
packaged FFmpeg tools are available; only its identity, metadata, hashes, and
errors enter the report.

Useful developer commands are:

```bash
python3 video-path-pilot/reliability.py snapshot SESSION --reason periodic
python3 video-path-pilot/reliability.py select-recovery SESSION
python3 video-path-pilot/reliability.py restore-recovery SESSION
python3 video-path-pilot/reliability.py diagnostics SESSION
python3 -m unittest video-path-pilot/tests/test_reliability.py
```
6 changes: 6 additions & 0 deletions packaging/windows/build-editpath.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,12 @@ New-Item -ItemType Directory -Force $sitePackages | Out-Null
& $python.Source -m pip install --disable-pip-version-check --no-deps --target $sitePackages "zstandard==0.23.0"
if ($LASTEXITCODE -ne 0) { Stop-Build "could not install the pinned zstandard runtime into embedded Python." }

# OpenFX is optional and Craft occasionally contributes an mltopenfx.dll built
# against a different MLT ABI. Loading that binary prints a registration error
# on every launch and has caused misleading crash reports. Do not ship it until
# the package can prove that its mlt_register export matches this MLT runtime.
Get-ChildItem $portable -Recurse -File -Filter "mltopenfx.dll" | Remove-Item -Force

$packagedEditPath = Join-Path $bin "edit_path"
if (-not (Test-Path (Join-Path $packagedEditPath "__main__.py"))) {
Stop-Build "the edit_path reconstruction package is missing from the portable build."
Expand Down
98 changes: 92 additions & 6 deletions video-path-pilot/gui/main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,24 @@ QString sessionsRoot()
return QDir(videos).filePath(QStringLiteral("EditPathSessions"));
}

void configureWindowsCrashDumps(const QString &session)
{
#ifdef Q_OS_WIN
const QString dumpFolder = QDir(session).filePath(QStringLiteral("crash-dumps"));
QDir().mkpath(dumpFolder);
for (const QString &executable : {QStringLiteral("kdenlive.exe"), QStringLiteral("EditPath.exe")}) {
QSettings dumps(QStringLiteral("HKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\Windows Error Reporting\\LocalDumps\\%1").arg(executable),
QSettings::NativeFormat);
dumps.setValue(QStringLiteral("DumpFolder"), QDir::toNativeSeparators(dumpFolder));
dumps.setValue(QStringLiteral("DumpType"), 2);
dumps.setValue(QStringLiteral("DumpCount"), 5);
dumps.sync();
}
#else
Q_UNUSED(session)
#endif
}

bool prepareRenderSafetyConfig(const QString &configName, const QString &session, QString *problem)
{
const QString configRoot = QStandardPaths::writableLocation(QStandardPaths::GenericConfigLocation);
Expand Down Expand Up @@ -237,8 +255,11 @@ class RecorderWindow final : public QMainWindow
m_openSession->setEnabled(false);
m_openCompleted = new QPushButton(QStringLiteral("Open Dataset Sample"));
m_openCompleted->setEnabled(false);
m_exportDiagnostics = new QPushButton(QStringLiteral("Export Diagnostics"));
m_exportDiagnostics->setEnabled(false);
secondary->addWidget(m_openSession);
secondary->addWidget(m_openCompleted);
secondary->addWidget(m_exportDiagnostics);
secondary->addStretch();
layout->addLayout(secondary);
m_toggleDetails = new QPushButton(QStringLiteral("Show technical details"));
Expand Down Expand Up @@ -272,6 +293,7 @@ class RecorderWindow final : public QMainWindow
connect(m_openSession, &QPushButton::clicked, this, [this] { openFolder(m_session, QStringLiteral("Session folder")); });
connect(m_openCompleted, &QPushButton::clicked, this,
[this] { openFolder(m_session + QStringLiteral("/completed-sample"), QStringLiteral("Generated sample")); });
connect(m_exportDiagnostics, &QPushButton::clicked, this, &RecorderWindow::exportDiagnostics);
connect(&m_editor, qOverload<int, QProcess::ExitStatus>(&QProcess::finished), this, &RecorderWindow::editorFinished);
connect(&m_editor, &QProcess::started, this, [this] {
writeManifest(QStringLiteral("recording"));
Expand All @@ -281,6 +303,8 @@ class RecorderWindow final : public QMainWindow
m_activity->appendPlainText(QStringLiteral("Kdenlive process started; waiting for its GUI-ready signal…"));
});
connect(&m_editor, &QProcess::errorOccurred, this, [this](QProcess::ProcessError error) {
m_lastProcessError = QStringLiteral("%1: %2").arg(int(error)).arg(m_editor.errorString());
m_activity->appendPlainText(QStringLiteral("Editor process error: %1").arg(m_lastProcessError));
if (error == QProcess::FailedToStart) {
m_heartbeat.stop();
m_readyPoll.stop();
Expand All @@ -296,7 +320,10 @@ class RecorderWindow final : public QMainWindow
connect(&m_worker, qOverload<int, QProcess::ExitStatus>(&QProcess::finished), this, &RecorderWindow::workerFinished);
m_heartbeat.setInterval(60000);
connect(&m_heartbeat, &QTimer::timeout, this, [this] {
if (m_editor.state() != QProcess::NotRunning) writeManifest(QStringLiteral("recording"));
if (m_editor.state() != QProcess::NotRunning) {
writeManifest(QStringLiteral("recording"));
createRecoverySnapshot(QStringLiteral("periodic"), false);
}
});
m_readyPoll.setInterval(250);
connect(&m_readyPoll, &QTimer::timeout, this, [this] {
Expand Down Expand Up @@ -334,6 +361,10 @@ class RecorderWindow final : public QMainWindow
{QStringLiteral("kdenlive_pid"), qint64(m_editor.processId())},
{QStringLiteral("last_exit_code"), m_lastEditorExitCode},
{QStringLiteral("last_exit_crashed"), m_lastEditorExitCrashed},
{QStringLiteral("last_exit_status"), m_lastExitStatus},
{QStringLiteral("last_process_error"), m_lastProcessError},
{QStringLiteral("editor_program"), m_editorProgram},
{QStringLiteral("editor_arguments"), m_editorArguments.join(QLatin1Char(' '))},
{QStringLiteral("updated_at_utc"), QDateTime::currentDateTimeUtc().toString(Qt::ISODateWithMs)}};
QSaveFile file(QDir(m_session).filePath(QStringLiteral("session.json")));
const QByteArray encoded = QJsonDocument(manifest).toJson(QJsonDocument::Indented);
Expand All @@ -356,6 +387,7 @@ class RecorderWindow final : public QMainWindow
m_segment = manifest.value(QStringLiteral("segment")).toInt();
m_sessionLabel->setText(m_session);
m_openSession->setEnabled(true);
m_exportDiagnostics->setEnabled(true);
const QString status = manifest.value(QStringLiteral("status")).toString();
if (status == QStringLiteral("ready_to_finish")) {
m_finish->setEnabled(true);
Expand All @@ -364,17 +396,23 @@ class RecorderWindow final : public QMainWindow
m_showExistingCompletion = true;
} else if (status == QStringLiteral("recovery_available") || status == QStringLiteral("recording")) {
const QString project = QDir(previous).filePath(QStringLiteral("edit.kdenlive"));
const bool canRecover = QFileInfo::exists(project);
QProcess recovery;
recovery.setProcessChannelMode(QProcess::MergedChannels);
recovery.start(pythonExecutable(), {m_repoRoot + QStringLiteral("/video-path-pilot/reliability.py"), QStringLiteral("restore-recovery"), previous});
const bool recoveryChecked = recovery.waitForFinished(15000) && recovery.exitCode() == 0;
const bool canRecover = recoveryChecked && QFileInfo::exists(project);
m_recover->setVisible(canRecover);
offerConfirmedNewSession();
writeManifest(QStringLiteral("recovery_available"));
setStatus(canRecover ? QStringLiteral(
"Kdenlive closed unexpectedly, but your saved work is available. Click Resume Editing to continue where you left off.")
: QStringLiteral("Kdenlive closed before the project could be saved. The session folder is available for technical review."),
true);
m_activity->appendPlainText(canRecover
? QStringLiteral("Interrupted session detected. Recovery will create recording segment %1.").arg(m_segment + 1)
: QStringLiteral("Interrupted session detected, but edit.kdenlive is missing."));
m_activity->appendPlainText(
canRecover ? QStringLiteral("Interrupted session detected. The newest valid recovery was selected; segment %1 will record resumed work.")
.arg(m_segment + 1)
: QStringLiteral("Interrupted session detected, but no valid recovery project was found: %1")
.arg(QString::fromUtf8(recovery.readAll()).trimmed()));
m_showExistingCompletion = true;
} else if (status == QStringLiteral("packaged")) {
m_openCompleted->setEnabled(true);
Expand All @@ -398,15 +436,18 @@ class RecorderWindow final : public QMainWindow
setStatus(QStringLiteral("EditPath could not create a folder for this edit. Check that your Videos folder is writable, then try again."), true);
return;
}
configureWindowsCrashDumps(m_session);
m_sessionLabel->setText(m_session);
m_openSession->setEnabled(true);
m_exportDiagnostics->setEnabled(true);
m_openCompleted->setEnabled(false);
writeManifest(QStringLiteral("created"));
launchSegment();
}

void launchSegment()
{
configureWindowsCrashDumps(m_session);
const QString runtimeProblem = guiRuntimeProblem();
if (!runtimeProblem.isEmpty()) {
setStatus(runtimeProblem, true);
Expand Down Expand Up @@ -468,6 +509,9 @@ class RecorderWindow final : public QMainWindow
arguments = {raw};
if (QFileInfo::exists(project)) arguments.append(project);
#endif
m_editorProgram = program;
m_editorArguments = arguments;
m_lastProcessError.clear();
m_editor.start(program, arguments);
}

Expand All @@ -478,6 +522,8 @@ class RecorderWindow final : public QMainWindow
m_launchProgress->setVisible(false);
m_lastEditorExitCode = exitCode;
m_lastEditorExitCrashed = exitStatus != QProcess::NormalExit || exitCode != 0;
m_lastExitStatus = exitStatus == QProcess::NormalExit ? QStringLiteral("normal_exit") : QStringLiteral("crash_exit");
createRecoverySnapshot(m_lastEditorExitCrashed ? QStringLiteral("crash_recovery") : QStringLiteral("editor_exit"), true);
if (m_lastEditorExitCrashed) writeManifest(QStringLiteral("recovery_available"));
m_title->setText(QStringLiteral("<h1>EditPath</h1><p>Kdenlive has closed. Your work is being checked and saved.</p>"));
m_instructions->setVisible(true);
Expand All @@ -492,6 +538,7 @@ class RecorderWindow final : public QMainWindow

void finishSession()
{
createRecoverySnapshot(QStringLiteral("pre_finish"), true);
m_finish->setEnabled(false);
m_workerPurpose = QStringLiteral("finalize");
m_workerTranscript.clear();
Expand All @@ -502,6 +549,35 @@ class RecorderWindow final : public QMainWindow
m_worker.start(pythonExecutable(), {m_repoRoot + QStringLiteral("/video-path-pilot/job_pipeline.py"), QStringLiteral("finalize-freeform"), m_session});
}

void createRecoverySnapshot(const QString &reason, bool wait)
{
if (m_session.isEmpty() || !QFileInfo::exists(QDir(m_session).filePath(QStringLiteral("edit.kdenlive")))) return;
const QString script = m_repoRoot + QStringLiteral("/video-path-pilot/reliability.py");
const QStringList arguments{script, QStringLiteral("snapshot"), m_session, QStringLiteral("--reason"), reason};
if (wait) {
QProcess snapshot;
snapshot.setProcessChannelMode(QProcess::MergedChannels);
snapshot.start(pythonExecutable(), arguments);
if (!snapshot.waitForFinished(15000) || snapshot.exitCode() != 0) {
m_activity->appendPlainText(QStringLiteral("Recovery snapshot warning: %1").arg(QString::fromUtf8(snapshot.readAll()).trimmed()));
} else {
m_activity->appendPlainText(QStringLiteral("Recovery copy saved: %1").arg(QDateTime::currentDateTime().toString(QStringLiteral("HH:mm:ss"))));
}
} else {
QProcess::startDetached(pythonExecutable(), arguments, m_repoRoot);
}
}

void exportDiagnostics()
{
if (m_session.isEmpty() || m_worker.state() != QProcess::NotRunning) return;
m_workerPurpose = QStringLiteral("diagnostics");
m_workerTranscript.clear();
m_exportDiagnostics->setEnabled(false);
setStatus(QStringLiteral("Creating a privacy-safe diagnostics ZIP…"));
m_worker.start(pythonExecutable(), {m_repoRoot + QStringLiteral("/video-path-pilot/reliability.py"), QStringLiteral("diagnostics"), m_session});
}

void readWorker()
{
const QString output = QString::fromUtf8(m_worker.readAllStandardOutput()).trimmed();
Expand Down Expand Up @@ -572,6 +648,14 @@ class RecorderWindow final : public QMainWindow
QStringLiteral("<h1>We couldn't create the sample yet</h1><p>Your project and rendered video have not been deleted or changed.</p>"));
setStatus(friendlyFinalizationError(m_workerTranscript), true);
}
} else if (m_workerPurpose == QStringLiteral("diagnostics")) {
m_exportDiagnostics->setEnabled(true);
if (success) {
setStatus(QStringLiteral("Diagnostics ZIP created. Its location is shown in technical details."));
QApplication::clipboard()->setText(m_workerTranscript.trimmed());
} else {
setStatus(QStringLiteral("Diagnostics could not be exported. Your project and recovery copies are unaffected."), true);
}
}
m_workerPurpose.clear();
}
Expand Down Expand Up @@ -618,13 +702,15 @@ class RecorderWindow final : public QMainWindow
}

QString m_repoRoot, m_session, m_sessionId, m_configName, m_workerPurpose, m_readyFile, m_workerTranscript;
QString m_lastProcessError, m_lastExitStatus{QStringLiteral("not_started")}, m_editorProgram;
QStringList m_editorArguments;
int m_segment{0};
int m_lastEditorExitCode{0};
QProcess m_editor, m_worker;
QTimer m_heartbeat, m_readyPoll;
bool m_showExistingCompletion{false}, m_lastEditorExitCrashed{false}, m_confirmNewSession{false};
QLabel *m_title{}, *m_instructions{}, *m_status{}, *m_sessionLabel{};
QPushButton *m_start{}, *m_recover{}, *m_finish{}, *m_openSession{}, *m_openCompleted{}, *m_toggleDetails{};
QPushButton *m_start{}, *m_recover{}, *m_finish{}, *m_openSession{}, *m_openCompleted{}, *m_exportDiagnostics{}, *m_toggleDetails{};
QPlainTextEdit *m_activity{};
QProgressBar *m_launchProgress{};
};
Expand Down
Loading