From 876e3e59fd12bc1cf2122b18b1897cd5f001836c Mon Sep 17 00:00:00 2001 From: Tenali Rama <225068477+Tenali-Rama@users.noreply.github.com> Date: Wed, 22 Jul 2026 03:46:49 +0530 Subject: [PATCH 01/14] Add no-terminal collector desktop app --- documentation.md | 22 ++ video-path-pilot/EDITOR_WORKFLOW.md | 40 ++- video-path-pilot/README.md | 13 + video-path-pilot/gui/CMakeLists.txt | 13 + video-path-pilot/gui/main.cpp | 390 ++++++++++++++++++++++++++ video-path-pilot/run-collector-app.sh | 23 ++ 6 files changed, 496 insertions(+), 5 deletions(-) create mode 100644 video-path-pilot/gui/CMakeLists.txt create mode 100644 video-path-pilot/gui/main.cpp create mode 100755 video-path-pilot/run-collector-app.sh diff --git a/documentation.md b/documentation.md index 100c515..2ff4678 100644 --- a/documentation.md +++ b/documentation.md @@ -485,6 +485,28 @@ language in `video-path-pilot/VOCABULARY.md`, and the machine contract in `video-path-pilot/sample.schema.json`. Automated tests cover branch compaction, normalization, asset binding, hashes, and package validation. +### No-terminal GUI enhancement + +Editor feedback changed the delivery requirement: the MVP must not expose a +terminal workflow. A native Qt 6 Widgets application now wraps the tested +collector engine without duplicating its business logic. The window supports +new/existing sample selection, prompt and plan entry, project profile, ordered +asset picking, instrumented Kdenlive launch, rationale notes, project/render +selection, final review, finalization, validation, and visible task status. + +The implementation lives under `video-path-pilot/gui/`. The clickable +`run-collector-app.sh` launcher builds the GUI on first use against the same +Craft Qt environment used by Kdenlive and then launches it without requiring +typed commands. Python remains an internal runtime implementation detail; +editors do not type or see collector commands. + +This iteration uses the GitHub feature branch `feature/gui-collector-mvp` so +the working command-based MVP remains stable on `video-path-pilot`. It should +be reviewed through a pull request before merge. The current launcher is +shareable among Linux machines with the project and Craft dependencies; a +self-contained AppImage or installer is a separate distribution gate before +sharing outside that controlled environment. + ### Privacy and security The collector can reveal editor behavior, project structure, local file paths, diff --git a/video-path-pilot/EDITOR_WORKFLOW.md b/video-path-pilot/EDITOR_WORKFLOW.md index a8183ea..c0155ae 100644 --- a/video-path-pilot/EDITOR_WORKFLOW.md +++ b/video-path-pilot/EDITOR_WORKFLOW.md @@ -3,10 +3,40 @@ # Editor workflow for the two-sample MVP +The normal editor workflow is entirely graphical. Double-click +`run-collector-app.sh` in the `video-path-pilot` folder and choose **Run** if +Linux asks whether to display or execute it. The first launch builds the small +collector window automatically; subsequent launches open directly. + Use a fresh directory for every sample. Do not reuse a crashed or incomplete -recording. Commands below are run from the repository root. +recording. + +## Graphical workflow + +1. Click **Create New Sample**. +2. Choose a new sample folder and enter editor ID, prompt, initial plan, project + profile, and source asset files. Their displayed order becomes + `asset_001`, `asset_002`, and so on. +3. Click **Create Sample**, then **Launch Instrumented Kdenlive**. +4. In Kdenlive, create a blank project with the displayed profile and import + files from the sample's `assets/` directory in filename order. +5. Edit normally. In the collector window, use **Save Creative Decision** for + meaningful choices—not every click. +6. Save the Kdenlive project, render the final video, and close Kdenlive + normally. +7. In the collector, select the saved project and rendered video, write the + final review, and click **Finalize and Validate**. +8. Watch the output completely and review `sample.json` before client delivery. + +If Kdenlive crashes or is force-quit, create a fresh sample rather than reusing +the incomplete raw recording. + +## Command-line fallback for developers + +The commands below remain available for diagnosis and automated testing. Hired +editors do not need to use them. -## 1. Initialize +### Initialize ```bash python3 video-path-pilot/sample_collector.py init \ @@ -19,7 +49,7 @@ python3 video-path-pilot/sample_collector.py init \ The command copies and hashes assets. It never modifies the originals. -## 2. Launch and edit +### Launch and edit ```bash python3 video-path-pilot/sample_collector.py launch \ @@ -44,7 +74,7 @@ python3 video-path-pilot/sample_collector.py note \ Render the final video, then close Kdenlive normally. A force-quit makes the sample incomplete and it should be recollected. -## 3. Finalize +### Finalize ```bash python3 video-path-pilot/sample_collector.py finalize \ @@ -58,7 +88,7 @@ Finalization validates the raw session, copies the native project and render, hashes every artifact, removes undone work from the accepted branch, creates `sample.json`, and validates the completed package. -## 4. Human review before client delivery +### Human review before client delivery Watch `output/final.*` completely. Open `sample.json` and verify that assets, operation order, frames, prompt, plan, notes, and output are plausible. The MVP diff --git a/video-path-pilot/README.md b/video-path-pilot/README.md index e2fd1c6..7c95a51 100644 --- a/video-path-pilot/README.md +++ b/video-path-pilot/README.md @@ -15,6 +15,19 @@ format and language are in `sample.schema.json` and `VOCABULARY.md`. ## MVP collector +The editor-facing interface is a native Qt desktop app. On Linux, double-click: + +```text +video-path-pilot/run-collector-app.sh +``` + +Choose **Run** if the file manager asks whether to display or execute the file. +The app provides forms and file pickers for the complete workflow; no terminal +commands are required. The launcher uses the existing Craft/Qt environment and +builds the small GUI automatically on first use. + +The underlying command interface remains available to developers and tests: + ```bash python3 video-path-pilot/sample_collector.py --help ``` diff --git a/video-path-pilot/gui/CMakeLists.txt b/video-path-pilot/gui/CMakeLists.txt new file mode 100644 index 0000000..cf6390f --- /dev/null +++ b/video-path-pilot/gui/CMakeLists.txt @@ -0,0 +1,13 @@ +# SPDX-FileCopyrightText: 2026 Video Path Pilot contributors +# SPDX-License-Identifier: GPL-3.0-only + +cmake_minimum_required(VERSION 3.20) +project(EditPathCollector VERSION 0.1.0 LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +find_package(Qt6 6.5 REQUIRED COMPONENTS Core Gui Widgets) + +qt_add_executable(edit-path-collector main.cpp) +target_link_libraries(edit-path-collector PRIVATE Qt6::Core Qt6::Gui Qt6::Widgets) +install(TARGETS edit-path-collector RUNTIME DESTINATION bin) diff --git a/video-path-pilot/gui/main.cpp b/video-path-pilot/gui/main.cpp new file mode 100644 index 0000000..98246bf --- /dev/null +++ b/video-path-pilot/gui/main.cpp @@ -0,0 +1,390 @@ +// SPDX-FileCopyrightText: 2026 Video Path Pilot contributors +// SPDX-License-Identifier: GPL-3.0-only + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { +QString repositoryRoot() +{ + const QString configured = qEnvironmentVariable("EDIT_PATH_REPO_ROOT"); + if (!configured.isEmpty() && QFileInfo::exists(configured + QStringLiteral("/video-path-pilot/sample_collector.py"))) { + return QDir(configured).absolutePath(); + } + QDir current(QCoreApplication::applicationDirPath()); + for (int depth = 0; depth < 6; ++depth) { + if (QFileInfo::exists(current.filePath(QStringLiteral("video-path-pilot/sample_collector.py")))) { + return current.absolutePath(); + } + if (!current.cdUp()) { + break; + } + } + return {}; +} + +QTextEdit *paragraphEditor(const QString &placeholder) +{ + auto *editor = new QTextEdit; + editor->setPlaceholderText(placeholder); + editor->setMinimumHeight(72); + return editor; +} +} + +class CollectorWindow final : public QMainWindow +{ +public: + CollectorWindow() + : m_repoRoot(repositoryRoot()) + { + setWindowTitle(QStringLiteral("Edit Path Collector MVP")); + resize(900, 760); + buildUi(); + QSettings settings; + const QString previous = settings.value(QStringLiteral("currentSample")).toString(); + if (!previous.isEmpty() && QFileInfo::exists(previous + QStringLiteral("/internal/collector-metadata.json"))) { + selectSample(previous); + } + if (m_repoRoot.isEmpty()) { + showError(QStringLiteral("Collector files could not be found. Start the app with the supplied launcher.")); + setControlsEnabled(false); + } + } + +protected: + void closeEvent(QCloseEvent *event) override + { + if (m_process.state() != QProcess::NotRunning || m_editorProcess.state() != QProcess::NotRunning) { + QMessageBox::warning(this, QStringLiteral("Kdenlive is running"), + QStringLiteral("Close Kdenlive before closing the collector.")); + event->ignore(); + return; + } + event->accept(); + } + +private: + void buildUi() + { + auto *central = new QWidget; + auto *layout = new QVBoxLayout(central); + auto *title = new QLabel(QStringLiteral("

Edit Path Collector

Create and package training samples without using a terminal.

")); + title->setTextFormat(Qt::RichText); + layout->addWidget(title); + + auto *workspaceRow = new QHBoxLayout; + m_workspaceLabel = new QLabel(QStringLiteral("No sample selected")); + m_workspaceLabel->setTextInteractionFlags(Qt::TextSelectableByMouse); + auto *newButton = new QPushButton(QStringLiteral("Create New Sample")); + auto *openButton = new QPushButton(QStringLiteral("Open Existing Sample")); + workspaceRow->addWidget(m_workspaceLabel, 1); + workspaceRow->addWidget(newButton); + workspaceRow->addWidget(openButton); + layout->addLayout(workspaceRow); + + m_newSample = new QGroupBox(QStringLiteral("1. Sample setup")); + auto *form = new QFormLayout(m_newSample); + m_sampleDirectory = new QLineEdit; + auto *directoryRow = new QHBoxLayout; + auto *chooseDirectory = new QPushButton(QStringLiteral("Choose…")); + directoryRow->addWidget(m_sampleDirectory, 1); + directoryRow->addWidget(chooseDirectory); + form->addRow(QStringLiteral("New sample folder"), directoryRow); + m_editorId = new QLineEdit(QStringLiteral("editor_001")); + form->addRow(QStringLiteral("Editor ID"), m_editorId); + m_prompt = paragraphEditor(QStringLiteral("What should the finished video accomplish?")); + form->addRow(QStringLiteral("Editing prompt"), m_prompt); + m_plan = paragraphEditor(QStringLiteral("Describe your intended structure, pacing, audio, and finish.")); + form->addRow(QStringLiteral("Initial plan"), m_plan); + auto *profileRow = new QHBoxLayout; + m_width = new QSpinBox; m_width->setRange(1, 16384); m_width->setValue(1920); + m_height = new QSpinBox; m_height->setRange(1, 16384); m_height->setValue(1080); + m_fpsNumerator = new QSpinBox; m_fpsNumerator->setRange(1, 240000); m_fpsNumerator->setValue(25); + m_fpsDenominator = new QSpinBox; m_fpsDenominator->setRange(1, 1001); m_fpsDenominator->setValue(1); + profileRow->addWidget(new QLabel(QStringLiteral("Width"))); profileRow->addWidget(m_width); + profileRow->addWidget(new QLabel(QStringLiteral("Height"))); profileRow->addWidget(m_height); + profileRow->addWidget(new QLabel(QStringLiteral("FPS"))); profileRow->addWidget(m_fpsNumerator); + profileRow->addWidget(new QLabel(QStringLiteral("/"))); profileRow->addWidget(m_fpsDenominator); + form->addRow(QStringLiteral("Project profile"), profileRow); + m_assets = new QListWidget; m_assets->setMinimumHeight(90); + auto *assetButtons = new QHBoxLayout; + auto *addAssets = new QPushButton(QStringLiteral("Add Asset Files…")); + auto *removeAsset = new QPushButton(QStringLiteral("Remove Selected")); + assetButtons->addWidget(addAssets); assetButtons->addWidget(removeAsset); assetButtons->addStretch(); + form->addRow(QStringLiteral("Source assets"), m_assets); + form->addRow(QString(), assetButtons); + auto *createButton = new QPushButton(QStringLiteral("Create Sample")); + form->addRow(QString(), createButton); + layout->addWidget(m_newSample); + + m_workflow = new QGroupBox(QStringLiteral("2. Edit and annotate")); + auto *workflowLayout = new QVBoxLayout(m_workflow); + m_status = new QLabel(QStringLiteral("Create or open a sample to begin.")); + m_status->setWordWrap(true); + workflowLayout->addWidget(m_status); + auto *launchButton = new QPushButton(QStringLiteral("Launch Instrumented Kdenlive")); + workflowLayout->addWidget(launchButton); + auto *noteForm = new QFormLayout; + m_reason = new QLineEdit; m_reason->setPlaceholderText(QStringLiteral("Why was a decision needed?")); + m_decision = new QLineEdit; m_decision->setPlaceholderText(QStringLiteral("What did you decide to do?")); + auto *saveNote = new QPushButton(QStringLiteral("Save Creative Decision")); + noteForm->addRow(QStringLiteral("Reason"), m_reason); + noteForm->addRow(QStringLiteral("Decision"), m_decision); + noteForm->addRow(QString(), saveNote); + workflowLayout->addLayout(noteForm); + layout->addWidget(m_workflow); + + m_finalize = new QGroupBox(QStringLiteral("3. Finalize sample")); + auto *finalForm = new QFormLayout(m_finalize); + m_projectFile = new QLineEdit; + m_outputFile = new QLineEdit; + auto addPicker = [this, finalForm](const QString &label, QLineEdit *field, const QString &filter) { + auto *row = new QHBoxLayout; + auto *button = new QPushButton(QStringLiteral("Choose…")); + row->addWidget(field, 1); row->addWidget(button); + finalForm->addRow(label, row); + connect(button, &QPushButton::clicked, this, [this, field, filter] { + const QString file = QFileDialog::getOpenFileName(this, QStringLiteral("Choose file"), {}, filter); + if (!file.isEmpty()) field->setText(file); + }); + }; + addPicker(QStringLiteral("Saved Kdenlive project"), m_projectFile, QStringLiteral("Kdenlive projects (*.kdenlive);;All files (*)")); + addPicker(QStringLiteral("Rendered final video"), m_outputFile, QStringLiteral("Video files (*.mp4 *.mov *.mkv *.webm);;All files (*)")); + m_review = paragraphEditor(QStringLiteral("Confirm how the result follows the prompt and what you checked.")); + finalForm->addRow(QStringLiteral("Final editor review"), m_review); + auto *finishRow = new QHBoxLayout; + auto *finalizeButton = new QPushButton(QStringLiteral("Finalize and Validate")); + auto *validateButton = new QPushButton(QStringLiteral("Validate Existing Sample")); + finishRow->addWidget(finalizeButton); finishRow->addWidget(validateButton); finishRow->addStretch(); + finalForm->addRow(QString(), finishRow); + layout->addWidget(m_finalize); + + m_log = new QPlainTextEdit; m_log->setReadOnly(true); m_log->setMaximumBlockCount(500); m_log->setMinimumHeight(100); + layout->addWidget(new QLabel(QStringLiteral("Activity"))); + layout->addWidget(m_log); + setCentralWidget(central); + setControlsEnabled(false); + + connect(newButton, &QPushButton::clicked, this, [this] { m_newSample->setVisible(true); }); + connect(openButton, &QPushButton::clicked, this, &CollectorWindow::openSample); + connect(chooseDirectory, &QPushButton::clicked, this, [this] { + const QString parent = QFileDialog::getExistingDirectory(this, QStringLiteral("Choose parent folder")); + if (!parent.isEmpty()) m_sampleDirectory->setText(QDir(parent).filePath(QStringLiteral("sample_001"))); + }); + connect(addAssets, &QPushButton::clicked, this, [this] { + const QStringList files = QFileDialog::getOpenFileNames(this, QStringLiteral("Choose source assets")); + for (const QString &file : files) if (m_assets->findItems(file, Qt::MatchExactly).isEmpty()) m_assets->addItem(file); + }); + connect(removeAsset, &QPushButton::clicked, this, [this] { qDeleteAll(m_assets->selectedItems()); }); + connect(createButton, &QPushButton::clicked, this, &CollectorWindow::createSample); + connect(launchButton, &QPushButton::clicked, this, &CollectorWindow::launchEditor); + connect(saveNote, &QPushButton::clicked, this, &CollectorWindow::saveDecision); + connect(finalizeButton, &QPushButton::clicked, this, &CollectorWindow::finalizeSample); + connect(validateButton, &QPushButton::clicked, this, [this] { runCollector({QStringLiteral("validate"), m_currentSample}); }); + connect(&m_process, &QProcess::readyReadStandardOutput, this, &CollectorWindow::readProcessOutput); + connect(&m_process, &QProcess::readyReadStandardError, this, &CollectorWindow::readProcessOutput); + connect(&m_process, qOverload(&QProcess::finished), this, &CollectorWindow::processFinished); + connect(&m_editorProcess, qOverload(&QProcess::finished), this, &CollectorWindow::editorFinished); + } + + void setControlsEnabled(bool enabled) + { + m_workflow->setEnabled(enabled); + m_finalize->setEnabled(enabled); + } + + void showError(const QString &message) { QMessageBox::critical(this, QStringLiteral("Edit Path Collector"), message); } + + void selectSample(const QString &path) + { + const QString absolute = QDir(path).absolutePath(); + if (!QFileInfo::exists(absolute + QStringLiteral("/internal/collector-metadata.json"))) { + showError(QStringLiteral("This folder is not a collector sample.")); + return; + } + m_currentSample = absolute; + m_workspaceLabel->setText(absolute); + m_status->setText(QStringLiteral("Sample ready. Launch Kdenlive, import assets in filename order, edit, save, render, and close normally.")); + setControlsEnabled(true); + m_newSample->setVisible(false); + QSettings().setValue(QStringLiteral("currentSample"), absolute); + } + + void openSample() + { + const QString directory = QFileDialog::getExistingDirectory(this, QStringLiteral("Open sample folder")); + if (!directory.isEmpty()) selectSample(directory); + } + + void createSample() + { + if (m_sampleDirectory->text().trimmed().isEmpty() || m_editorId->text().trimmed().isEmpty() + || m_prompt->toPlainText().trimmed().isEmpty() || m_plan->toPlainText().trimmed().isEmpty() || m_assets->count() == 0) { + showError(QStringLiteral("Folder, editor ID, prompt, plan, and at least one asset are required.")); + return; + } + QStringList arguments{QStringLiteral("init"), m_sampleDirectory->text().trimmed(), + QStringLiteral("--editor-id"), m_editorId->text().trimmed(), + QStringLiteral("--prompt"), m_prompt->toPlainText().trimmed(), + QStringLiteral("--plan"), m_plan->toPlainText().trimmed(), + QStringLiteral("--fps-num"), QString::number(m_fpsNumerator->value()), + QStringLiteral("--fps-den"), QString::number(m_fpsDenominator->value()), + QStringLiteral("--width"), QString::number(m_width->value()), + QStringLiteral("--height"), QString::number(m_height->value())}; + for (int i = 0; i < m_assets->count(); ++i) arguments << m_assets->item(i)->text(); + m_pendingSample = QDir(m_sampleDirectory->text().trimmed()).absolutePath(); + runCollector(arguments, QStringLiteral("init")); + } + + void launchEditor() + { + if (m_editorProcess.state() != QProcess::NotRunning) { + showError(QStringLiteral("Kdenlive is already running for this sample.")); + return; + } + if (QFileInfo::exists(m_currentSample + QStringLiteral("/evidence/raw-events.jsonl"))) { + showError(QStringLiteral("This sample already has a recording. Open or create a fresh sample instead of overwriting evidence.")); + return; + } + m_status->setText(QStringLiteral("Kdenlive is running. Keep this collector open and close Kdenlive normally when finished.")); + QStringList arguments{m_repoRoot + QStringLiteral("/video-path-pilot/sample_collector.py"), + QStringLiteral("launch"), m_currentSample}; + m_log->appendPlainText(QStringLiteral("Launching instrumented Kdenlive…")); + m_editorProcess.setWorkingDirectory(m_repoRoot); + m_editorProcess.setProcessChannelMode(QProcess::MergedChannels); + m_editorProcess.setStandardOutputFile(m_currentSample + QStringLiteral("/internal/kdenlive-console.log"), QIODevice::Append); + m_editorProcess.start(QStringLiteral("python3"), arguments); + } + + void saveDecision() + { + if (m_reason->text().trimmed().isEmpty() || m_decision->text().trimmed().isEmpty()) { + showError(QStringLiteral("Both the reason and decision are required.")); + return; + } + runCollector({QStringLiteral("note"), m_currentSample, QStringLiteral("--reason"), m_reason->text().trimmed(), + QStringLiteral("--decision"), m_decision->text().trimmed()}, QStringLiteral("note")); + } + + void finalizeSample() + { + if (m_editorProcess.state() != QProcess::NotRunning) { + showError(QStringLiteral("Close Kdenlive normally before finalizing the sample.")); + return; + } + if (m_projectFile->text().trimmed().isEmpty() || m_outputFile->text().trimmed().isEmpty() + || m_review->toPlainText().trimmed().isEmpty()) { + showError(QStringLiteral("Choose the saved project and final video, then provide the final review.")); + return; + } + runCollector({QStringLiteral("finalize"), m_currentSample, + QStringLiteral("--project"), m_projectFile->text().trimmed(), + QStringLiteral("--output"), m_outputFile->text().trimmed(), + QStringLiteral("--review"), m_review->toPlainText().trimmed()}, QStringLiteral("finalize")); + } + + void runCollector(QStringList arguments, const QString &purpose = {}) + { + if (m_process.state() != QProcess::NotRunning) { + showError(QStringLiteral("Another collector task is still running.")); + return; + } + m_purpose = purpose; + arguments.prepend(m_repoRoot + QStringLiteral("/video-path-pilot/sample_collector.py")); + m_log->appendPlainText(QStringLiteral("Starting %1…").arg(purpose.isEmpty() ? arguments.value(1) : purpose)); + m_process.setWorkingDirectory(m_repoRoot); + m_process.start(QStringLiteral("python3"), arguments); + } + + void readProcessOutput() + { + const QString standard = QString::fromUtf8(m_process.readAllStandardOutput()).trimmed(); + const QString errors = QString::fromUtf8(m_process.readAllStandardError()).trimmed(); + if (!standard.isEmpty()) m_log->appendPlainText(standard); + if (!errors.isEmpty()) m_log->appendPlainText(errors); + } + + void processFinished(int exitCode, QProcess::ExitStatus status) + { + readProcessOutput(); + const bool success = status == QProcess::NormalExit && exitCode == 0; + m_log->appendPlainText(success ? QStringLiteral("Completed successfully.") : QStringLiteral("Task failed (exit code %1).").arg(exitCode)); + if (success && m_purpose == QStringLiteral("init")) selectSample(m_pendingSample); + if (success && m_purpose == QStringLiteral("note")) { m_reason->clear(); m_decision->clear(); } + if (success && m_purpose == QStringLiteral("finalize")) { + m_status->setText(QStringLiteral("Sample finalized and validated. It is ready for human review.")); + QMessageBox::information(this, QStringLiteral("Sample complete"), QStringLiteral("sample.json and all required evidence were created successfully.")); + } else if (!success && m_purpose != QStringLiteral("launch")) { + showError(QStringLiteral("The task failed. See Activity for details.")); + } + m_purpose.clear(); + } + + void editorFinished(int exitCode, QProcess::ExitStatus status) + { + const bool success = status == QProcess::NormalExit && exitCode == 0; + m_log->appendPlainText(success ? QStringLiteral("Kdenlive closed normally.") + : QStringLiteral("Kdenlive exited unexpectedly (code %1).").arg(exitCode)); + m_status->setText(success ? QStringLiteral("Kdenlive closed normally. Select the saved project and render to finalize.") + : QStringLiteral("Kdenlive did not close cleanly. This recording may be incomplete.")); + } + + QString m_repoRoot; + QString m_currentSample; + QString m_pendingSample; + QString m_purpose; + QProcess m_process; + QProcess m_editorProcess; + QLabel *m_workspaceLabel{}; + QLabel *m_status{}; + QGroupBox *m_newSample{}; + QGroupBox *m_workflow{}; + QGroupBox *m_finalize{}; + QLineEdit *m_sampleDirectory{}; + QLineEdit *m_editorId{}; + QTextEdit *m_prompt{}; + QTextEdit *m_plan{}; + QSpinBox *m_width{}; + QSpinBox *m_height{}; + QSpinBox *m_fpsNumerator{}; + QSpinBox *m_fpsDenominator{}; + QListWidget *m_assets{}; + QLineEdit *m_reason{}; + QLineEdit *m_decision{}; + QLineEdit *m_projectFile{}; + QLineEdit *m_outputFile{}; + QTextEdit *m_review{}; + QPlainTextEdit *m_log{}; +}; + +int main(int argc, char **argv) +{ + QApplication application(argc, argv); + QCoreApplication::setOrganizationName(QStringLiteral("Parsewave")); + QCoreApplication::setApplicationName(QStringLiteral("EditPathCollector")); + CollectorWindow window; + window.show(); + return application.exec(); +} diff --git a/video-path-pilot/run-collector-app.sh b/video-path-pilot/run-collector-app.sh new file mode 100755 index 0000000..aba6964 --- /dev/null +++ b/video-path-pilot/run-collector-app.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: 2026 Video Path Pilot contributors +# SPDX-License-Identifier: GPL-3.0-only + +set -euo pipefail +script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +repo_root=$(cd -- "$script_dir/.." && pwd) +craft_root=${KDENLIVE_PILOT_CRAFT_ROOT:-/home/tenali/CraftRoot} +binary="$repo_root/build/collector-gui/edit-path-collector" +export PATH="$craft_root/dev-utils/bin:$craft_root/bin:$craft_root/libexec:$PATH" + +if [[ ! -x $binary ]]; then + mkdir -p "$repo_root/build/collector-gui" + cmake -S "$script_dir/gui" -B "$repo_root/build/collector-gui" -GNinja \ + -DCMAKE_PREFIX_PATH="$craft_root" + cmake --build "$repo_root/build/collector-gui" +fi + +export EDIT_PATH_REPO_ROOT="$repo_root" +export LD_LIBRARY_PATH="$craft_root/lib:$craft_root/usr/lib/x86_64-linux-gnu${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" +export FONTCONFIG_FILE="$craft_root/etc/fonts/fonts.conf" +export FONTCONFIG_PATH="$craft_root/etc/fonts" +exec "$binary" From 5a0a1e2c3e8e5d2cbd13565b2bf9306284a3f727 Mon Sep 17 00:00:00 2001 From: Tenali Rama <225068477+Tenali-Rama@users.noreply.github.com> Date: Wed, 22 Jul 2026 15:09:48 +0530 Subject: [PATCH 02/14] Narrow MVP to behavior-only session recording --- documentation.md | 24 ++ video-path-pilot/EDITOR_WORKFLOW.md | 128 ++----- video-path-pilot/README.md | 25 +- video-path-pilot/gui/CMakeLists.txt | 8 +- video-path-pilot/gui/main.cpp | 435 ++++++++--------------- video-path-pilot/normalize_sample.py | 19 +- video-path-pilot/run-collector-app.sh | 2 +- video-path-pilot/run-video-path-pilot.sh | 7 +- video-path-pilot/sample.schema.json | 7 +- video-path-pilot/sample_collector.py | 32 +- video-path-pilot/tests/test_mvp.py | 7 +- video-path-pilot/validate_sample.py | 12 +- 12 files changed, 253 insertions(+), 453 deletions(-) diff --git a/documentation.md b/documentation.md index 2ff4678..0b66bfc 100644 --- a/documentation.md +++ b/documentation.md @@ -507,6 +507,30 @@ shareable among Linux machines with the project and Craft dependencies; a self-contained AppImage or installer is a separate distribution gate before sharing outside that controlled environment. +### MVP scope correction: recording only + +The operational plan was narrowed after internal direction. Editors will be +given the application, task instructions, and assets; they will perform the +edit and return the recording, native project, render, and source assets. The +Parsewave team—not the editor—will construct the two canonical samples and ask +the client for feedback. Consequently, the editor-facing MVP must not collect +editor intent. + +The GUI initialization, prompt, plan, asset-copying, rationale, decision-note, +subjective-review, normalization, and finalization screens were removed. The +app is now a one-screen **Edit Path Recorder** with Start Session and Open +Session Folder actions. Starting creates a unique directory under the user's +Videos folder, supplies a unique Kdenlive configuration so an old project is +not reopened, records JSONL and console evidence, and validates the raw session +after Kdenlive closes. + +The sample schema and internal prototype were also revised so editor plan, +rationale notes, and subjective editor review cannot leak into generated +samples. The externally assigned task prompt remains valid sample input; it is +not collected from the editor application. Objective completion confirmation +and later internal human review remain quality-control concerns, not editor +intent. + ### Privacy and security The collector can reveal editor behavior, project structure, local file paths, diff --git a/video-path-pilot/EDITOR_WORKFLOW.md b/video-path-pilot/EDITOR_WORKFLOW.md index c0155ae..b8e4f8a 100644 --- a/video-path-pilot/EDITOR_WORKFLOW.md +++ b/video-path-pilot/EDITOR_WORKFLOW.md @@ -1,96 +1,46 @@ -# Editor workflow for the two-sample MVP - -The normal editor workflow is entirely graphical. Double-click -`run-collector-app.sh` in the `video-path-pilot` folder and choose **Run** if -Linux asks whether to display or execute it. The first launch builds the small -collector window automatically; subsequent launches open directly. - -Use a fresh directory for every sample. Do not reuse a crashed or incomplete -recording. - -## Graphical workflow - -1. Click **Create New Sample**. -2. Choose a new sample folder and enter editor ID, prompt, initial plan, project - profile, and source asset files. Their displayed order becomes - `asset_001`, `asset_002`, and so on. -3. Click **Create Sample**, then **Launch Instrumented Kdenlive**. -4. In Kdenlive, create a blank project with the displayed profile and import - files from the sample's `assets/` directory in filename order. -5. Edit normally. In the collector window, use **Save Creative Decision** for - meaningful choices—not every click. -6. Save the Kdenlive project, render the final video, and close Kdenlive - normally. -7. In the collector, select the saved project and rendered video, write the - final review, and click **Finalize and Validate**. -8. Watch the output completely and review `sample.json` before client delivery. - -If Kdenlive crashes or is force-quit, create a fresh sample rather than reusing -the incomplete raw recording. - -## Command-line fallback for developers - -The commands below remain available for diagnosis and automated testing. Hired -editors do not need to use them. - -### Initialize - -```bash -python3 video-path-pilot/sample_collector.py init \ - /home/tenali/parsewave/samples/sample_001 \ - --editor-id editor_001 \ - --prompt "Create a 20-second energetic product montage." \ - --plan "Select the strongest moments, establish context, accelerate the cuts, and end on the product." \ - /path/to/video-a.mp4 /path/to/video-b.mp4 /path/to/music.wav -``` - -The command copies and hashes assets. It never modifies the originals. - -### Launch and edit - -```bash -python3 video-path-pilot/sample_collector.py launch \ - /home/tenali/parsewave/samples/sample_001 -``` - -In Kdenlive, create a blank project with the requested resolution and frame -rate. Import files from the sample's `assets/` folder **in filename order**. -Edit normally. Save the project outside the sample or directly as -`internal/final.kdenlive`. - -Add a rationale note when making a meaningful creative decision (not for every -click). Open another terminal and run: - -```bash -python3 video-path-pilot/sample_collector.py note \ - /home/tenali/parsewave/samples/sample_001 \ - --reason "The opening felt slow." \ - --decision "Used three short detail shots before the wide shot to create momentum." -``` - -Render the final video, then close Kdenlive normally. A force-quit makes the -sample incomplete and it should be recollected. - -### Finalize - -```bash -python3 video-path-pilot/sample_collector.py finalize \ - /home/tenali/parsewave/samples/sample_001 \ - --project /path/to/saved-project.kdenlive \ - --output /path/to/rendered-video.mp4 \ - --review "The output follows the prompt; pacing and audio ending were checked." +# Editor workflow for the recorder MVP + +The editor-facing application records editing behavior and outcomes only. It +does not collect the editor's plan, reasoning, decisions, rationale, or review. +The Parsewave team constructs the two client samples after the sessions. + +## Start the app + +Double-click `run-collector-app.sh` and choose **Run** if Linux asks whether to +display or execute it. No terminal commands are part of the editor workflow. + +## Record a session + +1. Click **Start Editing Session**. The app automatically creates a unique + folder under `Videos/EditPathSessions/` and launches an isolated Kdenlive + configuration that does not reopen the previous project. +2. Edit normally using the task instructions and assets supplied separately. +3. Save the native `.kdenlive` project and rendered final video inside the + session folder displayed by the recorder. +4. Close Kdenlive normally. Do not force-quit it. +5. Wait until the recorder reports that `raw-events.jsonl` passed validation. +6. Click **Open Session Folder** and return the complete folder plus the source + assets to the Parsewave team. + +The session folder contains at least: + +```text +session_YYYYMMDD_HHMMSS_xxxxxxxx/ +├── raw-events.jsonl +├── kdenlive-console.log +├── final.kdenlive # saved by editor +└── final.mp4 # rendered by editor ``` -Finalization validates the raw session, copies the native project and render, -hashes every artifact, removes undone work from the accepted branch, creates -`sample.json`, and validates the completed package. +If Kdenlive crashes or the recorder reports an incomplete session, create a +fresh session and repeat the edit. -### Human review before client delivery +## Internal team workflow -Watch `output/final.*` completely. Open `sample.json` and verify that assets, -operation order, frames, prompt, plan, notes, and output are plausible. The MVP -marks every sample `needs_human_review`; a reviewer should record approval in -the client-delivery notes. Do not send local caches or unrelated project files. +The editor does not generate `sample.json`. After receiving the session, +project, render, assets, and externally assigned task prompt, the internal team +validates the evidence, resolves project asset identities, normalizes the +accepted edit path, packages the sample, and performs human quality review. diff --git a/video-path-pilot/README.md b/video-path-pilot/README.md index 7c95a51..b67c3cc 100644 --- a/video-path-pilot/README.md +++ b/video-path-pilot/README.md @@ -5,15 +5,15 @@ SPDX-License-Identifier: GPL-3.0-only # Kdenlive Video Path Pilot -This fork includes an MVP training-sample collector around the Kdenlive -recorder. It packages the prompt, hashed assets, editor plan and rationale, -accepted software-independent edit path, final render, native project, and raw -audit evidence into one sample directory. +This fork includes an editor-facing recording MVP around Kdenlive. The app +records editing interactions and canonical timeline outcomes without collecting +editor intent. The internal project team later combines the recording, native +project, render, source assets, and externally assigned prompt into a sample. For the two-sample client trial, begin with `EDITOR_WORKFLOW.md`. The clean format and language are in `sample.schema.json` and `VOCABULARY.md`. -## MVP collector +## MVP recorder The editor-facing interface is a native Qt desktop app. On Linux, double-click: @@ -22,9 +22,11 @@ video-path-pilot/run-collector-app.sh ``` Choose **Run** if the file manager asks whether to display or execute the file. -The app provides forms and file pickers for the complete workflow; no terminal -commands are required. The launcher uses the existing Craft/Qt environment and -builds the small GUI automatically on first use. +The one-screen app creates a session folder automatically, launches Kdenlive +with an isolated fresh configuration, records the edit, validates normal +termination, and opens the return folder. It never asks for an editor plan, +rationale, creative decisions, or subjective review. No terminal commands are +required. The underlying command interface remains available to developers and tests: @@ -32,10 +34,9 @@ The underlying command interface remains available to developers and tests: python3 video-path-pilot/sample_collector.py --help ``` -`init` creates a workspace, `launch` starts the recorder, `note` captures an -occasional creative decision, and `finalize` normalizes and validates -`sample.json`. Undo/redo remains in evidence but is removed from the clean -successful trajectory. +The underlying Python sample tools are internal prototypes, not part of the +editor workflow. Undo/redo remains in raw evidence and can be removed later +when the internal team constructs the clean successful trajectory. The pilot is based on upstream Kdenlive revision `7de2ed9902b4288797a7781498546389a482a39e`. diff --git a/video-path-pilot/gui/CMakeLists.txt b/video-path-pilot/gui/CMakeLists.txt index cf6390f..5812038 100644 --- a/video-path-pilot/gui/CMakeLists.txt +++ b/video-path-pilot/gui/CMakeLists.txt @@ -2,12 +2,12 @@ # SPDX-License-Identifier: GPL-3.0-only cmake_minimum_required(VERSION 3.20) -project(EditPathCollector VERSION 0.1.0 LANGUAGES CXX) +project(EditPathRecorder VERSION 0.1.0 LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) find_package(Qt6 6.5 REQUIRED COMPONENTS Core Gui Widgets) -qt_add_executable(edit-path-collector main.cpp) -target_link_libraries(edit-path-collector PRIVATE Qt6::Core Qt6::Gui Qt6::Widgets) -install(TARGETS edit-path-collector RUNTIME DESTINATION bin) +qt_add_executable(edit-path-recorder main.cpp) +target_link_libraries(edit-path-recorder PRIVATE Qt6::Core Qt6::Gui Qt6::Widgets) +install(TARGETS edit-path-recorder RUNTIME DESTINATION bin) diff --git a/video-path-pilot/gui/main.cpp b/video-path-pilot/gui/main.cpp index 98246bf..616574c 100644 --- a/video-path-pilot/gui/main.cpp +++ b/video-path-pilot/gui/main.cpp @@ -3,82 +3,71 @@ #include #include +#include +#include #include -#include -#include -#include +#include #include -#include -#include -#include #include -#include -#include #include #include #include #include #include #include -#include #include -#include +#include +#include #include namespace { QString repositoryRoot() { const QString configured = qEnvironmentVariable("EDIT_PATH_REPO_ROOT"); - if (!configured.isEmpty() && QFileInfo::exists(configured + QStringLiteral("/video-path-pilot/sample_collector.py"))) { + if (!configured.isEmpty() && QFileInfo::exists(configured + QStringLiteral("/video-path-pilot/run-video-path-pilot.sh"))) { return QDir(configured).absolutePath(); } QDir current(QCoreApplication::applicationDirPath()); for (int depth = 0; depth < 6; ++depth) { - if (QFileInfo::exists(current.filePath(QStringLiteral("video-path-pilot/sample_collector.py")))) { + if (QFileInfo::exists(current.filePath(QStringLiteral("video-path-pilot/run-video-path-pilot.sh")))) { return current.absolutePath(); } - if (!current.cdUp()) { - break; - } + if (!current.cdUp()) break; } return {}; } -QTextEdit *paragraphEditor(const QString &placeholder) +QString defaultSessionRoot() { - auto *editor = new QTextEdit; - editor->setPlaceholderText(placeholder); - editor->setMinimumHeight(72); - return editor; + QString videos = QStandardPaths::writableLocation(QStandardPaths::MoviesLocation); + if (videos.isEmpty()) videos = QDir::homePath() + QStringLiteral("/Videos"); + return QDir(videos).filePath(QStringLiteral("EditPathSessions")); } } -class CollectorWindow final : public QMainWindow +class RecorderWindow final : public QMainWindow { public: - CollectorWindow() + RecorderWindow() : m_repoRoot(repositoryRoot()) { - setWindowTitle(QStringLiteral("Edit Path Collector MVP")); - resize(900, 760); + setWindowTitle(QStringLiteral("Edit Path Recorder MVP")); + resize(720, 520); buildUi(); - QSettings settings; - const QString previous = settings.value(QStringLiteral("currentSample")).toString(); - if (!previous.isEmpty() && QFileInfo::exists(previous + QStringLiteral("/internal/collector-metadata.json"))) { - selectSample(previous); - } + const QString previous = QSettings().value(QStringLiteral("lastSession")).toString(); + if (!previous.isEmpty() && QDir(previous).exists()) setSession(previous); if (m_repoRoot.isEmpty()) { - showError(QStringLiteral("Collector files could not be found. Start the app with the supplied launcher.")); - setControlsEnabled(false); + m_startButton->setEnabled(false); + setStatus(QStringLiteral("Recorder installation was not found."), true); } } protected: void closeEvent(QCloseEvent *event) override { - if (m_process.state() != QProcess::NotRunning || m_editorProcess.state() != QProcess::NotRunning) { - QMessageBox::warning(this, QStringLiteral("Kdenlive is running"), - QStringLiteral("Close Kdenlive before closing the collector.")); + if (m_editor.state() != QProcess::NotRunning || m_validator.state() != QProcess::NotRunning) { + QMessageBox::warning(this, QStringLiteral("Recording active"), + QStringLiteral("Close Kdenlive normally before closing the recorder.")); event->ignore(); return; } @@ -90,301 +79,167 @@ class CollectorWindow final : public QMainWindow { auto *central = new QWidget; auto *layout = new QVBoxLayout(central); - auto *title = new QLabel(QStringLiteral("

Edit Path Collector

Create and package training samples without using a terminal.

")); - title->setTextFormat(Qt::RichText); + auto *title = new QLabel(QStringLiteral( + "

Edit Path Recorder

" + "

This application records editing actions and timeline outcomes. " + "It does not ask for plans, explanations, decisions, or other editor intent.

")); + title->setWordWrap(true); layout->addWidget(title); - auto *workspaceRow = new QHBoxLayout; - m_workspaceLabel = new QLabel(QStringLiteral("No sample selected")); - m_workspaceLabel->setTextInteractionFlags(Qt::TextSelectableByMouse); - auto *newButton = new QPushButton(QStringLiteral("Create New Sample")); - auto *openButton = new QPushButton(QStringLiteral("Open Existing Sample")); - workspaceRow->addWidget(m_workspaceLabel, 1); - workspaceRow->addWidget(newButton); - workspaceRow->addWidget(openButton); - layout->addLayout(workspaceRow); - - m_newSample = new QGroupBox(QStringLiteral("1. Sample setup")); - auto *form = new QFormLayout(m_newSample); - m_sampleDirectory = new QLineEdit; - auto *directoryRow = new QHBoxLayout; - auto *chooseDirectory = new QPushButton(QStringLiteral("Choose…")); - directoryRow->addWidget(m_sampleDirectory, 1); - directoryRow->addWidget(chooseDirectory); - form->addRow(QStringLiteral("New sample folder"), directoryRow); - m_editorId = new QLineEdit(QStringLiteral("editor_001")); - form->addRow(QStringLiteral("Editor ID"), m_editorId); - m_prompt = paragraphEditor(QStringLiteral("What should the finished video accomplish?")); - form->addRow(QStringLiteral("Editing prompt"), m_prompt); - m_plan = paragraphEditor(QStringLiteral("Describe your intended structure, pacing, audio, and finish.")); - form->addRow(QStringLiteral("Initial plan"), m_plan); - auto *profileRow = new QHBoxLayout; - m_width = new QSpinBox; m_width->setRange(1, 16384); m_width->setValue(1920); - m_height = new QSpinBox; m_height->setRange(1, 16384); m_height->setValue(1080); - m_fpsNumerator = new QSpinBox; m_fpsNumerator->setRange(1, 240000); m_fpsNumerator->setValue(25); - m_fpsDenominator = new QSpinBox; m_fpsDenominator->setRange(1, 1001); m_fpsDenominator->setValue(1); - profileRow->addWidget(new QLabel(QStringLiteral("Width"))); profileRow->addWidget(m_width); - profileRow->addWidget(new QLabel(QStringLiteral("Height"))); profileRow->addWidget(m_height); - profileRow->addWidget(new QLabel(QStringLiteral("FPS"))); profileRow->addWidget(m_fpsNumerator); - profileRow->addWidget(new QLabel(QStringLiteral("/"))); profileRow->addWidget(m_fpsDenominator); - form->addRow(QStringLiteral("Project profile"), profileRow); - m_assets = new QListWidget; m_assets->setMinimumHeight(90); - auto *assetButtons = new QHBoxLayout; - auto *addAssets = new QPushButton(QStringLiteral("Add Asset Files…")); - auto *removeAsset = new QPushButton(QStringLiteral("Remove Selected")); - assetButtons->addWidget(addAssets); assetButtons->addWidget(removeAsset); assetButtons->addStretch(); - form->addRow(QStringLiteral("Source assets"), m_assets); - form->addRow(QString(), assetButtons); - auto *createButton = new QPushButton(QStringLiteral("Create Sample")); - form->addRow(QString(), createButton); - layout->addWidget(m_newSample); - - m_workflow = new QGroupBox(QStringLiteral("2. Edit and annotate")); - auto *workflowLayout = new QVBoxLayout(m_workflow); - m_status = new QLabel(QStringLiteral("Create or open a sample to begin.")); + auto *instructions = new QLabel(QStringLiteral( + "Editor instructions
    " + "
  1. Click Start Editing Session.
  2. " + "
  3. Use the fresh Kdenlive window and edit normally with the assets supplied for the task.
  4. " + "
  5. Save the Kdenlive project and rendered video in the session folder shown below.
  6. " + "
  7. Close Kdenlive normally and wait for recording validation.
  8. " + "
  9. Return the complete session folder to the project team.
  10. " + "
")); + instructions->setWordWrap(true); + layout->addWidget(instructions); + + m_status = new QLabel; m_status->setWordWrap(true); - workflowLayout->addWidget(m_status); - auto *launchButton = new QPushButton(QStringLiteral("Launch Instrumented Kdenlive")); - workflowLayout->addWidget(launchButton); - auto *noteForm = new QFormLayout; - m_reason = new QLineEdit; m_reason->setPlaceholderText(QStringLiteral("Why was a decision needed?")); - m_decision = new QLineEdit; m_decision->setPlaceholderText(QStringLiteral("What did you decide to do?")); - auto *saveNote = new QPushButton(QStringLiteral("Save Creative Decision")); - noteForm->addRow(QStringLiteral("Reason"), m_reason); - noteForm->addRow(QStringLiteral("Decision"), m_decision); - noteForm->addRow(QString(), saveNote); - workflowLayout->addLayout(noteForm); - layout->addWidget(m_workflow); - - m_finalize = new QGroupBox(QStringLiteral("3. Finalize sample")); - auto *finalForm = new QFormLayout(m_finalize); - m_projectFile = new QLineEdit; - m_outputFile = new QLineEdit; - auto addPicker = [this, finalForm](const QString &label, QLineEdit *field, const QString &filter) { - auto *row = new QHBoxLayout; - auto *button = new QPushButton(QStringLiteral("Choose…")); - row->addWidget(field, 1); row->addWidget(button); - finalForm->addRow(label, row); - connect(button, &QPushButton::clicked, this, [this, field, filter] { - const QString file = QFileDialog::getOpenFileName(this, QStringLiteral("Choose file"), {}, filter); - if (!file.isEmpty()) field->setText(file); - }); - }; - addPicker(QStringLiteral("Saved Kdenlive project"), m_projectFile, QStringLiteral("Kdenlive projects (*.kdenlive);;All files (*)")); - addPicker(QStringLiteral("Rendered final video"), m_outputFile, QStringLiteral("Video files (*.mp4 *.mov *.mkv *.webm);;All files (*)")); - m_review = paragraphEditor(QStringLiteral("Confirm how the result follows the prompt and what you checked.")); - finalForm->addRow(QStringLiteral("Final editor review"), m_review); - auto *finishRow = new QHBoxLayout; - auto *finalizeButton = new QPushButton(QStringLiteral("Finalize and Validate")); - auto *validateButton = new QPushButton(QStringLiteral("Validate Existing Sample")); - finishRow->addWidget(finalizeButton); finishRow->addWidget(validateButton); finishRow->addStretch(); - finalForm->addRow(QString(), finishRow); - layout->addWidget(m_finalize); - - m_log = new QPlainTextEdit; m_log->setReadOnly(true); m_log->setMaximumBlockCount(500); m_log->setMinimumHeight(100); - layout->addWidget(new QLabel(QStringLiteral("Activity"))); - layout->addWidget(m_log); + m_status->setStyleSheet(QStringLiteral("padding: 10px; border-radius: 4px; background: #e8eef7;")); + setStatus(QStringLiteral("Ready to start a new editing session.")); + layout->addWidget(m_status); + + auto *pathTitle = new QLabel(QStringLiteral("Current session folder")); + layout->addWidget(pathTitle); + m_sessionPath = new QLabel(QStringLiteral("No session created yet")); + m_sessionPath->setTextInteractionFlags(Qt::TextSelectableByMouse); + m_sessionPath->setWordWrap(true); + layout->addWidget(m_sessionPath); + + auto *buttons = new QHBoxLayout; + m_startButton = new QPushButton(QStringLiteral("Start Editing Session")); + m_startButton->setMinimumHeight(42); + m_openButton = new QPushButton(QStringLiteral("Open Session Folder")); + m_openButton->setEnabled(false); + buttons->addWidget(m_startButton); + buttons->addWidget(m_openButton); + layout->addLayout(buttons); + + m_activity = new QPlainTextEdit; + m_activity->setReadOnly(true); + m_activity->setMaximumBlockCount(200); + m_activity->setPlaceholderText(QStringLiteral("Recording activity will appear here.")); + layout->addWidget(m_activity, 1); setCentralWidget(central); - setControlsEnabled(false); - connect(newButton, &QPushButton::clicked, this, [this] { m_newSample->setVisible(true); }); - connect(openButton, &QPushButton::clicked, this, &CollectorWindow::openSample); - connect(chooseDirectory, &QPushButton::clicked, this, [this] { - const QString parent = QFileDialog::getExistingDirectory(this, QStringLiteral("Choose parent folder")); - if (!parent.isEmpty()) m_sampleDirectory->setText(QDir(parent).filePath(QStringLiteral("sample_001"))); - }); - connect(addAssets, &QPushButton::clicked, this, [this] { - const QStringList files = QFileDialog::getOpenFileNames(this, QStringLiteral("Choose source assets")); - for (const QString &file : files) if (m_assets->findItems(file, Qt::MatchExactly).isEmpty()) m_assets->addItem(file); + connect(m_startButton, &QPushButton::clicked, this, &RecorderWindow::startSession); + connect(m_openButton, &QPushButton::clicked, this, [this] { + QDesktopServices::openUrl(QUrl::fromLocalFile(m_currentSession)); }); - connect(removeAsset, &QPushButton::clicked, this, [this] { qDeleteAll(m_assets->selectedItems()); }); - connect(createButton, &QPushButton::clicked, this, &CollectorWindow::createSample); - connect(launchButton, &QPushButton::clicked, this, &CollectorWindow::launchEditor); - connect(saveNote, &QPushButton::clicked, this, &CollectorWindow::saveDecision); - connect(finalizeButton, &QPushButton::clicked, this, &CollectorWindow::finalizeSample); - connect(validateButton, &QPushButton::clicked, this, [this] { runCollector({QStringLiteral("validate"), m_currentSample}); }); - connect(&m_process, &QProcess::readyReadStandardOutput, this, &CollectorWindow::readProcessOutput); - connect(&m_process, &QProcess::readyReadStandardError, this, &CollectorWindow::readProcessOutput); - connect(&m_process, qOverload(&QProcess::finished), this, &CollectorWindow::processFinished); - connect(&m_editorProcess, qOverload(&QProcess::finished), this, &CollectorWindow::editorFinished); + connect(&m_editor, qOverload(&QProcess::finished), + this, &RecorderWindow::editorFinished); + connect(&m_validator, &QProcess::readyReadStandardOutput, this, &RecorderWindow::readValidatorOutput); + connect(&m_validator, &QProcess::readyReadStandardError, this, &RecorderWindow::readValidatorOutput); + connect(&m_validator, qOverload(&QProcess::finished), + this, &RecorderWindow::validatorFinished); } - void setControlsEnabled(bool enabled) + void setStatus(const QString &message, bool error = false) { - m_workflow->setEnabled(enabled); - m_finalize->setEnabled(enabled); + m_status->setText(message); + m_status->setStyleSheet(error + ? QStringLiteral("padding: 10px; border-radius: 4px; background: #f7dddd; color: #7d1010;") + : QStringLiteral("padding: 10px; border-radius: 4px; background: #e2f2e5; color: #164d24;")); } - void showError(const QString &message) { QMessageBox::critical(this, QStringLiteral("Edit Path Collector"), message); } - - void selectSample(const QString &path) + void setSession(const QString &path) { - const QString absolute = QDir(path).absolutePath(); - if (!QFileInfo::exists(absolute + QStringLiteral("/internal/collector-metadata.json"))) { - showError(QStringLiteral("This folder is not a collector sample.")); - return; - } - m_currentSample = absolute; - m_workspaceLabel->setText(absolute); - m_status->setText(QStringLiteral("Sample ready. Launch Kdenlive, import assets in filename order, edit, save, render, and close normally.")); - setControlsEnabled(true); - m_newSample->setVisible(false); - QSettings().setValue(QStringLiteral("currentSample"), absolute); + m_currentSession = QDir(path).absolutePath(); + m_sessionPath->setText(m_currentSession); + m_openButton->setEnabled(true); + QSettings().setValue(QStringLiteral("lastSession"), m_currentSession); } - void openSample() + void startSession() { - const QString directory = QFileDialog::getExistingDirectory(this, QStringLiteral("Open sample folder")); - if (!directory.isEmpty()) selectSample(directory); - } - - void createSample() - { - if (m_sampleDirectory->text().trimmed().isEmpty() || m_editorId->text().trimmed().isEmpty() - || m_prompt->toPlainText().trimmed().isEmpty() || m_plan->toPlainText().trimmed().isEmpty() || m_assets->count() == 0) { - showError(QStringLiteral("Folder, editor ID, prompt, plan, and at least one asset are required.")); + if (m_editor.state() != QProcess::NotRunning) return; + const QString stamp = QDateTime::currentDateTimeUtc().toString(QStringLiteral("yyyyMMdd_HHmmss")); + const QString suffix = QUuid::createUuid().toString(QUuid::WithoutBraces).left(8); + const QString path = QDir(defaultSessionRoot()).filePath(QStringLiteral("session_%1_%2").arg(stamp, suffix)); + QDir directory; + if (!directory.mkpath(path)) { + setStatus(QStringLiteral("Could not create the session folder: %1").arg(path), true); return; } - QStringList arguments{QStringLiteral("init"), m_sampleDirectory->text().trimmed(), - QStringLiteral("--editor-id"), m_editorId->text().trimmed(), - QStringLiteral("--prompt"), m_prompt->toPlainText().trimmed(), - QStringLiteral("--plan"), m_plan->toPlainText().trimmed(), - QStringLiteral("--fps-num"), QString::number(m_fpsNumerator->value()), - QStringLiteral("--fps-den"), QString::number(m_fpsDenominator->value()), - QStringLiteral("--width"), QString::number(m_width->value()), - QStringLiteral("--height"), QString::number(m_height->value())}; - for (int i = 0; i < m_assets->count(); ++i) arguments << m_assets->item(i)->text(); - m_pendingSample = QDir(m_sampleDirectory->text().trimmed()).absolutePath(); - runCollector(arguments, QStringLiteral("init")); - } - - void launchEditor() - { - if (m_editorProcess.state() != QProcess::NotRunning) { - showError(QStringLiteral("Kdenlive is already running for this sample.")); - return; - } - if (QFileInfo::exists(m_currentSample + QStringLiteral("/evidence/raw-events.jsonl"))) { - showError(QStringLiteral("This sample already has a recording. Open or create a fresh sample instead of overwriting evidence.")); - return; + setSession(path); + const QString raw = QDir(path).filePath(QStringLiteral("raw-events.jsonl")); + const QString console = QDir(path).filePath(QStringLiteral("kdenlive-console.log")); + const QString configName = QStringLiteral("edit-path-%1rc").arg(suffix); + + m_activity->appendPlainText(QStringLiteral("Session created: %1").arg(path)); + m_activity->appendPlainText(QStringLiteral("Launching a fresh isolated Kdenlive session…")); + setStatus(QStringLiteral("Recording in progress. Save the project and rendered video in the session folder, then close Kdenlive normally.")); + m_startButton->setEnabled(false); + + QProcessEnvironment environment = QProcessEnvironment::systemEnvironment(); + environment.insert(QStringLiteral("KDENLIVE_VIDEO_PATH_CONFIG"), configName); + m_editor.setProcessEnvironment(environment); + m_editor.setWorkingDirectory(m_repoRoot); + m_editor.setProcessChannelMode(QProcess::MergedChannels); + m_editor.setStandardOutputFile(console, QIODevice::Append); + m_editor.start(m_repoRoot + QStringLiteral("/video-path-pilot/run-video-path-pilot.sh"), {raw}); + if (!m_editor.waitForStarted(5000)) { + m_startButton->setEnabled(true); + setStatus(QStringLiteral("Kdenlive could not be started. See kdenlive-console.log."), true); } - m_status->setText(QStringLiteral("Kdenlive is running. Keep this collector open and close Kdenlive normally when finished.")); - QStringList arguments{m_repoRoot + QStringLiteral("/video-path-pilot/sample_collector.py"), - QStringLiteral("launch"), m_currentSample}; - m_log->appendPlainText(QStringLiteral("Launching instrumented Kdenlive…")); - m_editorProcess.setWorkingDirectory(m_repoRoot); - m_editorProcess.setProcessChannelMode(QProcess::MergedChannels); - m_editorProcess.setStandardOutputFile(m_currentSample + QStringLiteral("/internal/kdenlive-console.log"), QIODevice::Append); - m_editorProcess.start(QStringLiteral("python3"), arguments); } - void saveDecision() - { - if (m_reason->text().trimmed().isEmpty() || m_decision->text().trimmed().isEmpty()) { - showError(QStringLiteral("Both the reason and decision are required.")); - return; - } - runCollector({QStringLiteral("note"), m_currentSample, QStringLiteral("--reason"), m_reason->text().trimmed(), - QStringLiteral("--decision"), m_decision->text().trimmed()}, QStringLiteral("note")); - } - - void finalizeSample() - { - if (m_editorProcess.state() != QProcess::NotRunning) { - showError(QStringLiteral("Close Kdenlive normally before finalizing the sample.")); - return; - } - if (m_projectFile->text().trimmed().isEmpty() || m_outputFile->text().trimmed().isEmpty() - || m_review->toPlainText().trimmed().isEmpty()) { - showError(QStringLiteral("Choose the saved project and final video, then provide the final review.")); - return; - } - runCollector({QStringLiteral("finalize"), m_currentSample, - QStringLiteral("--project"), m_projectFile->text().trimmed(), - QStringLiteral("--output"), m_outputFile->text().trimmed(), - QStringLiteral("--review"), m_review->toPlainText().trimmed()}, QStringLiteral("finalize")); - } - - void runCollector(QStringList arguments, const QString &purpose = {}) + void editorFinished(int exitCode, QProcess::ExitStatus status) { - if (m_process.state() != QProcess::NotRunning) { - showError(QStringLiteral("Another collector task is still running.")); - return; + m_activity->appendPlainText(QStringLiteral("Kdenlive exited; validating the recording…")); + if (status != QProcess::NormalExit || exitCode != 0) { + m_activity->appendPlainText(QStringLiteral("Kdenlive exit code: %1").arg(exitCode)); } - m_purpose = purpose; - arguments.prepend(m_repoRoot + QStringLiteral("/video-path-pilot/sample_collector.py")); - m_log->appendPlainText(QStringLiteral("Starting %1…").arg(purpose.isEmpty() ? arguments.value(1) : purpose)); - m_process.setWorkingDirectory(m_repoRoot); - m_process.start(QStringLiteral("python3"), arguments); + const QString validator = m_repoRoot + QStringLiteral("/video-path-pilot/validate_video_path.py"); + const QString raw = QDir(m_currentSession).filePath(QStringLiteral("raw-events.jsonl")); + m_validator.setWorkingDirectory(m_repoRoot); + m_validator.start(QStringLiteral("python3"), {validator, raw}); } - void readProcessOutput() + void readValidatorOutput() { - const QString standard = QString::fromUtf8(m_process.readAllStandardOutput()).trimmed(); - const QString errors = QString::fromUtf8(m_process.readAllStandardError()).trimmed(); - if (!standard.isEmpty()) m_log->appendPlainText(standard); - if (!errors.isEmpty()) m_log->appendPlainText(errors); + const QString output = QString::fromUtf8(m_validator.readAllStandardOutput()).trimmed(); + const QString errors = QString::fromUtf8(m_validator.readAllStandardError()).trimmed(); + if (!output.isEmpty()) m_activity->appendPlainText(output); + if (!errors.isEmpty()) m_activity->appendPlainText(errors); } - void processFinished(int exitCode, QProcess::ExitStatus status) + void validatorFinished(int exitCode, QProcess::ExitStatus status) { - readProcessOutput(); - const bool success = status == QProcess::NormalExit && exitCode == 0; - m_log->appendPlainText(success ? QStringLiteral("Completed successfully.") : QStringLiteral("Task failed (exit code %1).").arg(exitCode)); - if (success && m_purpose == QStringLiteral("init")) selectSample(m_pendingSample); - if (success && m_purpose == QStringLiteral("note")) { m_reason->clear(); m_decision->clear(); } - if (success && m_purpose == QStringLiteral("finalize")) { - m_status->setText(QStringLiteral("Sample finalized and validated. It is ready for human review.")); - QMessageBox::information(this, QStringLiteral("Sample complete"), QStringLiteral("sample.json and all required evidence were created successfully.")); - } else if (!success && m_purpose != QStringLiteral("launch")) { - showError(QStringLiteral("The task failed. See Activity for details.")); + readValidatorOutput(); + m_startButton->setEnabled(true); + if (status == QProcess::NormalExit && exitCode == 0) { + setStatus(QStringLiteral("Recording completed successfully. Return this session folder, the saved project, rendered video, and source assets to the project team.")); + QMessageBox::information(this, QStringLiteral("Recording complete"), + QStringLiteral("The editing session was recorded and validated successfully.")); + } else { + setStatus(QStringLiteral("Recording is incomplete or invalid. Kdenlive may have crashed or been force-quit. Do not submit this session as complete."), true); + QMessageBox::warning(this, QStringLiteral("Recording incomplete"), + QStringLiteral("The session did not pass validation. See Activity and kdenlive-console.log.")); } - m_purpose.clear(); - } - - void editorFinished(int exitCode, QProcess::ExitStatus status) - { - const bool success = status == QProcess::NormalExit && exitCode == 0; - m_log->appendPlainText(success ? QStringLiteral("Kdenlive closed normally.") - : QStringLiteral("Kdenlive exited unexpectedly (code %1).").arg(exitCode)); - m_status->setText(success ? QStringLiteral("Kdenlive closed normally. Select the saved project and render to finalize.") - : QStringLiteral("Kdenlive did not close cleanly. This recording may be incomplete.")); } QString m_repoRoot; - QString m_currentSample; - QString m_pendingSample; - QString m_purpose; - QProcess m_process; - QProcess m_editorProcess; - QLabel *m_workspaceLabel{}; + QString m_currentSession; + QProcess m_editor; + QProcess m_validator; QLabel *m_status{}; - QGroupBox *m_newSample{}; - QGroupBox *m_workflow{}; - QGroupBox *m_finalize{}; - QLineEdit *m_sampleDirectory{}; - QLineEdit *m_editorId{}; - QTextEdit *m_prompt{}; - QTextEdit *m_plan{}; - QSpinBox *m_width{}; - QSpinBox *m_height{}; - QSpinBox *m_fpsNumerator{}; - QSpinBox *m_fpsDenominator{}; - QListWidget *m_assets{}; - QLineEdit *m_reason{}; - QLineEdit *m_decision{}; - QLineEdit *m_projectFile{}; - QLineEdit *m_outputFile{}; - QTextEdit *m_review{}; - QPlainTextEdit *m_log{}; + QLabel *m_sessionPath{}; + QPushButton *m_startButton{}; + QPushButton *m_openButton{}; + QPlainTextEdit *m_activity{}; }; int main(int argc, char **argv) { QApplication application(argc, argv); QCoreApplication::setOrganizationName(QStringLiteral("Parsewave")); - QCoreApplication::setApplicationName(QStringLiteral("EditPathCollector")); - CollectorWindow window; + QCoreApplication::setApplicationName(QStringLiteral("EditPathRecorder")); + RecorderWindow window; window.show(); return application.exec(); } diff --git a/video-path-pilot/normalize_sample.py b/video-path-pilot/normalize_sample.py index 629e557..505c47f 100755 --- a/video-path-pilot/normalize_sample.py +++ b/video-path-pilot/normalize_sample.py @@ -106,31 +106,15 @@ def build_sample(root: Path, metadata: dict) -> dict: "evidence": {"raw_event_id": event.get("event_id"), "raw_sequence": event.get("sequence")}, "extensions": {"kdenlive": {"command_label": event.get("label")}}, }) - notes = read_jsonl(root / "internal" / "rationale.jsonl") - # Notes are entered immediately after a meaningful decision. Associate each - # with the latest accepted edit that had completed when the note was saved. - for note in notes: - preceding = [ - (operation, event) for operation, event in zip(operations, accepted_commits(events)) - if event.get("timestamp_utc", "") <= note.get("timestamp_utc", "") - ] - note["after_operation_id"] = preceding[-1][0]["operation_id"] if preceding else None - notes_by_operation: dict[str, list[str]] = {} - for note in notes: - if note.get("after_operation_id"): - notes_by_operation.setdefault(note["after_operation_id"], []).append(note.get("note_id")) - for operation in operations: - operation["rationale_note_ids"] = notes_by_operation.get(operation["operation_id"], []) input_assets = [{k: a[k] for k in ("asset_id", "original_filename", "file", "sha256", "bytes")} for a in metadata["assets"]] unresolved = sorted(set(asset_refs.values()) - {a["asset_id"] for a in input_assets}) return { "schema_version": "0.1.0", "sample_id": metadata["sample_id"], - "task": {"prompt": metadata["prompt"], "editor_plan": metadata["editor_plan"]}, + "task": {"prompt": metadata["prompt"]}, "project": metadata["project"], "inputs": {"assets": input_assets}, "edit_path": {"time_unit": "frame", "operations": operations}, - "rationale": {"decision_notes": notes, "editor_review": metadata["editor_review"]}, "output": {"video": metadata["artifacts"]["final_video"], "sha256": metadata["artifacts"]["final_video_sha256"]}, "quality": { "raw_session_complete": True, @@ -138,6 +122,7 @@ def build_sample(root: Path, metadata: dict) -> dict: "asset_binding_method": metadata["asset_binding_method"], "unresolved_asset_ids": unresolved, "review_status": "needs_human_review", + "output_completion_confirmed": metadata["output_completion_confirmed"], }, "evidence": { "raw_events": metadata["artifacts"]["raw_events"], diff --git a/video-path-pilot/run-collector-app.sh b/video-path-pilot/run-collector-app.sh index aba6964..b236128 100755 --- a/video-path-pilot/run-collector-app.sh +++ b/video-path-pilot/run-collector-app.sh @@ -6,7 +6,7 @@ set -euo pipefail script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) repo_root=$(cd -- "$script_dir/.." && pwd) craft_root=${KDENLIVE_PILOT_CRAFT_ROOT:-/home/tenali/CraftRoot} -binary="$repo_root/build/collector-gui/edit-path-collector" +binary="$repo_root/build/collector-gui/edit-path-recorder" export PATH="$craft_root/dev-utils/bin:$craft_root/bin:$craft_root/libexec:$PATH" if [[ ! -x $binary ]]; then diff --git a/video-path-pilot/run-video-path-pilot.sh b/video-path-pilot/run-video-path-pilot.sh index c6dd0c6..b463bff 100755 --- a/video-path-pilot/run-video-path-pilot.sh +++ b/video-path-pilot/run-video-path-pilot.sh @@ -40,4 +40,9 @@ export MLT_REPOSITORY="$craft_root/lib/mlt-7" export QT_DATA_DIRS="$source_root/data${QT_DATA_DIRS:+:$QT_DATA_DIRS}" export KDENLIVE_VIDEO_PATH_LOG=$log_path -exec "$binary" +arguments=() +if [[ -n ${KDENLIVE_VIDEO_PATH_CONFIG:-} ]]; then + arguments+=(--config "$KDENLIVE_VIDEO_PATH_CONFIG" --no-welcome) +fi + +exec "$binary" "${arguments[@]}" diff --git a/video-path-pilot/sample.schema.json b/video-path-pilot/sample.schema.json index cf8a226..f5aa999 100644 --- a/video-path-pilot/sample.schema.json +++ b/video-path-pilot/sample.schema.json @@ -3,16 +3,15 @@ "$id": "https://parsewave.example/schemas/edit-path-sample-0.1.0.json", "title": "Edit-path training sample MVP", "type": "object", - "required": ["schema_version", "sample_id", "task", "project", "inputs", "edit_path", "rationale", "output", "quality", "evidence", "provenance"], + "required": ["schema_version", "sample_id", "task", "project", "inputs", "edit_path", "output", "quality", "evidence", "provenance"], "properties": { "schema_version": {"const": "0.1.0"}, "sample_id": {"type": "string", "minLength": 1}, "task": { "type": "object", - "required": ["prompt", "editor_plan"], + "required": ["prompt"], "properties": { - "prompt": {"type": "string", "minLength": 1}, - "editor_plan": {"type": "string", "minLength": 1} + "prompt": {"type": "string", "minLength": 1} } }, "project": { diff --git a/video-path-pilot/sample_collector.py b/video-path-pilot/sample_collector.py index d301d05..67c0b7c 100755 --- a/video-path-pilot/sample_collector.py +++ b/video-path-pilot/sample_collector.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 # SPDX-FileCopyrightText: 2026 Video Path Pilot contributors # SPDX-License-Identifier: GPL-3.0-only -"""Create, annotate, launch, and finalize an editor sample workspace.""" +"""Internal prototype for constructing and validating a sample package.""" from __future__ import annotations @@ -11,7 +11,6 @@ import shutil import subprocess import sys -import uuid from datetime import datetime, timezone from pathlib import Path @@ -71,7 +70,6 @@ def command_init(args: argparse.Namespace) -> int: "status": "initialized", "prompt": args.prompt, "editor": {"editor_id": args.editor_id}, - "editor_plan": args.plan, "project": { "frame_rate": {"numerator": args.fps_num, "denominator": args.fps_den}, "width": args.width, @@ -81,7 +79,6 @@ def command_init(args: argparse.Namespace) -> int: "asset_binding_method": "first_use_order", } dump(root / "internal" / "collector-metadata.json", metadata) - (root / "internal" / "rationale.jsonl").touch() print(f"created sample workspace: {root}") print("Import the files from its assets/ directory into Kdenlive in filename order.") print(f"Then launch with: {Path(__file__).name} launch {root}") @@ -95,21 +92,6 @@ def load_metadata(root: Path) -> dict: return json.loads(path.read_text(encoding="utf-8")) -def command_note(args: argparse.Namespace) -> int: - root = args.sample_dir.resolve() - load_metadata(root) - note = { - "note_id": str(uuid.uuid4()), - "timestamp_utc": utc_now(), - "reason": args.reason, - "decision": args.decision, - } - with (root / "internal" / "rationale.jsonl").open("a", encoding="utf-8") as stream: - stream.write(json.dumps(note, ensure_ascii=False) + "\n") - print(f"saved rationale note: {note['note_id']}") - return 0 - - def command_launch(args: argparse.Namespace) -> int: root = args.sample_dir.resolve() metadata = load_metadata(root) @@ -144,7 +126,7 @@ def command_finalize(args: argparse.Namespace) -> int: copy_artifact(args.output, final_video) metadata["status"] = "finalized" metadata["finalized_at_utc"] = utc_now() - metadata["editor_review"] = args.review + metadata["output_completion_confirmed"] = True metadata["artifacts"] = { "final_video": final_video.relative_to(root).as_posix(), "final_video_sha256": sha256(final_video), @@ -181,7 +163,6 @@ def parser() -> argparse.ArgumentParser: init.add_argument("--sample-id") init.add_argument("--prompt", required=True) init.add_argument("--editor-id", required=True) - init.add_argument("--plan", required=True, help="editor's high-level plan before editing") init.add_argument("--fps-num", type=int, default=25) init.add_argument("--fps-den", type=int, default=1) init.add_argument("--width", type=int, default=1920) @@ -189,12 +170,6 @@ def parser() -> argparse.ArgumentParser: init.add_argument("assets", type=Path, nargs="+") init.set_defaults(function=command_init) - note = sub.add_parser("note", help="record why an important editing decision was made") - note.add_argument("sample_dir", type=Path) - note.add_argument("--reason", required=True) - note.add_argument("--decision", required=True) - note.set_defaults(function=command_note) - launch = sub.add_parser("launch", help="start the instrumented Kdenlive") launch.add_argument("sample_dir", type=Path) launch.set_defaults(function=command_launch) @@ -203,7 +178,8 @@ def parser() -> argparse.ArgumentParser: finalize.add_argument("sample_dir", type=Path) finalize.add_argument("--project", type=Path, required=True) finalize.add_argument("--output", type=Path, required=True) - finalize.add_argument("--review", required=True, help="editor's final assessment") + finalize.add_argument("--confirm-output-complete", action="store_true", required=True, + help="confirm that the required project and render were supplied") finalize.set_defaults(function=command_finalize) validate = sub.add_parser("validate", help="validate a finalized sample") diff --git a/video-path-pilot/tests/test_mvp.py b/video-path-pilot/tests/test_mvp.py index c07829a..9210481 100644 --- a/video-path-pilot/tests/test_mvp.py +++ b/video-path-pilot/tests/test_mvp.py @@ -32,7 +32,6 @@ def test_build_and_validate_sample(self): (root / "assets/asset_001.mp4").write_bytes(b"asset") (root / "output/final.mp4").write_bytes(b"video") (root / "internal/final.kdenlive").write_bytes(b"project") - (root / "internal/rationale.jsonl").write_text(json.dumps({"reason": "pace", "decision": "shorter opening"}) + "\n") events = [{"event_type": "session.start", "sequence": 1}, { "event_type": "state.diff", "boundary": "commit", "sequence": 2, "event_id": "raw-2", "label": "Insert Clip", "after_hash": HASH_B, @@ -43,17 +42,19 @@ def test_build_and_validate_sample(self): raw.write_text("".join(json.dumps(e) + "\n" for e in events)) sha = lambda path: hashlib.sha256(path.read_bytes()).hexdigest() metadata = { - "sample_id": "sample_test", "prompt": "Make a short edit", "editor_plan": "Use the strongest shot", + "sample_id": "sample_test", "prompt": "Make a short edit", "editor": {"editor_id": "editor_test"}, "project": {"frame_rate": {"numerator": 25, "denominator": 1}, "width": 1920, "height": 1080}, "assets": [{"asset_id": "asset_001", "original_filename": "source.mp4", "file": "assets/asset_001.mp4", "sha256": sha(root / "assets/asset_001.mp4"), "bytes": 5}], - "asset_binding_method": "first_use_order", "editor_review": "Checked", + "asset_binding_method": "first_use_order", "output_completion_confirmed": True, "artifacts": {"final_video": "output/final.mp4", "final_video_sha256": sha(root / "output/final.mp4"), "native_project": "internal/final.kdenlive", "native_project_sha256": sha(root / "internal/final.kdenlive"), "raw_events": "evidence/raw-events.jsonl", "raw_events_sha256": sha(raw)}} sample = build_sample(root, metadata) self.assertEqual(sample["edit_path"]["operations"][0]["operation"], "clip.insert") + self.assertNotIn("rationale", sample) + self.assertNotIn("editor_plan", sample["task"]) path = root / "sample.json" path.write_text(json.dumps(sample)) self.assertEqual(validate_sample(path, check_files=True), []) diff --git a/video-path-pilot/validate_sample.py b/video-path-pilot/validate_sample.py index 19e2347..3fcd800 100755 --- a/video-path-pilot/validate_sample.py +++ b/video-path-pilot/validate_sample.py @@ -26,12 +26,13 @@ def validate_sample(path: Path, check_files: bool = False) -> list[str]: sample = json.loads(path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError) as exc: return [f"cannot read sample: {exc}"] - for key in ("schema_version", "sample_id", "task", "project", "inputs", "edit_path", "rationale", "output", "quality", "evidence", "provenance"): + for key in ("schema_version", "sample_id", "task", "project", "inputs", "edit_path", "output", "quality", "evidence", "provenance"): if key not in sample: errors.append(f"missing top-level field: {key}") + if "rationale" in sample: errors.append("editor intent is prohibited: remove rationale") if sample.get("schema_version") != "0.1.0": errors.append("unsupported schema_version") task = sample.get("task", {}) - for field in ("prompt", "editor_plan"): - if not isinstance(task.get(field), str) or not task[field].strip(): errors.append(f"task.{field} must be non-empty") + if "editor_plan" in task: errors.append("editor intent is prohibited: remove task.editor_plan") + if not isinstance(task.get("prompt"), str) or not task["prompt"].strip(): errors.append("task.prompt must be non-empty") rate = sample.get("project", {}).get("frame_rate", {}) if not isinstance(rate.get("numerator"), int) or rate.get("numerator", 0) <= 0: errors.append("invalid frame-rate numerator") if not isinstance(rate.get("denominator"), int) or rate.get("denominator", 0) <= 0: errors.append("invalid frame-rate denominator") @@ -41,8 +42,11 @@ def validate_sample(path: Path, check_files: bool = False) -> list[str]: if len(ids) != len(set(ids)): errors.append("asset IDs must be unique") operations = sample.get("edit_path", {}).get("operations", []) if not operations: errors.append("edit_path requires at least one accepted operation") + for index, operation in enumerate(operations): + if isinstance(operation, dict) and "rationale_note_ids" in operation: + errors.append(f"editor intent is prohibited: remove operation {index + 1} rationale_note_ids") if sample.get("quality", {}).get("unresolved_asset_ids"): errors.append("sample has unresolved asset bindings") - if not sample.get("rationale", {}).get("editor_review", "").strip(): errors.append("editor final review is required") + if sample.get("quality", {}).get("output_completion_confirmed") is not True: errors.append("output completion must be confirmed") if check_files: root = path.parent references = [(a.get("file"), a.get("sha256")) for a in assets if isinstance(a, dict)] From 7fc5e6fe4508745ca923db43e8909203bb056f96 Mon Sep 17 00:00:00 2001 From: Tenali Rama <225068477+Tenali-Rama@users.noreply.github.com> Date: Wed, 22 Jul 2026 16:07:26 +0530 Subject: [PATCH 03/14] Add assigned-job packaging and reconstruction --- documentation.md | 56 ++++ video-path-pilot/EDITOR_WORKFLOW.md | 42 +-- video-path-pilot/README.md | 32 +- video-path-pilot/gui/main.cpp | 363 +++++++++++++---------- video-path-pilot/job.schema.json | 49 +++ video-path-pilot/job.schema.json.license | 2 + video-path-pilot/job_pipeline.py | 276 +++++++++++++++++ video-path-pilot/media_reconstruct.py | 155 ++++++++++ video-path-pilot/normalize_sample.py | 60 +++- video-path-pilot/run-collector-app.sh | 3 + video-path-pilot/run-video-path-pilot.sh | 3 + video-path-pilot/sample_collector.py | 201 ------------- video-path-pilot/tests/test_mvp.py | 52 +++- video-path-pilot/validate_sample.py | 5 +- video-path-pilot/validate_video_path.py | 6 +- 15 files changed, 906 insertions(+), 399 deletions(-) create mode 100644 video-path-pilot/job.schema.json create mode 100644 video-path-pilot/job.schema.json.license create mode 100755 video-path-pilot/job_pipeline.py create mode 100644 video-path-pilot/media_reconstruct.py delete mode 100755 video-path-pilot/sample_collector.py diff --git a/documentation.md b/documentation.md index 0b66bfc..95f6735 100644 --- a/documentation.md +++ b/documentation.md @@ -531,6 +531,62 @@ not collected from the editor application. Objective completion confirmation and later internal human review remain quality-control concerns, not editor intent. +### Assigned jobs, automatic packaging, and reconstruction foundation + +The recorder now consumes a controlled `job.json` containing job ID, external +task prompt, project profile, and hashed asset manifest. Opening the job shows +the task and automatically imports its assets when an isolated Kdenlive session +starts. Editors do not identify or order assets manually. + +`job_pipeline.py` creates and validates assigned jobs and packages completed +sessions. It parses both MLT `chain` and `producer` resources from the saved +`.kdenlive` XML, resolves each native bin ID to a job asset by SHA-256, and +rejects any unresolved native asset used by recorded operations. This replaces +the invalid first-use-order assumption exposed by the first GUI test. A pipeline +acceptance check reproduced that case: native ID 4 correctly resolved to +`asset_002`, not the first audio asset. + +Clean operations are generated automatically after the editor closes Kdenlive +and clicks Finish Job. Numbered raw segments, project, render, assets, hashes, +and `sample.json` are packaged under `completed-sample/`. The validator checks +the resulting artifact paths and hashes. + +The first reconstruction stage is implemented as independent canonical replay. +Starting at each recorded checkpoint, the pipeline applies the accepted state +diffs and recomputes deterministic SHA-256 timeline hashes after every step. +It also checks state continuity across crash-recovery segments. The replay was +verified against sessions 015, 019, 020, 023, 024, 025, 026, and 028 with exact +hash matches across clips, effects, mixes, speed, fades, track state/structure, +ripple delete, and keyframes. + +The first limited media adapter is now implemented. It builds a new MLT project +from the canonical final state for normal-speed cut/trim/move timelines without +effects, mixes, or transitions, renders `reconstructed.mp4`, probes both media +files, and compares profile, duration, video SSIM (minimum 0.95), and audio PSNR +(minimum 40 dB). A controlled single-clip reconstruction produced matching +1280×720/30 fps/eight-second media, video SSIM 0.974985, and audio PSNR +172.592 dB. Unsupported operations produce an explicit `unsupported` result +and `quality.ready_for_client_review` remains false. Expanding the adapter to +effects, transitions, speed, keyframes, titles, and other operations remains a +production gate. + +Crash handling now preserves numbered JSONL and console segments. An invalid or +missing final `session.end` enables Recover and Continue, which reuses the same +isolated Kdenlive configuration so its recovery mechanism can restore work. +Only the final segment must close normally; prior crash segments must remain +structurally valid and continuity is checked during canonical replay. +`session.json` persists the job, isolated configuration, segment number, process +ID, and lifecycle status so reopening the recorder can offer recovery or resume +finalization instead of losing supervisor state. + +An end-to-end synthetic acceptance job exercised the complete supported path: +job creation and hashing, raw checkpoint/diff recording, project resource +resolution, clean operation generation, canonical replay, new MLT project +generation, rendering, decoded comparison, sample validation, and readiness +calculation. Native asset ID 4 resolved to `asset_001`, canonical replay passed, +media reconstruction passed with SSIM 0.974985 and audio PSNR 172.592 dB, every +packaged hash validated, and `quality.ready_for_client_review` was true. + ### Privacy and security The collector can reveal editor behavior, project structure, local file paths, diff --git a/video-path-pilot/EDITOR_WORKFLOW.md b/video-path-pilot/EDITOR_WORKFLOW.md index b8e4f8a..4b06314 100644 --- a/video-path-pilot/EDITOR_WORKFLOW.md +++ b/video-path-pilot/EDITOR_WORKFLOW.md @@ -14,33 +14,39 @@ display or execute it. No terminal commands are part of the editor workflow. ## Record a session -1. Click **Start Editing Session**. The app automatically creates a unique - folder under `Videos/EditPathSessions/` and launches an isolated Kdenlive - configuration that does not reopen the previous project. -2. Edit normally using the task instructions and assets supplied separately. -3. Save the native `.kdenlive` project and rendered final video inside the +1. Click **Open Assigned Job** and select the provided `job.json`. The app shows + the externally assigned task, project profile, and asset count. +2. Click **Start Editing Session**. The app creates a unique folder under the + job's `sessions/` directory, launches an isolated Kdenlive configuration, + and imports the job assets automatically. +3. Edit normally using the displayed task and supplied assets. +4. Save the native `.kdenlive` project and rendered final video inside the session folder displayed by the recorder. -4. Close Kdenlive normally. Do not force-quit it. -5. Wait until the recorder reports that `raw-events.jsonl` passed validation. -6. Click **Open Session Folder** and return the complete folder plus the source - assets to the Parsewave team. +5. Close Kdenlive normally. If it crashes, click **Recover and Continue**; the + app preserves the prior segment and reopens the same isolated Kdenlive + recovery context. +6. After recording validation, click **Finish Job**. The app resolves project + bin IDs by asset SHA-256, normalizes the accepted edit path, generates and + validates `sample.json`, and replays canonical state hashes. +7. Click **Open Completed Sample** and return the job directory. The session folder contains at least: ```text -session_YYYYMMDD_HHMMSS_xxxxxxxx/ -├── raw-events.jsonl -├── kdenlive-console.log +session_YYYYMMDD_HHMMSS/ +├── raw-events-001.jsonl +├── kdenlive-console-001.log ├── final.kdenlive # saved by editor └── final.mp4 # rendered by editor ``` -If Kdenlive crashes or the recorder reports an incomplete session, create a -fresh session and repeat the edit. +Crash recovery creates `raw-events-002.jsonl`, `raw-events-003.jsonl`, and so +on. Earlier incomplete segments remain auditable instead of being overwritten. ## Internal team workflow -The editor does not generate `sample.json`. After receiving the session, -project, render, assets, and externally assigned task prompt, the internal team -validates the evidence, resolves project asset identities, normalizes the -accepted edit path, packages the sample, and performs human quality review. +The editor application generates `sample.json` automatically. The internal team +still performs final human review. Canonical replay is required. The initial +media adapter also reconstructs and renders ordinary cut/trim/move timelines; +effects, transitions, speed changes, and other unsupported features are clearly +reported and the sample is marked not ready for client review. diff --git a/video-path-pilot/README.md b/video-path-pilot/README.md index b67c3cc..e617815 100644 --- a/video-path-pilot/README.md +++ b/video-path-pilot/README.md @@ -5,10 +5,10 @@ SPDX-License-Identifier: GPL-3.0-only # Kdenlive Video Path Pilot -This fork includes an editor-facing recording MVP around Kdenlive. The app -records editing interactions and canonical timeline outcomes without collecting -editor intent. The internal project team later combines the recording, native -project, render, source assets, and externally assigned prompt into a sample. +This fork includes an assigned-job recording MVP around Kdenlive. The app +records interactions and canonical outcomes without collecting editor intent, +resolves assets from the saved project by SHA-256, normalizes the accepted path, +and generates `sample.json` automatically. For the two-sample client trial, begin with `EDITOR_WORKFLOW.md`. The clean format and language are in `sample.schema.json` and `VOCABULARY.md`. @@ -22,11 +22,18 @@ video-path-pilot/run-collector-app.sh ``` Choose **Run** if the file manager asks whether to display or execute the file. -The one-screen app creates a session folder automatically, launches Kdenlive -with an isolated fresh configuration, records the edit, validates normal -termination, and opens the return folder. It never asks for an editor plan, -rationale, creative decisions, or subjective review. No terminal commands are -required. +The app opens a supplied `job.json`, creates a session folder, launches +Kdenlive with an isolated configuration and preloaded assets, records numbered +segments, offers crash recovery, validates termination, and packages the +completed sample. It never asks for an editor plan, rationale, creative +decisions, or subjective review. No terminal commands are required. + +Canonical state replay must reproduce every recorded state hash. A first MLT +media adapter reconstructs cut/trim/move edits with normal-speed clips and no +effects/transitions, renders `reconstructed.mp4`, and compares resolution, +frame rate, duration, video SSIM, and audio PSNR. Unsupported editing features +are reported explicitly and prevent client-readiness; adapter coverage must be +expanded before general collection. The underlying command interface remains available to developers and tests: @@ -34,9 +41,10 @@ The underlying command interface remains available to developers and tests: python3 video-path-pilot/sample_collector.py --help ``` -The underlying Python sample tools are internal prototypes, not part of the -editor workflow. Undo/redo remains in raw evidence and can be removed later -when the internal team constructs the clean successful trajectory. +`job_pipeline.py` is used internally to create assigned jobs and by the app to +package completed samples. The older first-use-order collector was removed so +it cannot produce incorrect asset identities. Undo/redo remains in raw evidence +but is removed from the clean successful trajectory. The pilot is based on upstream Kdenlive revision `7de2ed9902b4288797a7781498546389a482a39e`. diff --git a/video-path-pilot/gui/main.cpp b/video-path-pilot/gui/main.cpp index 616574c..20fdc47 100644 --- a/video-path-pilot/gui/main.cpp +++ b/video-path-pilot/gui/main.cpp @@ -6,16 +6,21 @@ #include #include #include +#include +#include #include #include +#include +#include +#include #include #include #include #include #include +#include #include #include -#include #include #include #include @@ -24,52 +29,39 @@ namespace { QString repositoryRoot() { const QString configured = qEnvironmentVariable("EDIT_PATH_REPO_ROOT"); - if (!configured.isEmpty() && QFileInfo::exists(configured + QStringLiteral("/video-path-pilot/run-video-path-pilot.sh"))) { + if (!configured.isEmpty() && QFileInfo::exists(configured + QStringLiteral("/video-path-pilot/job_pipeline.py"))) return QDir(configured).absolutePath(); - } QDir current(QCoreApplication::applicationDirPath()); for (int depth = 0; depth < 6; ++depth) { - if (QFileInfo::exists(current.filePath(QStringLiteral("video-path-pilot/run-video-path-pilot.sh")))) { - return current.absolutePath(); - } + if (QFileInfo::exists(current.filePath(QStringLiteral("video-path-pilot/job_pipeline.py")))) return current.absolutePath(); if (!current.cdUp()) break; } return {}; } - -QString defaultSessionRoot() -{ - QString videos = QStandardPaths::writableLocation(QStandardPaths::MoviesLocation); - if (videos.isEmpty()) videos = QDir::homePath() + QStringLiteral("/Videos"); - return QDir(videos).filePath(QStringLiteral("EditPathSessions")); -} } class RecorderWindow final : public QMainWindow { public: - RecorderWindow() - : m_repoRoot(repositoryRoot()) + RecorderWindow() : m_repoRoot(repositoryRoot()) { setWindowTitle(QStringLiteral("Edit Path Recorder MVP")); - resize(720, 520); + resize(760, 580); buildUi(); - const QString previous = QSettings().value(QStringLiteral("lastSession")).toString(); - if (!previous.isEmpty() && QDir(previous).exists()) setSession(previous); + const QString previous = QSettings().value(QStringLiteral("lastJob")).toString(); + if (!previous.isEmpty() && QFileInfo::exists(previous + QStringLiteral("/job.json"))) loadJob(previous); if (m_repoRoot.isEmpty()) { - m_startButton->setEnabled(false); setStatus(QStringLiteral("Recorder installation was not found."), true); + m_openJob->setEnabled(false); } } protected: void closeEvent(QCloseEvent *event) override { - if (m_editor.state() != QProcess::NotRunning || m_validator.state() != QProcess::NotRunning) { - QMessageBox::warning(this, QStringLiteral("Recording active"), - QStringLiteral("Close Kdenlive normally before closing the recorder.")); - event->ignore(); - return; + if (m_editor.state() != QProcess::NotRunning || m_worker.state() != QProcess::NotRunning) { + QMessageBox::warning(this, QStringLiteral("Task active"), QStringLiteral("Wait for the active task or close Kdenlive normally.")); + event->ignore(); return; } event->accept(); } @@ -79,158 +71,231 @@ class RecorderWindow final : public QMainWindow { auto *central = new QWidget; auto *layout = new QVBoxLayout(central); - auto *title = new QLabel(QStringLiteral( - "

Edit Path Recorder

" - "

This application records editing actions and timeline outcomes. " - "It does not ask for plans, explanations, decisions, or other editor intent.

")); - title->setWordWrap(true); - layout->addWidget(title); - - auto *instructions = new QLabel(QStringLiteral( - "Editor instructions
    " - "
  1. Click Start Editing Session.
  2. " - "
  3. Use the fresh Kdenlive window and edit normally with the assets supplied for the task.
  4. " - "
  5. Save the Kdenlive project and rendered video in the session folder shown below.
  6. " - "
  7. Close Kdenlive normally and wait for recording validation.
  8. " - "
  9. Return the complete session folder to the project team.
  10. " - "
")); - instructions->setWordWrap(true); - layout->addWidget(instructions); - - m_status = new QLabel; - m_status->setWordWrap(true); - m_status->setStyleSheet(QStringLiteral("padding: 10px; border-radius: 4px; background: #e8eef7;")); - setStatus(QStringLiteral("Ready to start a new editing session.")); - layout->addWidget(m_status); - - auto *pathTitle = new QLabel(QStringLiteral("Current session folder")); - layout->addWidget(pathTitle); - m_sessionPath = new QLabel(QStringLiteral("No session created yet")); - m_sessionPath->setTextInteractionFlags(Qt::TextSelectableByMouse); - m_sessionPath->setWordWrap(true); - layout->addWidget(m_sessionPath); - - auto *buttons = new QHBoxLayout; - m_startButton = new QPushButton(QStringLiteral("Start Editing Session")); - m_startButton->setMinimumHeight(42); - m_openButton = new QPushButton(QStringLiteral("Open Session Folder")); - m_openButton->setEnabled(false); - buttons->addWidget(m_startButton); - buttons->addWidget(m_openButton); - layout->addLayout(buttons); - - m_activity = new QPlainTextEdit; - m_activity->setReadOnly(true); - m_activity->setMaximumBlockCount(200); - m_activity->setPlaceholderText(QStringLiteral("Recording activity will appear here.")); - layout->addWidget(m_activity, 1); - setCentralWidget(central); - - connect(m_startButton, &QPushButton::clicked, this, &RecorderWindow::startSession); - connect(m_openButton, &QPushButton::clicked, this, [this] { - QDesktopServices::openUrl(QUrl::fromLocalFile(m_currentSession)); - }); - connect(&m_editor, qOverload(&QProcess::finished), - this, &RecorderWindow::editorFinished); - connect(&m_validator, &QProcess::readyReadStandardOutput, this, &RecorderWindow::readValidatorOutput); - connect(&m_validator, &QProcess::readyReadStandardError, this, &RecorderWindow::readValidatorOutput); - connect(&m_validator, qOverload(&QProcess::finished), - this, &RecorderWindow::validatorFinished); + auto *title = new QLabel(QStringLiteral("

Edit Path Recorder

Open the assigned job, edit in Kdenlive, and finish the job.

")); + title->setWordWrap(true); layout->addWidget(title); + + auto *jobRow = new QHBoxLayout; + m_openJob = new QPushButton(QStringLiteral("Open Assigned Job")); + m_jobLabel = new QLabel(QStringLiteral("No job opened")); m_jobLabel->setTextInteractionFlags(Qt::TextSelectableByMouse); + jobRow->addWidget(m_openJob); jobRow->addWidget(m_jobLabel, 1); layout->addLayout(jobRow); + + m_task = new QLabel(QStringLiteral("Task details will appear here.")); + m_task->setWordWrap(true); m_task->setStyleSheet(QStringLiteral("padding: 10px; background: #eef1f5; border-radius: 4px;")); + layout->addWidget(m_task); + + m_status = new QLabel; m_status->setWordWrap(true); layout->addWidget(m_status); + setStatus(QStringLiteral("Open an assigned job to begin.")); + + layout->addWidget(new QLabel(QStringLiteral("Session folder"))); + m_sessionLabel = new QLabel(QStringLiteral("No session created")); + m_sessionLabel->setWordWrap(true); m_sessionLabel->setTextInteractionFlags(Qt::TextSelectableByMouse); + layout->addWidget(m_sessionLabel); + + auto *primary = new QHBoxLayout; + m_start = new QPushButton(QStringLiteral("Start Editing Session")); + m_recover = new QPushButton(QStringLiteral("Recover and Continue")); + m_finish = new QPushButton(QStringLiteral("Finish Job")); + m_start->setMinimumHeight(42); m_recover->setMinimumHeight(42); m_finish->setMinimumHeight(42); + m_start->setEnabled(false); m_recover->setVisible(false); m_finish->setEnabled(false); + primary->addWidget(m_start); primary->addWidget(m_recover); primary->addWidget(m_finish); layout->addLayout(primary); + + auto *secondary = new QHBoxLayout; + m_openSession = new QPushButton(QStringLiteral("Open Session Folder")); + m_openCompleted = new QPushButton(QStringLiteral("Open Completed Sample")); + m_openSession->setEnabled(false); m_openCompleted->setEnabled(false); + secondary->addWidget(m_openSession); secondary->addWidget(m_openCompleted); secondary->addStretch(); layout->addLayout(secondary); + + m_activity = new QPlainTextEdit; m_activity->setReadOnly(true); m_activity->setMaximumBlockCount(300); + layout->addWidget(m_activity, 1); setCentralWidget(central); + + connect(m_openJob, &QPushButton::clicked, this, &RecorderWindow::chooseJob); + connect(m_start, &QPushButton::clicked, this, &RecorderWindow::startNewSession); + connect(m_recover, &QPushButton::clicked, this, [this] { ++m_segment; launchSegment(); }); + connect(m_finish, &QPushButton::clicked, this, &RecorderWindow::finishJob); + connect(m_openSession, &QPushButton::clicked, this, [this] { QDesktopServices::openUrl(QUrl::fromLocalFile(m_session)); }); + connect(m_openCompleted, &QPushButton::clicked, this, [this] { QDesktopServices::openUrl(QUrl::fromLocalFile(m_jobRoot + QStringLiteral("/completed-sample"))); }); + connect(&m_editor, qOverload(&QProcess::finished), this, &RecorderWindow::editorFinished); + connect(&m_worker, &QProcess::readyReadStandardOutput, this, &RecorderWindow::readWorker); + connect(&m_worker, &QProcess::readyReadStandardError, this, &RecorderWindow::readWorker); + connect(&m_worker, qOverload(&QProcess::finished), this, &RecorderWindow::workerFinished); } - void setStatus(const QString &message, bool error = false) + void setStatus(const QString &text, bool error = false) { - m_status->setText(message); - m_status->setStyleSheet(error - ? QStringLiteral("padding: 10px; border-radius: 4px; background: #f7dddd; color: #7d1010;") - : QStringLiteral("padding: 10px; border-radius: 4px; background: #e2f2e5; color: #164d24;")); + m_status->setText(text); + m_status->setStyleSheet(error ? QStringLiteral("padding:10px;background:#f7dddd;color:#7d1010;border-radius:4px;") + : QStringLiteral("padding:10px;background:#e2f2e5;color:#164d24;border-radius:4px;")); } - void setSession(const QString &path) + void chooseJob() { - m_currentSession = QDir(path).absolutePath(); - m_sessionPath->setText(m_currentSession); - m_openButton->setEnabled(true); - QSettings().setValue(QStringLiteral("lastSession"), m_currentSession); + const QString file = QFileDialog::getOpenFileName(this, QStringLiteral("Open assigned job"), {}, QStringLiteral("Assigned jobs (job.json)")); + if (!file.isEmpty()) loadJob(QFileInfo(file).absolutePath()); } - void startSession() + void loadJob(const QString &root) { - if (m_editor.state() != QProcess::NotRunning) return; - const QString stamp = QDateTime::currentDateTimeUtc().toString(QStringLiteral("yyyyMMdd_HHmmss")); - const QString suffix = QUuid::createUuid().toString(QUuid::WithoutBraces).left(8); - const QString path = QDir(defaultSessionRoot()).filePath(QStringLiteral("session_%1_%2").arg(stamp, suffix)); - QDir directory; - if (!directory.mkpath(path)) { - setStatus(QStringLiteral("Could not create the session folder: %1").arg(path), true); - return; + QFile file(QDir(root).filePath(QStringLiteral("job.json"))); + if (!file.open(QIODevice::ReadOnly)) { setStatus(QStringLiteral("Could not read job.json."), true); return; } + const auto document = QJsonDocument::fromJson(file.readAll()); + const auto job = document.object(); + const auto task = job.value(QStringLiteral("task")).toObject(); + const auto project = job.value(QStringLiteral("project")).toObject(); + const auto rate = project.value(QStringLiteral("frame_rate")).toObject(); + if (job.value(QStringLiteral("schema_version")).toString() != QStringLiteral("0.1.0") + || job.value(QStringLiteral("job_id")).toString().isEmpty() || task.value(QStringLiteral("prompt")).toString().isEmpty() + || job.value(QStringLiteral("assets")).toArray().isEmpty()) { + setStatus(QStringLiteral("The selected job.json is incomplete or unsupported."), true); return; + } + QStringList clips; + for (const auto &value : job.value(QStringLiteral("assets")).toArray()) { + const QString relative = value.toObject().value(QStringLiteral("file")).toString(); + const QString absolute = QDir(root).filePath(relative); + if (!QFileInfo::exists(absolute)) { setStatus(QStringLiteral("Assigned asset is missing: %1").arg(relative), true); return; } + clips << QDir(absolute).absolutePath(); + } + m_jobRoot = QDir(root).absolutePath(); m_assetPaths = clips; + m_jobLabel->setText(QStringLiteral("%1 — %2").arg(job.value(QStringLiteral("job_id")).toString(), m_jobRoot)); + m_task->setText(QStringLiteral("Task
%1

Project: %2 × %3, %4/%5 fps   Assets: %6") + .arg(task.value(QStringLiteral("prompt")).toString().toHtmlEscaped()) + .arg(project.value(QStringLiteral("width")).toInt()).arg(project.value(QStringLiteral("height")).toInt()) + .arg(rate.value(QStringLiteral("numerator")).toInt()).arg(rate.value(QStringLiteral("denominator")).toInt()).arg(clips.size())); + m_start->setEnabled(!QDir(m_jobRoot + QStringLiteral("/completed-sample")).exists()); + m_openCompleted->setEnabled(QDir(m_jobRoot + QStringLiteral("/completed-sample")).exists()); + setStatus(QStringLiteral("Assigned job is ready.")); QSettings().setValue(QStringLiteral("lastJob"), m_jobRoot); + restoreLastSession(); + } + + void writeSessionManifest(const QString &status) + { + if (m_session.isEmpty()) return; + QJsonObject manifest{{QStringLiteral("schema_version"), QStringLiteral("0.1.0")}, + {QStringLiteral("job_root"), m_jobRoot}, {QStringLiteral("session_dir"), m_session}, + {QStringLiteral("config_name"), m_configName}, {QStringLiteral("segment"), m_segment}, + {QStringLiteral("status"), status}, {QStringLiteral("kdenlive_pid"), qint64(m_editor.processId())}, + {QStringLiteral("updated_at_utc"), QDateTime::currentDateTimeUtc().toString(Qt::ISODateWithMs)}}; + QFile file(QDir(m_session).filePath(QStringLiteral("session.json"))); + if (file.open(QIODevice::WriteOnly | QIODevice::Truncate)) file.write(QJsonDocument(manifest).toJson(QJsonDocument::Indented)); + QSettings().setValue(QStringLiteral("lastSession"), m_session); + } + + void restoreLastSession() + { + const QString previous = QSettings().value(QStringLiteral("lastSession")).toString(); + QFile file(QDir(previous).filePath(QStringLiteral("session.json"))); + if (previous.isEmpty() || !file.open(QIODevice::ReadOnly)) return; + const auto manifest = QJsonDocument::fromJson(file.readAll()).object(); + if (manifest.value(QStringLiteral("job_root")).toString() != m_jobRoot) return; + m_session = previous; m_configName = manifest.value(QStringLiteral("config_name")).toString(); + m_segment = manifest.value(QStringLiteral("segment")).toInt(); + m_sessionLabel->setText(m_session); m_openSession->setEnabled(true); + const QString status = manifest.value(QStringLiteral("status")).toString(); + if (status == QStringLiteral("ready_to_finish")) { + m_finish->setEnabled(true); setStatus(QStringLiteral("The previous recording is ready to finish.")); + } else if (status == QStringLiteral("recovery_available") || status == QStringLiteral("recording")) { + m_recover->setVisible(true); setStatus(QStringLiteral("A previous session was interrupted. Verify Kdenlive is closed, then use Recover and Continue."), true); } - setSession(path); - const QString raw = QDir(path).filePath(QStringLiteral("raw-events.jsonl")); - const QString console = QDir(path).filePath(QStringLiteral("kdenlive-console.log")); - const QString configName = QStringLiteral("edit-path-%1rc").arg(suffix); + } - m_activity->appendPlainText(QStringLiteral("Session created: %1").arg(path)); - m_activity->appendPlainText(QStringLiteral("Launching a fresh isolated Kdenlive session…")); - setStatus(QStringLiteral("Recording in progress. Save the project and rendered video in the session folder, then close Kdenlive normally.")); - m_startButton->setEnabled(false); + void startNewSession() + { + const QString stamp = QDateTime::currentDateTimeUtc().toString(QStringLiteral("yyyyMMdd_HHmmss")); + m_configName = QStringLiteral("edit-path-%1rc").arg(QUuid::createUuid().toString(QUuid::WithoutBraces).left(8)); + m_session = QDir(m_jobRoot).filePath(QStringLiteral("sessions/session_%1").arg(stamp)); + if (!QDir().mkpath(m_session)) { setStatus(QStringLiteral("Could not create session folder."), true); return; } + m_segment = 1; m_sessionLabel->setText(m_session); m_openSession->setEnabled(true); + writeSessionManifest(QStringLiteral("created")); + launchSegment(); + } + void launchSegment() + { + m_recover->setVisible(false); m_start->setEnabled(false); m_finish->setEnabled(false); + const QString number = QStringLiteral("%1").arg(m_segment, 3, 10, QLatin1Char('0')); + const QString raw = QDir(m_session).filePath(QStringLiteral("raw-events-%1.jsonl").arg(number)); + const QString console = QDir(m_session).filePath(QStringLiteral("kdenlive-console-%1.log").arg(number)); QProcessEnvironment environment = QProcessEnvironment::systemEnvironment(); - environment.insert(QStringLiteral("KDENLIVE_VIDEO_PATH_CONFIG"), configName); - m_editor.setProcessEnvironment(environment); - m_editor.setWorkingDirectory(m_repoRoot); - m_editor.setProcessChannelMode(QProcess::MergedChannels); - m_editor.setStandardOutputFile(console, QIODevice::Append); + environment.insert(QStringLiteral("KDENLIVE_VIDEO_PATH_CONFIG"), m_configName); + if (m_segment == 1) environment.insert(QStringLiteral("KDENLIVE_VIDEO_PATH_CLIPS"), m_assetPaths.join(QLatin1Char(','))); + else environment.remove(QStringLiteral("KDENLIVE_VIDEO_PATH_CLIPS")); + m_editor.setProcessEnvironment(environment); m_editor.setWorkingDirectory(m_repoRoot); + m_editor.setProcessChannelMode(QProcess::MergedChannels); m_editor.setStandardOutputFile(console, QIODevice::Append); + m_activity->appendPlainText(QStringLiteral("Starting recording segment %1…").arg(number)); + setStatus(m_segment == 1 ? QStringLiteral("Editing session is recording. Save the project and final render in the session folder.") + : QStringLiteral("Recovery segment is recording. Complete the edit and close Kdenlive normally.")); m_editor.start(m_repoRoot + QStringLiteral("/video-path-pilot/run-video-path-pilot.sh"), {raw}); - if (!m_editor.waitForStarted(5000)) { - m_startButton->setEnabled(true); - setStatus(QStringLiteral("Kdenlive could not be started. See kdenlive-console.log."), true); - } + if (m_editor.waitForStarted(5000)) writeSessionManifest(QStringLiteral("recording")); + else { setStatus(QStringLiteral("Kdenlive could not be started. See the segment console log."), true); m_start->setEnabled(true); } } void editorFinished(int exitCode, QProcess::ExitStatus status) { - m_activity->appendPlainText(QStringLiteral("Kdenlive exited; validating the recording…")); - if (status != QProcess::NormalExit || exitCode != 0) { - m_activity->appendPlainText(QStringLiteral("Kdenlive exit code: %1").arg(exitCode)); - } - const QString validator = m_repoRoot + QStringLiteral("/video-path-pilot/validate_video_path.py"); - const QString raw = QDir(m_currentSession).filePath(QStringLiteral("raw-events.jsonl")); - m_validator.setWorkingDirectory(m_repoRoot); - m_validator.start(QStringLiteral("python3"), {validator, raw}); + m_activity->appendPlainText(QStringLiteral("Kdenlive exited with code %1; checking recording…").arg(exitCode)); + m_workerPurpose = QStringLiteral("validate-segment"); + const QString raw = QDir(m_session).filePath(QStringLiteral("raw-events-%1.jsonl").arg(m_segment, 3, 10, QLatin1Char('0'))); + m_worker.start(QStringLiteral("python3"), {m_repoRoot + QStringLiteral("/video-path-pilot/validate_video_path.py"), raw}); + Q_UNUSED(status) + } + + void finishJob() + { + m_finish->setEnabled(false); m_workerPurpose = QStringLiteral("finalize"); + setStatus(QStringLiteral("Resolving assets, normalizing the edit path, generating sample.json, and replaying canonical state…")); + m_worker.start(QStringLiteral("python3"), {m_repoRoot + QStringLiteral("/video-path-pilot/job_pipeline.py"), + QStringLiteral("finalize"), m_jobRoot, m_session}); } - void readValidatorOutput() + void readWorker() { - const QString output = QString::fromUtf8(m_validator.readAllStandardOutput()).trimmed(); - const QString errors = QString::fromUtf8(m_validator.readAllStandardError()).trimmed(); + const QString output = QString::fromUtf8(m_worker.readAllStandardOutput()).trimmed(); + const QString errors = QString::fromUtf8(m_worker.readAllStandardError()).trimmed(); if (!output.isEmpty()) m_activity->appendPlainText(output); if (!errors.isEmpty()) m_activity->appendPlainText(errors); } - void validatorFinished(int exitCode, QProcess::ExitStatus status) + void workerFinished(int exitCode, QProcess::ExitStatus status) { - readValidatorOutput(); - m_startButton->setEnabled(true); - if (status == QProcess::NormalExit && exitCode == 0) { - setStatus(QStringLiteral("Recording completed successfully. Return this session folder, the saved project, rendered video, and source assets to the project team.")); - QMessageBox::information(this, QStringLiteral("Recording complete"), - QStringLiteral("The editing session was recorded and validated successfully.")); - } else { - setStatus(QStringLiteral("Recording is incomplete or invalid. Kdenlive may have crashed or been force-quit. Do not submit this session as complete."), true); - QMessageBox::warning(this, QStringLiteral("Recording incomplete"), - QStringLiteral("The session did not pass validation. See Activity and kdenlive-console.log.")); + readWorker(); const bool success = status == QProcess::NormalExit && exitCode == 0; + if (m_workerPurpose == QStringLiteral("validate-segment")) { + if (success) { + writeSessionManifest(QStringLiteral("ready_to_finish")); + setStatus(QStringLiteral("Recording completed. Ensure exactly one .kdenlive project and one rendered video are in the session folder, then click Finish Job.")); + m_finish->setEnabled(true); m_start->setEnabled(true); + } else { + writeSessionManifest(QStringLiteral("recovery_available")); + setStatus(QStringLiteral("Kdenlive did not close cleanly. Use Recover and Continue to reopen the isolated session and Kdenlive recovery."), true); + m_recover->setVisible(true); + } + } else if (m_workerPurpose == QStringLiteral("finalize")) { + if (success) { + writeSessionManifest(QStringLiteral("packaged")); + m_openCompleted->setEnabled(true); m_start->setEnabled(false); + QFile reportFile(m_jobRoot + QStringLiteral("/completed-sample/validation/reconstruction-report.json")); + bool mediaPassed = false; + if (reportFile.open(QIODevice::ReadOnly)) { + const auto report = QJsonDocument::fromJson(reportFile.readAll()).object(); + mediaPassed = report.value(QStringLiteral("media_project_reconstruction")).toString() == QStringLiteral("passed"); + } + if (mediaPassed) { + setStatus(QStringLiteral("Sample generated. Canonical replay, reconstructed render, and media comparison passed.")); + QMessageBox::information(this, QStringLiteral("Job complete"), QStringLiteral("The sample and reconstructed render passed validation.")); + } else { + setStatus(QStringLiteral("Sample generated and canonical replay passed, but this edit uses media features the reconstruction adapter does not yet support. It is not ready for client review."), true); + QMessageBox::warning(this, QStringLiteral("Reconstruction pending"), QStringLiteral("The sample was packaged, but media reconstruction did not pass. See reconstruction-report.json.")); + } + } else { + setStatus(QStringLiteral("Job packaging failed. Review Activity, correct the project/render/assets, and try Finish Job again."), true); + m_finish->setEnabled(true); + } } + m_workerPurpose.clear(); } - QString m_repoRoot; - QString m_currentSession; - QProcess m_editor; - QProcess m_validator; - QLabel *m_status{}; - QLabel *m_sessionPath{}; - QPushButton *m_startButton{}; - QPushButton *m_openButton{}; + QString m_repoRoot, m_jobRoot, m_session, m_configName, m_workerPurpose; + QStringList m_assetPaths; int m_segment{0}; + QProcess m_editor, m_worker; + QLabel *m_jobLabel{}, *m_task{}, *m_status{}, *m_sessionLabel{}; + QPushButton *m_openJob{}, *m_start{}, *m_recover{}, *m_finish{}, *m_openSession{}, *m_openCompleted{}; QPlainTextEdit *m_activity{}; }; @@ -239,7 +304,5 @@ int main(int argc, char **argv) QApplication application(argc, argv); QCoreApplication::setOrganizationName(QStringLiteral("Parsewave")); QCoreApplication::setApplicationName(QStringLiteral("EditPathRecorder")); - RecorderWindow window; - window.show(); - return application.exec(); + RecorderWindow window; window.show(); return application.exec(); } diff --git a/video-path-pilot/job.schema.json b/video-path-pilot/job.schema.json new file mode 100644 index 0000000..f488565 --- /dev/null +++ b/video-path-pilot/job.schema.json @@ -0,0 +1,49 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://parsewave.example/schemas/edit-path-job-0.1.0.json", + "title": "Assigned editing job", + "type": "object", + "required": ["schema_version", "job_id", "task", "project", "assets"], + "properties": { + "schema_version": {"const": "0.1.0"}, + "job_id": {"type": "string", "minLength": 1}, + "task": { + "type": "object", + "required": ["prompt"], + "properties": {"prompt": {"type": "string", "minLength": 1}}, + "additionalProperties": false + }, + "project": { + "type": "object", + "required": ["frame_rate", "width", "height"], + "properties": { + "frame_rate": { + "type": "object", + "required": ["numerator", "denominator"], + "properties": { + "numerator": {"type": "integer", "minimum": 1}, + "denominator": {"type": "integer", "minimum": 1} + } + }, + "width": {"type": "integer", "minimum": 1}, + "height": {"type": "integer", "minimum": 1} + } + }, + "assets": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": ["asset_id", "file", "original_filename", "sha256", "bytes"], + "properties": { + "asset_id": {"type": "string", "pattern": "^asset_[0-9]{3,}$"}, + "file": {"type": "string"}, + "original_filename": {"type": "string", "minLength": 1}, + "sha256": {"type": "string", "pattern": "^[0-9a-f]{64}$"}, + "bytes": {"type": "integer", "minimum": 1} + } + } + } + }, + "additionalProperties": false +} diff --git a/video-path-pilot/job.schema.json.license b/video-path-pilot/job.schema.json.license new file mode 100644 index 0000000..2b8cbd8 --- /dev/null +++ b/video-path-pilot/job.schema.json.license @@ -0,0 +1,2 @@ +SPDX-FileCopyrightText: 2026 Video Path Pilot contributors +SPDX-License-Identifier: GPL-3.0-only diff --git a/video-path-pilot/job_pipeline.py b/video-path-pilot/job_pipeline.py new file mode 100755 index 0000000..c41f978 --- /dev/null +++ b/video-path-pilot/job_pipeline.py @@ -0,0 +1,276 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: 2026 Video Path Pilot contributors +# SPDX-License-Identifier: GPL-3.0-only +"""Create assigned jobs and automatically package completed editing sessions.""" + +from __future__ import annotations + +import argparse +import copy +import hashlib +import json +import shutil +import sys +import xml.etree.ElementTree as ET +from pathlib import Path + +from normalize_sample import accepted_commits, build_sample, read_jsonl +from media_reconstruct import reconstruct as reconstruct_media +from validate_sample import validate_sample +from validate_video_path import validate as validate_raw + +VIDEO_SUFFIXES = {".mp4", ".mov", ".mkv", ".webm"} +COLLECTION = {"clip": "clips", "track": "tracks", "composition": "compositions", "mix": "mixes", "master_effect": "master_effects"} + + +def sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as stream: + for block in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(block) + return digest.hexdigest() + + +def dump(path: Path, value: object) -> None: + path.write_text(json.dumps(value, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") + + +def load_job(root: Path) -> dict: + path = root / "job.json" + if not path.is_file(): raise ValueError(f"job.json not found in {root}") + job = json.loads(path.read_text(encoding="utf-8")) + if job.get("schema_version") != "0.1.0": raise ValueError("unsupported job schema") + if not str(job.get("task", {}).get("prompt", "")).strip(): raise ValueError("job prompt is missing") + if not job.get("assets"): raise ValueError("job has no assets") + ids: set[str] = set(); hashes: set[str] = set() + for asset in job["assets"]: + asset_id = asset.get("asset_id") + if asset_id in ids: raise ValueError(f"duplicate asset ID: {asset_id}") + ids.add(asset_id) + if asset.get("sha256") in hashes: raise ValueError(f"duplicate asset content is ambiguous: {asset['file']}") + hashes.add(asset.get("sha256")) + file = root / asset["file"] + if not file.is_file(): raise ValueError(f"job asset missing: {asset['file']}") + if file.stat().st_size != asset.get("bytes") or sha256(file) != asset.get("sha256"): + raise ValueError(f"job asset changed: {asset['file']}") + return job + + +def create_job(args: argparse.Namespace) -> int: + root = args.job_dir.resolve() + if root.exists(): raise ValueError(f"refusing to overwrite: {root}") + (root / "assets").mkdir(parents=True) + assets = [] + for index, source in enumerate(args.assets, 1): + if not source.is_file(): raise ValueError(f"asset missing: {source}") + asset_id = f"asset_{index:03d}" + target = root / "assets" / f"{asset_id}{source.suffix.lower()}" + shutil.copy2(source, target) + assets.append({"asset_id": asset_id, "file": target.relative_to(root).as_posix(), + "original_filename": source.name, "sha256": sha256(target), "bytes": target.stat().st_size}) + dump(root / "job.json", {"schema_version": "0.1.0", "job_id": args.job_id, + "task": {"prompt": args.prompt}, + "project": {"frame_rate": {"numerator": args.fps_num, "denominator": args.fps_den}, + "width": args.width, "height": args.height}, "assets": assets}) + print(f"created assigned job: {root}") + return 0 + + +def validate_job_command(args: argparse.Namespace) -> int: + job = load_job(args.job_dir.resolve()) + print(json.dumps({"job_id": job["job_id"], "prompt": job["task"]["prompt"], + "project": job["project"], "asset_count": len(job["assets"])})) + return 0 + + +def properties(element: ET.Element) -> dict[str, str]: + return {item.get("name", ""): item.text or "" for item in element.findall("property")} + + +def project_resources(project: Path) -> tuple[dict[str, Path], dict]: + root = ET.parse(project).getroot() + project_root = Path(root.get("root") or project.parent) + resources: dict[str, Path] = {} + for element in list(root.findall("chain")) + list(root.findall("producer")): + props = properties(element) + native_id, resource = props.get("kdenlive:id"), props.get("kdenlive:originalurl") or props.get("resource") + if not native_id or not resource or props.get("mlt_service") in {"color", "qtext", "kdenlivetitle"}: continue + candidate = Path(resource) + if not candidate.is_absolute(): candidate = project_root / candidate + candidate = candidate.resolve() + previous = resources.get(native_id) + if previous and previous != candidate: raise ValueError(f"Kdenlive bin ID {native_id} maps to multiple resources") + resources[native_id] = candidate + profile = root.find("profile") + if profile is None: raise ValueError("Kdenlive project has no profile") + settings = {"frame_rate": {"numerator": int(profile.get("frame_rate_num", "0")), + "denominator": int(profile.get("frame_rate_den", "0"))}, + "width": int(profile.get("width", "0")), "height": int(profile.get("height", "0"))} + return resources, settings + + +def resolve_assets(job_root: Path, job: dict, project: Path) -> tuple[dict[str, str], list[dict]]: + by_hash = {asset["sha256"]: asset["asset_id"] for asset in job["assets"]} + resources, settings = project_resources(project) + bindings: dict[str, str] = {} + problems = [] + for native_id, resource in resources.items(): + if not resource.is_file(): + problems.append({"native_id": native_id, "resource": str(resource), "error": "missing"}); continue + digest = sha256(resource) + asset_id = by_hash.get(digest) + if not asset_id: + problems.append({"native_id": native_id, "resource": str(resource), "sha256": digest, "error": "not_in_job"}); continue + bindings[native_id] = asset_id + return bindings, problems + + +def canonical_hash(snapshot: dict) -> str: + encoded = json.dumps(snapshot, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode() + return hashlib.sha256(encoded).hexdigest() + + +def apply_diff(snapshot: dict, diff: dict) -> None: + for change in diff.get("changes", []): + collection_name = COLLECTION.get(change.get("entity")) + if not collection_name: continue + collection = snapshot.setdefault(collection_name, []) + native_id = change.get("native_id") + position = next((index for index, item in enumerate(collection) if item.get("native_id") == native_id), None) + kind = change.get("change") + if kind == "removed" and position is not None: collection.pop(position) + elif kind == "added": collection.append(copy.deepcopy(change["after"])) + elif kind == "updated" and position is not None: collection[position] = copy.deepcopy(change["after"]) + if collection_name == "tracks": + collection.sort(key=lambda item: item.get("position", 0)) + elif collection_name in {"clips", "compositions", "mixes"}: + collection.sort(key=lambda item: (item.get("track_native_id", 0), item.get("timeline_start_frame", 0), item.get("native_id", 0))) + else: + collection.sort(key=lambda item: item.get("native_id", 0)) + if "duration_after" in diff: snapshot["duration_frames"] = diff["duration_after"] + + +def replay_report(raw_paths: list[Path]) -> dict: + segments = [] + previous_final_hash = None + all_passed = True + for path in raw_paths: + events = read_jsonl(path) + checkpoint = next((event for event in events if event.get("event_type") == "state.checkpoint"), None) + if not checkpoint: + segments.append({"file": path.name, "status": "failed", "error": "missing_checkpoint"}) + all_passed = False; continue + snapshot = copy.deepcopy(checkpoint["snapshot"]) + initial_hash = canonical_hash(snapshot) + continuity = previous_final_hash is None or initial_hash == previous_final_hash + steps = [] + for event in accepted_commits(events): + apply_diff(snapshot, event.get("diff", {})) + actual = canonical_hash(snapshot) + passed = actual == event.get("after_hash") + steps.append({"raw_event_id": event.get("event_id"), "expected_hash": event.get("after_hash"), + "replayed_hash": actual, "passed": passed}) + all_passed = all_passed and passed + previous_final_hash = canonical_hash(snapshot) + all_passed = all_passed and continuity + segments.append({"file": path.name, "initial_hash": initial_hash, "continuity_with_previous": continuity, + "final_hash": previous_final_hash, "steps": steps, "status": "passed" if continuity and all(s["passed"] for s in steps) else "failed"}) + return {"schema_version": "0.1.0", "canonical_state_replay": "passed" if all_passed else "failed", + "media_project_reconstruction": "not_implemented", "reconstructed_render": "not_implemented", "segments": segments} + + +def discover_one(session: Path, suffixes: set[str], label: str) -> Path: + matches = [path for path in session.iterdir() if path.is_file() and path.suffix.lower() in suffixes] + if len(matches) != 1: raise ValueError(f"expected exactly one {label} in {session}, found {len(matches)}") + return matches[0] + + +def finalize_job(args: argparse.Namespace) -> int: + job_root, session = args.job_dir.resolve(), args.session_dir.resolve() + job = load_job(job_root) + raw_paths = sorted(session.glob("raw-events-*.jsonl")) or sorted(session.glob("raw-events.jsonl")) + if not raw_paths: raise ValueError("session contains no raw event files") + for index, path in enumerate(raw_paths): + errors = validate_raw(path, require_complete=index == len(raw_paths) - 1) + if errors: raise ValueError(f"invalid recording segment {path.name}: " + "; ".join(errors)) + project = args.project.resolve() if args.project else discover_one(session, {".kdenlive"}, "Kdenlive project") + output = args.output.resolve() if args.output else discover_one(session, VIDEO_SUFFIXES, "rendered video") + _, project_settings = project_resources(project) + if project_settings != job["project"]: + raise ValueError(f"saved project profile {project_settings} does not match assigned profile {job['project']}") + bindings, problems = resolve_assets(job_root, job, project) + used_refs = {str(change.get(side, {}).get("asset_reference")) for path in raw_paths for event in read_jsonl(path) + if event.get("event_type") == "state.diff" for change in event.get("diff", {}).get("changes", []) + for side in ("before", "after") if change.get(side, {}).get("asset_reference") is not None} + unresolved_used = sorted(ref for ref in used_refs if ref not in bindings) + if unresolved_used: raise ValueError(f"project could not resolve used Kdenlive asset IDs: {', '.join(unresolved_used)}") + + sample_root = job_root / "completed-sample" + if sample_root.exists(): raise ValueError(f"completed sample already exists: {sample_root}") + for directory in ("assets", "output", "internal", "evidence", "validation"): + (sample_root / directory).mkdir(parents=True, exist_ok=True) + assets = [] + for asset in job["assets"]: + source = job_root / asset["file"] + target = sample_root / "assets" / source.name + shutil.copy2(source, target) + assets.append({**asset, "file": target.relative_to(sample_root).as_posix()}) + target_project = sample_root / "internal" / "final.kdenlive"; shutil.copy2(project, target_project) + target_output = sample_root / "output" / f"editor-final{output.suffix.lower()}"; shutil.copy2(output, target_output) + raw_artifacts = [] + for index, raw in enumerate(raw_paths, 1): + target = sample_root / "evidence" / f"raw-events-{index:03d}.jsonl"; shutil.copy2(raw, target) + raw_artifacts.append({"file": target.relative_to(sample_root).as_posix(), "sha256": sha256(target), + "termination": "normal" if index == len(raw_paths) else "crash"}) + metadata = {"sample_id": job["job_id"], "job_id": job["job_id"], "prompt": job["task"]["prompt"], + "project": project_settings, "assets": assets, "native_asset_bindings": bindings, + "asset_binding_method": "project_resource_sha256", "output_completion_confirmed": True, + "artifacts": {"final_video": target_output.relative_to(sample_root).as_posix(), + "final_video_sha256": sha256(target_output), "native_project": "internal/final.kdenlive", + "native_project_sha256": sha256(target_project), "raw_events": raw_artifacts}} + sample = build_sample(sample_root, metadata) + sample["quality"]["project_asset_resolution_problems"] = problems + dump(sample_root / "sample.json", sample) + report = replay_report([sample_root / item["file"] for item in raw_artifacts]) + media = reconstruct_media(sample_root / "sample.json") + report["media_project_reconstruction"] = media["status"] + report["reconstructed_render"] = "created" if media["status"] in {"passed", "comparison_failed"} else "not_created" + report["media"] = media + dump(sample_root / "validation" / "reconstruction-report.json", report) + sample["quality"]["canonical_reconstruction"] = report["canonical_state_replay"] + sample["quality"]["media_reconstruction"] = media["status"] + sample["quality"]["ready_for_client_review"] = report["canonical_state_replay"] == "passed" and media["status"] == "passed" + if media["status"] in {"passed", "comparison_failed"}: + reconstructed = sample_root / media["render"] + sample["output"]["reconstructed_video"] = media["render"] + sample["output"]["reconstructed_video_sha256"] = sha256(reconstructed) + dump(sample_root / "sample.json", sample) + errors = validate_sample(sample_root / "sample.json", check_files=True) + if report["canonical_state_replay"] != "passed": errors.append("canonical reconstruction failed") + if errors: raise ValueError("generated sample failed validation: " + "; ".join(errors)) + print(f"completed sample: {sample_root}") + print(f"media reconstruction: {media['status']}") + print(f"ready for client review: {str(sample['quality']['ready_for_client_review']).lower()}") + return 0 + + +def parser() -> argparse.ArgumentParser: + result = argparse.ArgumentParser(description=__doc__); sub = result.add_subparsers(dest="command", required=True) + create = sub.add_parser("create-job"); create.add_argument("job_dir", type=Path); create.add_argument("--job-id", required=True) + create.add_argument("--prompt", required=True); create.add_argument("--fps-num", type=int, default=25); create.add_argument("--fps-den", type=int, default=1) + create.add_argument("--width", type=int, default=1920); create.add_argument("--height", type=int, default=1080) + create.add_argument("assets", type=Path, nargs="+"); create.set_defaults(function=create_job) + check = sub.add_parser("validate-job"); check.add_argument("job_dir", type=Path); check.set_defaults(function=validate_job_command) + finish = sub.add_parser("finalize"); finish.add_argument("job_dir", type=Path); finish.add_argument("session_dir", type=Path) + finish.add_argument("--project", type=Path); finish.add_argument("--output", type=Path); finish.set_defaults(function=finalize_job) + return result + + +def main() -> int: + args = parser().parse_args() + try: return args.function(args) + except (OSError, ValueError, ET.ParseError, json.JSONDecodeError) as exc: + print(f"error: {exc}", file=sys.stderr); return 1 + + +if __name__ == "__main__": raise SystemExit(main()) diff --git a/video-path-pilot/media_reconstruct.py b/video-path-pilot/media_reconstruct.py new file mode 100644 index 0000000..b26a5e2 --- /dev/null +++ b/video-path-pilot/media_reconstruct.py @@ -0,0 +1,155 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: 2026 Video Path Pilot contributors +# SPDX-License-Identifier: GPL-3.0-only +"""Reconstruct and render the supported canonical edit subset with MLT.""" + +from __future__ import annotations + +import argparse +import json +import re +import shutil +import subprocess +import sys +import xml.etree.ElementTree as ET +from pathlib import Path + + +class UnsupportedEdit(ValueError): + pass + + +def prop(parent: ET.Element, name: str, value: object) -> None: + element = ET.SubElement(parent, "property", {"name": name}); element.text = str(value) + + +def assert_supported(state: dict) -> None: + if state.get("compositions") or state.get("mixes"): + raise UnsupportedEdit("transitions and mixes are not yet supported by the media adapter") + for master in state.get("master_effects", []): + if master.get("effects"): raise UnsupportedEdit("master effects are not yet supported") + for track in state.get("tracks", []): + if track.get("effects"): raise UnsupportedEdit("track effects are not yet supported") + for clip in state.get("clips", []): + if clip.get("effects"): raise UnsupportedEdit("clip effects and keyframes are not yet supported") + if clip.get("speed", 1) != 1: raise UnsupportedEdit("speed changes are not yet supported") + + +def build_mlt(sample_path: Path, destination: Path) -> None: + sample = json.loads(sample_path.read_text(encoding="utf-8")); root_dir = sample_path.parent + state = sample["edit_path"]["final_state"]; assert_supported(state) + project = sample["project"]; rate = project["frame_rate"]; duration = max(1, state.get("duration_frames", 1)) + assets = {asset["asset_id"]: (root_dir / asset["file"]).resolve() for asset in sample["inputs"]["assets"]} + mlt = ET.Element("mlt", {"LC_NUMERIC": "C", "producer": "main", "version": "7.0.0"}) + ET.SubElement(mlt, "profile", {"frame_rate_num": str(rate["numerator"]), "frame_rate_den": str(rate["denominator"]), + "width": str(project["width"]), "height": str(project["height"]), "progressive": "1", + "sample_aspect_num": "1", "sample_aspect_den": "1", "display_aspect_num": str(project["width"]), + "display_aspect_den": str(project["height"]), "colorspace": "709"}) + black = ET.SubElement(mlt, "producer", {"id": "background", "in": "0", "out": str(duration - 1)}) + prop(black, "resource", "black"); prop(black, "mlt_service", "color"); prop(black, "mlt_image_format", "rgba") + background = ET.SubElement(mlt, "playlist", {"id": "background_playlist"}) + ET.SubElement(background, "entry", {"producer": "background", "in": "0", "out": str(duration - 1)}) + + tracks = sorted(state.get("tracks", []), key=lambda item: item.get("position", 0)) + clips_by_track: dict[str, list[dict]] = {} + for clip in state.get("clips", []): clips_by_track.setdefault(clip["track_id"], []).append(clip) + playlist_ids = [] + for track_index, track in enumerate(tracks, 1): + playlist_id = f"playlist_{track_index}"; playlist_ids.append((playlist_id, track.get("kind", "video"))) + playlist = ET.SubElement(mlt, "playlist", {"id": playlist_id}); cursor = 0 + for clip_index, clip in enumerate(sorted(clips_by_track.get(track["track_id"], []), key=lambda item: item["timeline_start_frame"]), 1): + start = clip["timeline_start_frame"] + if start < cursor: raise UnsupportedEdit("overlapping clips on one track are not yet supported") + if start > cursor: ET.SubElement(playlist, "blank", {"length": str(start - cursor)}) + producer_id = f"producer_{track_index}_{clip_index}" + producer = ET.SubElement(mlt, "producer", {"id": producer_id, "in": str(clip["source_start_frame"]), "out": str(clip["source_end_frame"])}) + resource = assets.get(clip.get("asset_id")) + if resource is None: raise ValueError(f"asset is missing from sample: {clip.get('asset_id')}") + prop(producer, "resource", resource); prop(producer, "mlt_service", "avformat") + # MLT XML resolves producer references in document order. + mlt.remove(producer) + first_playlist = next(index for index, child in enumerate(list(mlt)) if child.tag == "playlist") + mlt.insert(first_playlist, producer) + ET.SubElement(playlist, "entry", {"producer": producer_id, "in": str(clip["source_start_frame"]), "out": str(clip["source_end_frame"])}) + cursor = start + clip["duration_frames"] + + tractor = ET.SubElement(mlt, "tractor", {"id": "main", "in": "0", "out": str(duration - 1)}) + ET.SubElement(tractor, "track", {"producer": "background_playlist"}) + for index, (playlist_id, kind) in enumerate(playlist_ids, 1): + attributes = {"producer": playlist_id, "hide": "audio" if kind == "video" else "video"} + ET.SubElement(tractor, "track", attributes) + transition = ET.SubElement(tractor, "transition", {"id": f"transition_{index}"}) + prop(transition, "a_track", 0); prop(transition, "b_track", index) + prop(transition, "mlt_service", "qtblend" if kind == "video" else "mix") + prop(transition, "always_active", 1) + if kind == "audio": + prop(transition, "accepts_blanks", 1); prop(transition, "sum", 1) + else: + prop(transition, "compositing", 0); prop(transition, "distort", 0); prop(transition, "rotate_center", 0) + ET.indent(mlt) + ET.ElementTree(mlt).write(destination, encoding="utf-8", xml_declaration=True) + + +def ffprobe(path: Path) -> dict: + result = subprocess.run(["ffprobe", "-v", "error", "-show_entries", "format=duration:stream=codec_type,width,height,r_frame_rate", + "-of", "json", str(path)], check=True, capture_output=True, text=True) + return json.loads(result.stdout) + + +def compare_media(editor: Path, reconstructed: Path, editor_probe: dict, reconstructed_probe: dict) -> dict: + ffmpeg = shutil.which("ffmpeg") + if not ffmpeg: raise RuntimeError("ffmpeg executable was not found") + video = subprocess.run([ffmpeg, "-hide_banner", "-i", str(editor), "-i", str(reconstructed), + "-lavfi", "[0:v][1:v]ssim", "-f", "null", "-"], capture_output=True, text=True) + match = re.search(r"All:([0-9.]+)", video.stderr) + if not match: raise RuntimeError("FFmpeg did not report video SSIM") + ssim = float(match.group(1)) + editor_audio = any(stream.get("codec_type") == "audio" for stream in editor_probe.get("streams", [])) + reconstructed_audio = any(stream.get("codec_type") == "audio" for stream in reconstructed_probe.get("streams", [])) + audio_psnr = None + if editor_audio and reconstructed_audio: + audio = subprocess.run([ffmpeg, "-hide_banner", "-i", str(editor), "-i", str(reconstructed), + "-lavfi", "[0:a][1:a]apsnr", "-f", "null", "-"], capture_output=True, text=True) + values = [float(value) for value in re.findall(r"PSNR ch\d+: ([0-9.]+) dB", audio.stderr)] + if values: audio_psnr = min(values) + editor_video = next(stream for stream in editor_probe["streams"] if stream.get("codec_type") == "video") + reconstructed_video = next(stream for stream in reconstructed_probe["streams"] if stream.get("codec_type") == "video") + editor_duration = float(editor_probe["format"]["duration"]); reconstructed_duration = float(reconstructed_probe["format"]["duration"]) + profile_match = (editor_video.get("width"), editor_video.get("height"), editor_video.get("r_frame_rate")) == ( + reconstructed_video.get("width"), reconstructed_video.get("height"), reconstructed_video.get("r_frame_rate")) + duration_delta = abs(editor_duration - reconstructed_duration) + passed = profile_match and duration_delta <= 0.05 and ssim >= 0.95 and (audio_psnr is None or audio_psnr >= 40.0) + return {"profile_match": profile_match, "duration_delta_seconds": duration_delta, "video_ssim": ssim, + "video_ssim_threshold": 0.95, "audio_psnr_db": audio_psnr, "audio_psnr_threshold_db": 40.0, + "passed": passed} + + +def reconstruct(sample_path: Path) -> dict: + root = sample_path.parent; validation = root / "validation"; output = root / "output" + validation.mkdir(exist_ok=True); output.mkdir(exist_ok=True) + mlt_path = validation / "reconstructed.mlt"; render = output / "reconstructed.mp4" + report = {"adapter": "mlt-basic-cut-v0.1", "project": str(mlt_path.relative_to(root)), "render": str(render.relative_to(root))} + try: + build_mlt(sample_path, mlt_path) + melt = shutil.which("melt-7") or shutil.which("melt") + if not melt: raise RuntimeError("melt executable was not found") + subprocess.run([melt, str(mlt_path), "-consumer", f"avformat:{render}", "vcodec=libx264", "acodec=aac", "real_time=-1"], + check=True, capture_output=True, text=True) + editor = root / json.loads(sample_path.read_text())["output"]["video"] + reconstructed_probe, editor_probe = ffprobe(render), ffprobe(editor) + comparison = compare_media(editor, render, editor_probe, reconstructed_probe) + report.update({"status": "passed" if comparison["passed"] else "comparison_failed", + "reconstructed_probe": reconstructed_probe, "editor_probe": editor_probe, "comparison": comparison}) + except UnsupportedEdit as exc: + report.update({"status": "unsupported", "reason": str(exc)}) + except (OSError, RuntimeError, subprocess.CalledProcessError, json.JSONDecodeError, ET.ParseError) as exc: + report.update({"status": "failed", "reason": str(exc)}) + return report + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__); parser.add_argument("sample", type=Path); args = parser.parse_args() + report = reconstruct(args.sample.resolve()); print(json.dumps(report, indent=2)); return 0 if report["status"] == "passed" else 1 + + +if __name__ == "__main__": raise SystemExit(main()) diff --git a/video-path-pilot/normalize_sample.py b/video-path-pilot/normalize_sample.py index 505c47f..cf5e418 100755 --- a/video-path-pilot/normalize_sample.py +++ b/video-path-pilot/normalize_sample.py @@ -6,11 +6,13 @@ from __future__ import annotations import json +import copy from pathlib import Path from typing import Any ENTITY_PREFIX = {"clip": "clip", "track": "track", "composition": "transition", "mix": "transition", "master_effect": "master"} +COLLECTION = {"clip": "clips", "track": "tracks", "composition": "compositions", "mix": "mixes", "master_effect": "master_effects"} def read_jsonl(path: Path) -> list[dict]: @@ -82,7 +84,7 @@ def clean(value: Any) -> Any: result["track_id"] = ids[track_key] if "asset_reference" in result: ref = str(result.pop("asset_reference")) - result["asset_id"] = assets.setdefault(ref, f"asset_{len(assets) + 1:03d}") + result["asset_id"] = assets.setdefault(ref, f"unresolved_native_asset_{ref}") return result result = {"change": change.get("change"), "entity_type": entity, "entity_id": ids[key]} @@ -91,12 +93,40 @@ def clean(value: Any) -> Any: return result +def apply_native_diff(snapshot: dict, diff: dict) -> None: + for change in diff.get("changes", []): + collection = snapshot.setdefault(COLLECTION[change["entity"]], []) + position = next((i for i, value in enumerate(collection) if value.get("native_id") == change.get("native_id")), None) + if change["change"] == "removed" and position is not None: + collection.pop(position) + elif change["change"] == "added": + collection.append(copy.deepcopy(change["after"])) + elif change["change"] == "updated" and position is not None: + collection[position] = copy.deepcopy(change["after"]) + if "duration_after" in diff: + snapshot["duration_frames"] = diff["duration_after"] + + +def normalized_state(snapshot: dict, ids: dict[tuple[str, str], str], assets: dict[str, str]) -> dict: + state = {"duration_frames": snapshot.get("duration_frames", 0)} + for singular, collection_name in COLLECTION.items(): + state[collection_name] = [normalized_change({"entity": singular, "native_id": value.get("native_id"), + "change": "added", "after": value}, ids, assets)["after"] | { + f"{singular}_id": ids[(singular, str(value.get("native_id")))]} + for value in snapshot.get(collection_name, [])] + return state + + def build_sample(root: Path, metadata: dict) -> dict: - events = read_jsonl(root / "evidence" / "raw-events.jsonl") + raw_artifacts = metadata["artifacts"]["raw_events"] + if isinstance(raw_artifacts, str): + raw_artifacts = [{"file": raw_artifacts}] + event_groups = [read_jsonl(root / artifact["file"]) for artifact in raw_artifacts] ids: dict[tuple[str, str], str] = {} - asset_refs: dict[str, str] = {} + asset_refs: dict[str, str] = dict(metadata.get("native_asset_bindings", {})) operations = [] - for index, event in enumerate(accepted_commits(events), 1): + commits = [event for events in event_groups for event in accepted_commits(events)] + for index, event in enumerate(commits, 1): diff = event.get("diff", {}) operations.append({ "operation_id": f"op_{index:04d}", @@ -106,15 +136,26 @@ def build_sample(root: Path, metadata: dict) -> dict: "evidence": {"raw_event_id": event.get("event_id"), "raw_sequence": event.get("sequence")}, "extensions": {"kdenlive": {"command_label": event.get("label")}}, }) - input_assets = [{k: a[k] for k in ("asset_id", "original_filename", "file", "sha256", "bytes")} for a in metadata["assets"]] - unresolved = sorted(set(asset_refs.values()) - {a["asset_id"] for a in input_assets}) + first_checkpoint = next((event for event in event_groups[0] if event.get("event_type") == "state.checkpoint"), None) + if not first_checkpoint: + raise ValueError("recording has no canonical checkpoint") + initial_native = copy.deepcopy(first_checkpoint["snapshot"]) + final_native = copy.deepcopy(initial_native) + for event in commits: + apply_native_diff(final_native, event.get("diff", {})) + initial_state = normalized_state(initial_native, ids, asset_refs) + final_state = normalized_state(final_native, ids, asset_refs) + input_assets = [{"asset_id": a["asset_id"], "original_filename": a.get("original_filename", Path(a["file"]).name), + "file": a["file"], "sha256": a["sha256"], "bytes": a["bytes"]} for a in metadata["assets"]] + valid_asset_ids = {a["asset_id"] for a in input_assets} + unresolved = sorted(value for value in set(asset_refs.values()) if value not in valid_asset_ids) return { "schema_version": "0.1.0", "sample_id": metadata["sample_id"], "task": {"prompt": metadata["prompt"]}, "project": metadata["project"], "inputs": {"assets": input_assets}, - "edit_path": {"time_unit": "frame", "operations": operations}, + "edit_path": {"time_unit": "frame", "initial_state": initial_state, "operations": operations, "final_state": final_state}, "output": {"video": metadata["artifacts"]["final_video"], "sha256": metadata["artifacts"]["final_video_sha256"]}, "quality": { "raw_session_complete": True, @@ -125,10 +166,9 @@ def build_sample(root: Path, metadata: dict) -> dict: "output_completion_confirmed": metadata["output_completion_confirmed"], }, "evidence": { - "raw_events": metadata["artifacts"]["raw_events"], - "raw_events_sha256": metadata["artifacts"]["raw_events_sha256"], + "raw_events": raw_artifacts, "native_project": metadata["artifacts"]["native_project"], "native_project_sha256": metadata["artifacts"]["native_project_sha256"], }, - "provenance": {"editor_id": metadata["editor"]["editor_id"], "collector": "kdenlive-video-path-mvp", "collector_version": "0.1.0"}, + "provenance": {"job_id": metadata.get("job_id", metadata["sample_id"]), "collector": "kdenlive-video-path-mvp", "collector_version": "0.2.0"}, } diff --git a/video-path-pilot/run-collector-app.sh b/video-path-pilot/run-collector-app.sh index b236128..c4b1043 100755 --- a/video-path-pilot/run-collector-app.sh +++ b/video-path-pilot/run-collector-app.sh @@ -20,4 +20,7 @@ export EDIT_PATH_REPO_ROOT="$repo_root" export LD_LIBRARY_PATH="$craft_root/lib:$craft_root/usr/lib/x86_64-linux-gnu${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" export FONTCONFIG_FILE="$craft_root/etc/fonts/fonts.conf" export FONTCONFIG_PATH="$craft_root/etc/fonts" +export MLT_PREFIX="$craft_root" +export MLT_DATA="$craft_root/share/mlt-7" +export MLT_REPOSITORY="$craft_root/lib/mlt-7" exec "$binary" diff --git a/video-path-pilot/run-video-path-pilot.sh b/video-path-pilot/run-video-path-pilot.sh index b463bff..5927cfe 100755 --- a/video-path-pilot/run-video-path-pilot.sh +++ b/video-path-pilot/run-video-path-pilot.sh @@ -44,5 +44,8 @@ arguments=() if [[ -n ${KDENLIVE_VIDEO_PATH_CONFIG:-} ]]; then arguments+=(--config "$KDENLIVE_VIDEO_PATH_CONFIG" --no-welcome) fi +if [[ -n ${KDENLIVE_VIDEO_PATH_CLIPS:-} ]]; then + arguments+=(-i "$KDENLIVE_VIDEO_PATH_CLIPS") +fi exec "$binary" "${arguments[@]}" diff --git a/video-path-pilot/sample_collector.py b/video-path-pilot/sample_collector.py deleted file mode 100755 index 67c0b7c..0000000 --- a/video-path-pilot/sample_collector.py +++ /dev/null @@ -1,201 +0,0 @@ -#!/usr/bin/env python3 -# SPDX-FileCopyrightText: 2026 Video Path Pilot contributors -# SPDX-License-Identifier: GPL-3.0-only -"""Internal prototype for constructing and validating a sample package.""" - -from __future__ import annotations - -import argparse -import hashlib -import json -import shutil -import subprocess -import sys -from datetime import datetime, timezone -from pathlib import Path - -from normalize_sample import build_sample -from validate_sample import validate_sample -from validate_video_path import validate as validate_raw - - -def utc_now() -> str: - return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") - - -def dump(path: Path, value: object) -> None: - path.write_text(json.dumps(value, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") - - -def sha256(path: Path) -> str: - digest = hashlib.sha256() - with path.open("rb") as stream: - for block in iter(lambda: stream.read(1024 * 1024), b""): - digest.update(block) - return digest.hexdigest() - - -def command_init(args: argparse.Namespace) -> int: - root = args.sample_dir.resolve() - if root.exists(): - raise ValueError(f"refusing to overwrite existing sample directory: {root}") - if not args.assets: - raise ValueError("at least one source asset is required") - for asset in args.assets: - if not asset.is_file(): - raise ValueError(f"asset does not exist: {asset}") - - (root / "assets").mkdir(parents=True) - (root / "output").mkdir() - (root / "internal").mkdir() - (root / "evidence").mkdir() - - assets = [] - for index, source in enumerate(args.assets, 1): - asset_id = f"asset_{index:03d}" - destination = root / "assets" / f"{asset_id}{source.suffix.lower()}" - shutil.copy2(source.resolve(), destination) - assets.append({ - "asset_id": asset_id, - "original_filename": source.name, - "file": destination.relative_to(root).as_posix(), - "sha256": sha256(destination), - "bytes": destination.stat().st_size, - }) - - metadata = { - "collector_version": "0.1.0", - "sample_id": args.sample_id or root.name, - "created_at_utc": utc_now(), - "status": "initialized", - "prompt": args.prompt, - "editor": {"editor_id": args.editor_id}, - "project": { - "frame_rate": {"numerator": args.fps_num, "denominator": args.fps_den}, - "width": args.width, - "height": args.height, - }, - "assets": assets, - "asset_binding_method": "first_use_order", - } - dump(root / "internal" / "collector-metadata.json", metadata) - print(f"created sample workspace: {root}") - print("Import the files from its assets/ directory into Kdenlive in filename order.") - print(f"Then launch with: {Path(__file__).name} launch {root}") - return 0 - - -def load_metadata(root: Path) -> dict: - path = root / "internal" / "collector-metadata.json" - if not path.is_file(): - raise ValueError(f"not a sample workspace: {root}") - return json.loads(path.read_text(encoding="utf-8")) - - -def command_launch(args: argparse.Namespace) -> int: - root = args.sample_dir.resolve() - metadata = load_metadata(root) - raw = root / "evidence" / "raw-events.jsonl" - if raw.exists(): - raise ValueError(f"raw recording already exists: {raw}") - launcher = Path(__file__).with_name("run-video-path-pilot.sh") - metadata["status"] = "recording" - metadata["recording_started_at_utc"] = utc_now() - dump(root / "internal" / "collector-metadata.json", metadata) - return subprocess.call([str(launcher), str(raw)]) - - -def copy_artifact(source: Path, destination: Path) -> None: - if not source.is_file(): - raise ValueError(f"required file does not exist: {source}") - if source.resolve() != destination.resolve(): - shutil.copy2(source.resolve(), destination) - - -def command_finalize(args: argparse.Namespace) -> int: - root = args.sample_dir.resolve() - metadata = load_metadata(root) - raw = root / "evidence" / "raw-events.jsonl" - raw_errors = validate_raw(raw) - if raw_errors: - raise ValueError("raw recording is invalid:\n " + "\n ".join(raw_errors)) - - copy_artifact(args.project, root / "internal" / "final.kdenlive") - suffix = args.output.suffix.lower() or ".mp4" - final_video = root / "output" / f"final{suffix}" - copy_artifact(args.output, final_video) - metadata["status"] = "finalized" - metadata["finalized_at_utc"] = utc_now() - metadata["output_completion_confirmed"] = True - metadata["artifacts"] = { - "final_video": final_video.relative_to(root).as_posix(), - "final_video_sha256": sha256(final_video), - "native_project": "internal/final.kdenlive", - "native_project_sha256": sha256(root / "internal" / "final.kdenlive"), - "raw_events": "evidence/raw-events.jsonl", - "raw_events_sha256": sha256(raw), - } - dump(root / "internal" / "collector-metadata.json", metadata) - dump(root / "sample.json", build_sample(root, metadata)) - errors = validate_sample(root / "sample.json", check_files=True) - if errors: - raise ValueError("generated sample failed validation:\n " + "\n ".join(errors)) - print(f"sample finalized and valid: {root / 'sample.json'}") - return 0 - - -def command_validate(args: argparse.Namespace) -> int: - root = args.sample_dir.resolve() - errors = validate_sample(root / "sample.json", check_files=True) - if errors: - for error in errors: - print(error, file=sys.stderr) - return 1 - print(f"valid sample: {root / 'sample.json'}") - return 0 - - -def parser() -> argparse.ArgumentParser: - result = argparse.ArgumentParser(description=__doc__) - sub = result.add_subparsers(dest="command", required=True) - init = sub.add_parser("init", help="create a new sample workspace") - init.add_argument("sample_dir", type=Path) - init.add_argument("--sample-id") - init.add_argument("--prompt", required=True) - init.add_argument("--editor-id", required=True) - init.add_argument("--fps-num", type=int, default=25) - init.add_argument("--fps-den", type=int, default=1) - init.add_argument("--width", type=int, default=1920) - init.add_argument("--height", type=int, default=1080) - init.add_argument("assets", type=Path, nargs="+") - init.set_defaults(function=command_init) - - launch = sub.add_parser("launch", help="start the instrumented Kdenlive") - launch.add_argument("sample_dir", type=Path) - launch.set_defaults(function=command_launch) - - finalize = sub.add_parser("finalize", help="package and validate a completed sample") - finalize.add_argument("sample_dir", type=Path) - finalize.add_argument("--project", type=Path, required=True) - finalize.add_argument("--output", type=Path, required=True) - finalize.add_argument("--confirm-output-complete", action="store_true", required=True, - help="confirm that the required project and render were supplied") - finalize.set_defaults(function=command_finalize) - - validate = sub.add_parser("validate", help="validate a finalized sample") - validate.add_argument("sample_dir", type=Path) - validate.set_defaults(function=command_validate) - return result - - -def main() -> int: - args = parser().parse_args() - try: - return args.function(args) - except (OSError, ValueError, json.JSONDecodeError) as exc: - print(f"error: {exc}", file=sys.stderr) - return 1 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/video-path-pilot/tests/test_mvp.py b/video-path-pilot/tests/test_mvp.py index 9210481..42878a6 100644 --- a/video-path-pilot/tests/test_mvp.py +++ b/video-path-pilot/tests/test_mvp.py @@ -9,6 +9,7 @@ from pathlib import Path sys.path.insert(0, str(Path(__file__).parents[1])) +from job_pipeline import canonical_hash, replay_report, resolve_assets from normalize_sample import accepted_commits, build_sample from validate_sample import validate_sample @@ -33,11 +34,14 @@ def test_build_and_validate_sample(self): (root / "output/final.mp4").write_bytes(b"video") (root / "internal/final.kdenlive").write_bytes(b"project") events = [{"event_type": "session.start", "sequence": 1}, { - "event_type": "state.diff", "boundary": "commit", "sequence": 2, + "event_type": "state.checkpoint", "sequence": 2, "snapshot": {"timeline_id": "timeline", + "duration_frames": 0, "tracks": [], "clips": [], "compositions": [], "mixes": [], + "master_effects": [{"native_id": 0, "effects": []}]}, "state_hash": "a" * 64}, { + "event_type": "state.diff", "boundary": "commit", "sequence": 3, "event_id": "raw-2", "label": "Insert Clip", "after_hash": HASH_B, "diff": {"changes": [{"entity": "clip", "native_id": 8, "change": "added", "after": {"asset_reference": "4", "track_native_id": 3, "timeline_start_frame": 0, "duration_frames": 25}}]}, - }, {"event_type": "session.end", "sequence": 3}] + }, {"event_type": "session.end", "sequence": 4}] raw = root / "evidence/raw-events.jsonl" raw.write_text("".join(json.dumps(e) + "\n" for e in events)) sha = lambda path: hashlib.sha256(path.read_bytes()).hexdigest() @@ -47,10 +51,11 @@ def test_build_and_validate_sample(self): "project": {"frame_rate": {"numerator": 25, "denominator": 1}, "width": 1920, "height": 1080}, "assets": [{"asset_id": "asset_001", "original_filename": "source.mp4", "file": "assets/asset_001.mp4", "sha256": sha(root / "assets/asset_001.mp4"), "bytes": 5}], - "asset_binding_method": "first_use_order", "output_completion_confirmed": True, + "asset_binding_method": "project_resource_sha256", "native_asset_bindings": {"4": "asset_001"}, + "output_completion_confirmed": True, "artifacts": {"final_video": "output/final.mp4", "final_video_sha256": sha(root / "output/final.mp4"), "native_project": "internal/final.kdenlive", "native_project_sha256": sha(root / "internal/final.kdenlive"), - "raw_events": "evidence/raw-events.jsonl", "raw_events_sha256": sha(raw)}} + "raw_events": [{"file": "evidence/raw-events.jsonl", "sha256": sha(raw)}]}} sample = build_sample(root, metadata) self.assertEqual(sample["edit_path"]["operations"][0]["operation"], "clip.insert") self.assertNotIn("rationale", sample) @@ -59,6 +64,45 @@ def test_build_and_validate_sample(self): path.write_text(json.dumps(sample)) self.assertEqual(validate_sample(path, check_files=True), []) + def test_project_resources_resolve_by_hash_not_import_order(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + (root / "assets").mkdir() + first = root / "assets/asset_001.wav"; first.write_bytes(b"audio") + second = root / "assets/asset_002.mp4"; second.write_bytes(b"video") + project = root / "final.kdenlive" + project.write_text(f''' + + {second} + 4avformat + ''') + make = lambda path, asset_id: {"asset_id": asset_id, "file": str(path.relative_to(root)), + "sha256": hashlib.sha256(path.read_bytes()).hexdigest(), "bytes": path.stat().st_size} + job = {"assets": [make(first, "asset_001"), make(second, "asset_002")]} + bindings, problems = resolve_assets(root, job, project) + self.assertEqual(bindings, {"4": "asset_002"}) + self.assertEqual(problems, []) + + def test_canonical_replay_reconstructs_exact_state_hash(self): + with tempfile.TemporaryDirectory() as temporary: + path = Path(temporary) / "raw.jsonl" + baseline = {"timeline_id": "timeline", "duration_frames": 0, "tracks": [], "clips": [], + "compositions": [], "mixes": [], "master_effects": [{"native_id": 0, "effects": []}]} + after = dict(baseline) + after["duration_frames"] = 25 + after["clips"] = [{"native_id": 8, "asset_reference": "4", "track_native_id": 3, + "timeline_start_frame": 0, "duration_frames": 25}] + events = [ + {"event_type": "state.checkpoint", "snapshot": baseline, "state_hash": canonical_hash(baseline)}, + {"event_type": "state.diff", "boundary": "commit", "event_id": "event-1", + "after_hash": canonical_hash(after), "diff": {"duration_after": 25, "changes": [ + {"entity": "clip", "native_id": 8, "change": "added", "after": after["clips"][0]}]}}, + ] + path.write_text("".join(json.dumps(event) + "\n" for event in events)) + report = replay_report([path]) + self.assertEqual(report["canonical_state_replay"], "passed") + self.assertEqual(report["reconstructed_render"], "not_implemented") + if __name__ == "__main__": unittest.main() diff --git a/video-path-pilot/validate_sample.py b/video-path-pilot/validate_sample.py index 3fcd800..202edf1 100755 --- a/video-path-pilot/validate_sample.py +++ b/video-path-pilot/validate_sample.py @@ -52,9 +52,12 @@ def validate_sample(path: Path, check_files: bool = False) -> list[str]: references = [(a.get("file"), a.get("sha256")) for a in assets if isinstance(a, dict)] references += [ (sample.get("output", {}).get("video"), sample.get("output", {}).get("sha256")), - (sample.get("evidence", {}).get("raw_events"), sample.get("evidence", {}).get("raw_events_sha256")), (sample.get("evidence", {}).get("native_project"), sample.get("evidence", {}).get("native_project_sha256")), ] + if sample.get("output", {}).get("reconstructed_video"): + references.append((sample["output"]["reconstructed_video"], sample["output"].get("reconstructed_video_sha256"))) + for raw in sample.get("evidence", {}).get("raw_events", []): + if isinstance(raw, dict): references.append((raw.get("file"), raw.get("sha256"))) for relative, expected in references: if not isinstance(relative, str): errors.append("artifact path is missing"); continue artifact = root / relative diff --git a/video-path-pilot/validate_video_path.py b/video-path-pilot/validate_video_path.py index fb2e710..c3e6a2e 100644 --- a/video-path-pilot/validate_video_path.py +++ b/video-path-pilot/validate_video_path.py @@ -35,7 +35,7 @@ } -def validate(path: Path) -> list[str]: +def validate(path: Path, require_complete: bool = True) -> list[str]: errors: list[str] = [] session_id: str | None = None expected_sequence = 1 @@ -162,9 +162,9 @@ def validate(path: Path) -> list[str]: if event_count == 0: errors.append("file has no events") - elif session_end_count == 0: + elif require_complete and session_end_count == 0: errors.append("incomplete session: missing session.end (application may have crashed or been force-quit)") - elif last_event_type != "session.end": + elif require_complete and last_event_type != "session.end": errors.append("session.end must be the final event") return errors From 5617114d3101e30727f3e536e003ef35fdcdccf6 Mon Sep 17 00:00:00 2001 From: Tenali Rama <225068477+Tenali-Rama@users.noreply.github.com> Date: Wed, 22 Jul 2026 16:37:19 +0530 Subject: [PATCH 04/14] Keep recorder responsive during Kdenlive startup --- video-path-pilot/gui/main.cpp | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/video-path-pilot/gui/main.cpp b/video-path-pilot/gui/main.cpp index 20fdc47..8bd8da3 100644 --- a/video-path-pilot/gui/main.cpp +++ b/video-path-pilot/gui/main.cpp @@ -115,6 +115,17 @@ class RecorderWindow final : public QMainWindow connect(m_openSession, &QPushButton::clicked, this, [this] { QDesktopServices::openUrl(QUrl::fromLocalFile(m_session)); }); connect(m_openCompleted, &QPushButton::clicked, this, [this] { QDesktopServices::openUrl(QUrl::fromLocalFile(m_jobRoot + QStringLiteral("/completed-sample"))); }); connect(&m_editor, qOverload(&QProcess::finished), this, &RecorderWindow::editorFinished); + connect(&m_editor, &QProcess::started, this, [this] { + writeSessionManifest(QStringLiteral("recording")); + m_activity->appendPlainText(QStringLiteral("Kdenlive process started. Remote X11 startup can take 15–60 seconds.")); + }); + connect(&m_editor, &QProcess::errorOccurred, this, [this](QProcess::ProcessError error) { + if (error == QProcess::FailedToStart) { + writeSessionManifest(QStringLiteral("start_failed")); + setStatus(QStringLiteral("Kdenlive could not be started. Check the SSH X11 connection and segment console log."), true); + m_start->setEnabled(true); + } + }); connect(&m_worker, &QProcess::readyReadStandardOutput, this, &RecorderWindow::readWorker); connect(&m_worker, &QProcess::readyReadStandardError, this, &RecorderWindow::readWorker); connect(&m_worker, qOverload(&QProcess::finished), this, &RecorderWindow::workerFinished); @@ -224,8 +235,6 @@ class RecorderWindow final : public QMainWindow setStatus(m_segment == 1 ? QStringLiteral("Editing session is recording. Save the project and final render in the session folder.") : QStringLiteral("Recovery segment is recording. Complete the edit and close Kdenlive normally.")); m_editor.start(m_repoRoot + QStringLiteral("/video-path-pilot/run-video-path-pilot.sh"), {raw}); - if (m_editor.waitForStarted(5000)) writeSessionManifest(QStringLiteral("recording")); - else { setStatus(QStringLiteral("Kdenlive could not be started. See the segment console log."), true); m_start->setEnabled(true); } } void editorFinished(int exitCode, QProcess::ExitStatus status) From a6e12904214e8d0586ffa25ffc257f9175c1669b Mon Sep 17 00:00:00 2001 From: Tenali Rama <225068477+Tenali-Rama@users.noreply.github.com> Date: Wed, 22 Jul 2026 17:00:35 +0530 Subject: [PATCH 05/14] Switch recorder to freeform asset discovery --- documentation.md | 27 ++++ video-path-pilot/EDITOR_WORKFLOW.md | 77 ++++----- video-path-pilot/README.md | 26 ++-- video-path-pilot/gui/main.cpp | 233 ++++++++++------------------ video-path-pilot/job_pipeline.py | 55 +++++++ video-path-pilot/sample.schema.json | 3 +- video-path-pilot/validate_sample.py | 6 +- 7 files changed, 208 insertions(+), 219 deletions(-) diff --git a/documentation.md b/documentation.md index 95f6735..2929b75 100644 --- a/documentation.md +++ b/documentation.md @@ -587,6 +587,33 @@ calculation. Native asset ID 4 resolved to `asset_001`, canonical replay passed, media reconstruction passed with SSIM 0.974985 and audio PSNR 172.592 dB, every packaged hash validated, and `quality.ready_for_client_review` was true. +### Freeform editor workflow correction + +Testing showed that an assigned-job initialization screen was the wrong product +assumption. Editors may obtain or create assets throughout an edit rather than +receiving a complete manifest at startup. The editor GUI was reduced again to +Start Session, Recover and Continue, Finish Session, and folder actions. It +launches blank isolated Kdenlive and does not preload media. + +On Finish, `finalize-freeform` parses every file-backed `chain` and `producer` +from the saved project, deduplicates resources by SHA-256, assigns canonical +asset IDs, copies the discovered media, and resolves native IDs before sample +normalization. A freeform end-to-end test discovered the project asset, +generated the package, passed canonical and media reconstruction, and validated +all hashes. + +Software cannot recover a verbal instruction. Therefore `sample.json` is still +generated at the editor end but contains `task.prompt: null`, +`prompt_status: pending_internal_entry`, and `ready_for_client_review: false`. +The internal `attach-prompt` operation inserts the exact known instruction and +recomputes readiness. The acceptance test changed readiness to true after that +attachment without collecting any editor intent. + +The latest reported “crash” was also audited. Both recent JSONL files ended with +valid `session.end`, Kdenlive logged requested close events, session manifests +reached `ready_to_finish`, and no core dump existed. The recording did not +crash, although remote X11 responsiveness made the shutdown appear abrupt. + ### Privacy and security The collector can reveal editor behavior, project structure, local file paths, diff --git a/video-path-pilot/EDITOR_WORKFLOW.md b/video-path-pilot/EDITOR_WORKFLOW.md index 4b06314..c497478 100644 --- a/video-path-pilot/EDITOR_WORKFLOW.md +++ b/video-path-pilot/EDITOR_WORKFLOW.md @@ -1,52 +1,31 @@ -# Editor workflow for the recorder MVP - -The editor-facing application records editing behavior and outcomes only. It -does not collect the editor's plan, reasoning, decisions, rationale, or review. -The Parsewave team constructs the two client samples after the sessions. - -## Start the app - -Double-click `run-collector-app.sh` and choose **Run** if Linux asks whether to -display or execute it. No terminal commands are part of the editor workflow. - -## Record a session - -1. Click **Open Assigned Job** and select the provided `job.json`. The app shows - the externally assigned task, project profile, and asset count. -2. Click **Start Editing Session**. The app creates a unique folder under the - job's `sessions/` directory, launches an isolated Kdenlive configuration, - and imports the job assets automatically. -3. Edit normally using the displayed task and supplied assets. -4. Save the native `.kdenlive` project and rendered final video inside the - session folder displayed by the recorder. -5. Close Kdenlive normally. If it crashes, click **Recover and Continue**; the - app preserves the prior segment and reopens the same isolated Kdenlive - recovery context. -6. After recording validation, click **Finish Job**. The app resolves project - bin IDs by asset SHA-256, normalizes the accepted edit path, generates and - validates `sample.json`, and replays canonical state hashes. -7. Click **Open Completed Sample** and return the job directory. - -The session folder contains at least: - -```text -session_YYYYMMDD_HHMMSS/ -├── raw-events-001.jsonl -├── kdenlive-console-001.log -├── final.kdenlive # saved by editor -└── final.mp4 # rendered by editor -``` - -Crash recovery creates `raw-events-002.jsonl`, `raw-events-003.jsonl`, and so -on. Earlier incomplete segments remain auditable instead of being overwritten. - -## Internal team workflow - -The editor application generates `sample.json` automatically. The internal team -still performs final human review. Canonical replay is required. The initial -media adapter also reconstructs and renders ordinary cut/trim/move timelines; -effects, transitions, speed changes, and other unsupported features are clearly -reported and the sample is marked not ready for client review. +# Editor workflow for the freeform recorder MVP + +The editor may find, download, generate, or import media at any point. Nothing +must be prepared in the app before editing. + +1. Double-click `run-collector-app.sh` and choose **Run**. +2. Click **Start Editing Session** and wait for the blank Kdenlive window. + Remote X11 startup can take 15–60 seconds. +3. Make the requested video normally. Import or create assets whenever needed. +4. Save exactly one `.kdenlive` project directly in the session folder shown by + the recorder. +5. Render exactly one final video (`.mp4`, `.mov`, `.mkv`, or `.webm`) into that + same folder. +6. Close Kdenlive normally and wait for recording validation. +7. If Kdenlive ended unexpectedly, use **Recover and Continue**. A new numbered + event segment is created without overwriting prior evidence. +8. Click **Finish Session**. The app discovers resources from the saved project, + hashes and copies them, resolves Kdenlive IDs, generates `sample.json`, + reconstructs supported edits, renders, and compares media. +9. Use **Open Generated Sample** to inspect the result. + +The generated sample marks the verbal task prompt as +`pending_internal_entry`. The internal team attaches the exact instruction +before client review. No editor intent is collected. + +Unsupported effects, transitions, speed changes, or other reconstruction gaps +do not destroy the sample; they are reported and keep +`ready_for_client_review` false. diff --git a/video-path-pilot/README.md b/video-path-pilot/README.md index e617815..0eec147 100644 --- a/video-path-pilot/README.md +++ b/video-path-pilot/README.md @@ -5,10 +5,10 @@ SPDX-License-Identifier: GPL-3.0-only # Kdenlive Video Path Pilot -This fork includes an assigned-job recording MVP around Kdenlive. The app -records interactions and canonical outcomes without collecting editor intent, -resolves assets from the saved project by SHA-256, normalizes the accepted path, -and generates `sample.json` automatically. +This fork includes a freeform recording MVP around Kdenlive. Editors can import, +download, or create media at any point. The app records canonical outcomes, +discovers actual resources from the final project, resolves them by SHA-256, +normalizes the accepted path, and generates `sample.json` automatically. For the two-sample client trial, begin with `EDITOR_WORKFLOW.md`. The clean format and language are in `sample.schema.json` and `VOCABULARY.md`. @@ -22,11 +22,10 @@ video-path-pilot/run-collector-app.sh ``` Choose **Run** if the file manager asks whether to display or execute the file. -The app opens a supplied `job.json`, creates a session folder, launches -Kdenlive with an isolated configuration and preloaded assets, records numbered -segments, offers crash recovery, validates termination, and packages the -completed sample. It never asks for an editor plan, rationale, creative -decisions, or subjective review. No terminal commands are required. +The one-screen app creates a session folder, launches blank Kdenlive with an +isolated configuration, records numbered segments, offers crash recovery, +validates termination, and packages the completed sample. There is no assigned +job or initialization screen. No terminal commands are required. Canonical state replay must reproduce every recorded state hash. A first MLT media adapter reconstructs cut/trim/move edits with normal-speed clips and no @@ -41,10 +40,11 @@ The underlying command interface remains available to developers and tests: python3 video-path-pilot/sample_collector.py --help ``` -`job_pipeline.py` is used internally to create assigned jobs and by the app to -package completed samples. The older first-use-order collector was removed so -it cannot produce incorrect asset identities. Undo/redo remains in raw evidence -but is removed from the clean successful trajectory. +`job_pipeline.py` discovers project resources and packages completed sessions. +It can also create controlled jobs for automated testing, but the editor GUI +does not require them. A verbal task becomes an explicit pending prompt in the +generated sample; the internal team attaches the exact wording later. Undo/redo +remains in raw evidence but is removed from the clean successful trajectory. The pilot is based on upstream Kdenlive revision `7de2ed9902b4288797a7781498546389a482a39e`. diff --git a/video-path-pilot/gui/main.cpp b/video-path-pilot/gui/main.cpp index 8bd8da3..4bfea00 100644 --- a/video-path-pilot/gui/main.cpp +++ b/video-path-pilot/gui/main.cpp @@ -7,10 +7,8 @@ #include #include #include -#include #include #include -#include #include #include #include @@ -21,6 +19,7 @@ #include #include #include +#include #include #include #include @@ -38,6 +37,13 @@ QString repositoryRoot() } return {}; } + +QString sessionsRoot() +{ + QString videos = QStandardPaths::writableLocation(QStandardPaths::MoviesLocation); + if (videos.isEmpty()) videos = QDir::homePath() + QStringLiteral("/Videos"); + return QDir(videos).filePath(QStringLiteral("EditPathSessions")); +} } class RecorderWindow final : public QMainWindow @@ -45,15 +51,8 @@ class RecorderWindow final : public QMainWindow public: RecorderWindow() : m_repoRoot(repositoryRoot()) { - setWindowTitle(QStringLiteral("Edit Path Recorder MVP")); - resize(760, 580); - buildUi(); - const QString previous = QSettings().value(QStringLiteral("lastJob")).toString(); - if (!previous.isEmpty() && QFileInfo::exists(previous + QStringLiteral("/job.json"))) loadJob(previous); - if (m_repoRoot.isEmpty()) { - setStatus(QStringLiteral("Recorder installation was not found."), true); - m_openJob->setEnabled(false); - } + setWindowTitle(QStringLiteral("Edit Path Recorder MVP")); resize(720, 500); buildUi(); restoreLastSession(); + if (m_repoRoot.isEmpty()) { m_start->setEnabled(false); setStatus(QStringLiteral("Recorder installation was not found."), true); } } protected: @@ -69,60 +68,43 @@ class RecorderWindow final : public QMainWindow private: void buildUi() { - auto *central = new QWidget; - auto *layout = new QVBoxLayout(central); - auto *title = new QLabel(QStringLiteral("

Edit Path Recorder

Open the assigned job, edit in Kdenlive, and finish the job.

")); + auto *central = new QWidget; auto *layout = new QVBoxLayout(central); + auto *title = new QLabel(QStringLiteral("

Edit Path Recorder

Start a session, make the video in Kdenlive, and finish the session.

")); title->setWordWrap(true); layout->addWidget(title); - - auto *jobRow = new QHBoxLayout; - m_openJob = new QPushButton(QStringLiteral("Open Assigned Job")); - m_jobLabel = new QLabel(QStringLiteral("No job opened")); m_jobLabel->setTextInteractionFlags(Qt::TextSelectableByMouse); - jobRow->addWidget(m_openJob); jobRow->addWidget(m_jobLabel, 1); layout->addLayout(jobRow); - - m_task = new QLabel(QStringLiteral("Task details will appear here.")); - m_task->setWordWrap(true); m_task->setStyleSheet(QStringLiteral("padding: 10px; background: #eef1f5; border-radius: 4px;")); - layout->addWidget(m_task); - - m_status = new QLabel; m_status->setWordWrap(true); layout->addWidget(m_status); - setStatus(QStringLiteral("Open an assigned job to begin.")); - + auto *instructions = new QLabel(QStringLiteral( + "You may import, download, or create media at any time while editing. When finished, save exactly one " + ".kdenlive project and one rendered video directly in the session folder, then close Kdenlive normally.")); + instructions->setWordWrap(true); layout->addWidget(instructions); + m_status = new QLabel; m_status->setWordWrap(true); layout->addWidget(m_status); setStatus(QStringLiteral("Ready to start.")); layout->addWidget(new QLabel(QStringLiteral("Session folder"))); - m_sessionLabel = new QLabel(QStringLiteral("No session created")); - m_sessionLabel->setWordWrap(true); m_sessionLabel->setTextInteractionFlags(Qt::TextSelectableByMouse); - layout->addWidget(m_sessionLabel); + m_sessionLabel = new QLabel(QStringLiteral("No session created")); m_sessionLabel->setWordWrap(true); + m_sessionLabel->setTextInteractionFlags(Qt::TextSelectableByMouse); layout->addWidget(m_sessionLabel); auto *primary = new QHBoxLayout; - m_start = new QPushButton(QStringLiteral("Start Editing Session")); - m_recover = new QPushButton(QStringLiteral("Recover and Continue")); - m_finish = new QPushButton(QStringLiteral("Finish Job")); - m_start->setMinimumHeight(42); m_recover->setMinimumHeight(42); m_finish->setMinimumHeight(42); - m_start->setEnabled(false); m_recover->setVisible(false); m_finish->setEnabled(false); + m_start = new QPushButton(QStringLiteral("Start Editing Session")); m_start->setMinimumHeight(42); + m_recover = new QPushButton(QStringLiteral("Recover and Continue")); m_recover->setMinimumHeight(42); m_recover->setVisible(false); + m_finish = new QPushButton(QStringLiteral("Finish Session")); m_finish->setMinimumHeight(42); m_finish->setEnabled(false); primary->addWidget(m_start); primary->addWidget(m_recover); primary->addWidget(m_finish); layout->addLayout(primary); - auto *secondary = new QHBoxLayout; - m_openSession = new QPushButton(QStringLiteral("Open Session Folder")); - m_openCompleted = new QPushButton(QStringLiteral("Open Completed Sample")); - m_openSession->setEnabled(false); m_openCompleted->setEnabled(false); + m_openSession = new QPushButton(QStringLiteral("Open Session Folder")); m_openSession->setEnabled(false); + m_openCompleted = new QPushButton(QStringLiteral("Open Generated Sample")); m_openCompleted->setEnabled(false); secondary->addWidget(m_openSession); secondary->addWidget(m_openCompleted); secondary->addStretch(); layout->addLayout(secondary); + m_activity = new QPlainTextEdit; m_activity->setReadOnly(true); m_activity->setMaximumBlockCount(300); layout->addWidget(m_activity, 1); + setCentralWidget(central); - m_activity = new QPlainTextEdit; m_activity->setReadOnly(true); m_activity->setMaximumBlockCount(300); - layout->addWidget(m_activity, 1); setCentralWidget(central); - - connect(m_openJob, &QPushButton::clicked, this, &RecorderWindow::chooseJob); connect(m_start, &QPushButton::clicked, this, &RecorderWindow::startNewSession); connect(m_recover, &QPushButton::clicked, this, [this] { ++m_segment; launchSegment(); }); - connect(m_finish, &QPushButton::clicked, this, &RecorderWindow::finishJob); + connect(m_finish, &QPushButton::clicked, this, &RecorderWindow::finishSession); connect(m_openSession, &QPushButton::clicked, this, [this] { QDesktopServices::openUrl(QUrl::fromLocalFile(m_session)); }); - connect(m_openCompleted, &QPushButton::clicked, this, [this] { QDesktopServices::openUrl(QUrl::fromLocalFile(m_jobRoot + QStringLiteral("/completed-sample"))); }); + connect(m_openCompleted, &QPushButton::clicked, this, [this] { QDesktopServices::openUrl(QUrl::fromLocalFile(m_session + QStringLiteral("/completed-sample"))); }); connect(&m_editor, qOverload(&QProcess::finished), this, &RecorderWindow::editorFinished); connect(&m_editor, &QProcess::started, this, [this] { - writeSessionManifest(QStringLiteral("recording")); + writeManifest(QStringLiteral("recording")); m_activity->appendPlainText(QStringLiteral("Kdenlive process started. Remote X11 startup can take 15–60 seconds.")); }); connect(&m_editor, &QProcess::errorOccurred, this, [this](QProcess::ProcessError error) { if (error == QProcess::FailedToStart) { - writeSessionManifest(QStringLiteral("start_failed")); - setStatus(QStringLiteral("Kdenlive could not be started. Check the SSH X11 connection and segment console log."), true); + writeManifest(QStringLiteral("start_failed")); setStatus(QStringLiteral("Kdenlive could not start. Check X11 and the console log."), true); m_start->setEnabled(true); } }); @@ -133,58 +115,19 @@ class RecorderWindow final : public QMainWindow void setStatus(const QString &text, bool error = false) { - m_status->setText(text); - m_status->setStyleSheet(error ? QStringLiteral("padding:10px;background:#f7dddd;color:#7d1010;border-radius:4px;") - : QStringLiteral("padding:10px;background:#e2f2e5;color:#164d24;border-radius:4px;")); - } - - void chooseJob() - { - const QString file = QFileDialog::getOpenFileName(this, QStringLiteral("Open assigned job"), {}, QStringLiteral("Assigned jobs (job.json)")); - if (!file.isEmpty()) loadJob(QFileInfo(file).absolutePath()); - } - - void loadJob(const QString &root) - { - QFile file(QDir(root).filePath(QStringLiteral("job.json"))); - if (!file.open(QIODevice::ReadOnly)) { setStatus(QStringLiteral("Could not read job.json."), true); return; } - const auto document = QJsonDocument::fromJson(file.readAll()); - const auto job = document.object(); - const auto task = job.value(QStringLiteral("task")).toObject(); - const auto project = job.value(QStringLiteral("project")).toObject(); - const auto rate = project.value(QStringLiteral("frame_rate")).toObject(); - if (job.value(QStringLiteral("schema_version")).toString() != QStringLiteral("0.1.0") - || job.value(QStringLiteral("job_id")).toString().isEmpty() || task.value(QStringLiteral("prompt")).toString().isEmpty() - || job.value(QStringLiteral("assets")).toArray().isEmpty()) { - setStatus(QStringLiteral("The selected job.json is incomplete or unsupported."), true); return; - } - QStringList clips; - for (const auto &value : job.value(QStringLiteral("assets")).toArray()) { - const QString relative = value.toObject().value(QStringLiteral("file")).toString(); - const QString absolute = QDir(root).filePath(relative); - if (!QFileInfo::exists(absolute)) { setStatus(QStringLiteral("Assigned asset is missing: %1").arg(relative), true); return; } - clips << QDir(absolute).absolutePath(); - } - m_jobRoot = QDir(root).absolutePath(); m_assetPaths = clips; - m_jobLabel->setText(QStringLiteral("%1 — %2").arg(job.value(QStringLiteral("job_id")).toString(), m_jobRoot)); - m_task->setText(QStringLiteral("Task
%1

Project: %2 × %3, %4/%5 fps   Assets: %6") - .arg(task.value(QStringLiteral("prompt")).toString().toHtmlEscaped()) - .arg(project.value(QStringLiteral("width")).toInt()).arg(project.value(QStringLiteral("height")).toInt()) - .arg(rate.value(QStringLiteral("numerator")).toInt()).arg(rate.value(QStringLiteral("denominator")).toInt()).arg(clips.size())); - m_start->setEnabled(!QDir(m_jobRoot + QStringLiteral("/completed-sample")).exists()); - m_openCompleted->setEnabled(QDir(m_jobRoot + QStringLiteral("/completed-sample")).exists()); - setStatus(QStringLiteral("Assigned job is ready.")); QSettings().setValue(QStringLiteral("lastJob"), m_jobRoot); - restoreLastSession(); + m_status->setText(text); m_status->setStyleSheet(error + ? QStringLiteral("padding:10px;background:#f7dddd;color:#7d1010;border-radius:4px;") + : QStringLiteral("padding:10px;background:#e2f2e5;color:#164d24;border-radius:4px;")); } - void writeSessionManifest(const QString &status) + void writeManifest(const QString &status) { if (m_session.isEmpty()) return; - QJsonObject manifest{{QStringLiteral("schema_version"), QStringLiteral("0.1.0")}, - {QStringLiteral("job_root"), m_jobRoot}, {QStringLiteral("session_dir"), m_session}, - {QStringLiteral("config_name"), m_configName}, {QStringLiteral("segment"), m_segment}, - {QStringLiteral("status"), status}, {QStringLiteral("kdenlive_pid"), qint64(m_editor.processId())}, - {QStringLiteral("updated_at_utc"), QDateTime::currentDateTimeUtc().toString(Qt::ISODateWithMs)}}; + QJsonObject manifest{{QStringLiteral("schema_version"), QStringLiteral("0.2.0")}, + {QStringLiteral("session_dir"), m_session}, {QStringLiteral("config_name"), m_configName}, + {QStringLiteral("segment"), m_segment}, {QStringLiteral("status"), status}, + {QStringLiteral("kdenlive_pid"), qint64(m_editor.processId())}, + {QStringLiteral("updated_at_utc"), QDateTime::currentDateTimeUtc().toString(Qt::ISODateWithMs)}}; QFile file(QDir(m_session).filePath(QStringLiteral("session.json"))); if (file.open(QIODevice::WriteOnly | QIODevice::Truncate)) file.write(QJsonDocument(manifest).toJson(QJsonDocument::Indented)); QSettings().setValue(QStringLiteral("lastSession"), m_session); @@ -196,27 +139,27 @@ class RecorderWindow final : public QMainWindow QFile file(QDir(previous).filePath(QStringLiteral("session.json"))); if (previous.isEmpty() || !file.open(QIODevice::ReadOnly)) return; const auto manifest = QJsonDocument::fromJson(file.readAll()).object(); - if (manifest.value(QStringLiteral("job_root")).toString() != m_jobRoot) return; + if (manifest.value(QStringLiteral("schema_version")).toString() != QStringLiteral("0.2.0")) return; m_session = previous; m_configName = manifest.value(QStringLiteral("config_name")).toString(); - m_segment = manifest.value(QStringLiteral("segment")).toInt(); - m_sessionLabel->setText(m_session); m_openSession->setEnabled(true); + m_segment = manifest.value(QStringLiteral("segment")).toInt(); m_sessionLabel->setText(m_session); m_openSession->setEnabled(true); const QString status = manifest.value(QStringLiteral("status")).toString(); - if (status == QStringLiteral("ready_to_finish")) { - m_finish->setEnabled(true); setStatus(QStringLiteral("The previous recording is ready to finish.")); - } else if (status == QStringLiteral("recovery_available") || status == QStringLiteral("recording")) { - m_recover->setVisible(true); setStatus(QStringLiteral("A previous session was interrupted. Verify Kdenlive is closed, then use Recover and Continue."), true); + if (status == QStringLiteral("ready_to_finish")) { m_finish->setEnabled(true); setStatus(QStringLiteral("Previous recording is ready to finish.")); } + else if (status == QStringLiteral("recovery_available") || status == QStringLiteral("recording")) { + m_recover->setVisible(true); setStatus(QStringLiteral("Previous session was interrupted. Verify Kdenlive is closed, then recover or start a fresh session."), true); + } else if (status == QStringLiteral("packaged")) { + m_openCompleted->setEnabled(true); setStatus(QStringLiteral("Previous sample was generated.")); } } void startNewSession() { const QString stamp = QDateTime::currentDateTimeUtc().toString(QStringLiteral("yyyyMMdd_HHmmss")); - m_configName = QStringLiteral("edit-path-%1rc").arg(QUuid::createUuid().toString(QUuid::WithoutBraces).left(8)); - m_session = QDir(m_jobRoot).filePath(QStringLiteral("sessions/session_%1").arg(stamp)); + const QString suffix = QUuid::createUuid().toString(QUuid::WithoutBraces).left(8); + m_session = QDir(sessionsRoot()).filePath(QStringLiteral("session_%1_%2").arg(stamp, suffix)); + m_configName = QStringLiteral("edit-path-%1rc").arg(suffix); m_segment = 1; if (!QDir().mkpath(m_session)) { setStatus(QStringLiteral("Could not create session folder."), true); return; } - m_segment = 1; m_sessionLabel->setText(m_session); m_openSession->setEnabled(true); - writeSessionManifest(QStringLiteral("created")); - launchSegment(); + m_sessionLabel->setText(m_session); m_openSession->setEnabled(true); m_openCompleted->setEnabled(false); + writeManifest(QStringLiteral("created")); launchSegment(); } void launchSegment() @@ -227,91 +170,71 @@ class RecorderWindow final : public QMainWindow const QString console = QDir(m_session).filePath(QStringLiteral("kdenlive-console-%1.log").arg(number)); QProcessEnvironment environment = QProcessEnvironment::systemEnvironment(); environment.insert(QStringLiteral("KDENLIVE_VIDEO_PATH_CONFIG"), m_configName); - if (m_segment == 1) environment.insert(QStringLiteral("KDENLIVE_VIDEO_PATH_CLIPS"), m_assetPaths.join(QLatin1Char(','))); - else environment.remove(QStringLiteral("KDENLIVE_VIDEO_PATH_CLIPS")); + environment.remove(QStringLiteral("KDENLIVE_VIDEO_PATH_CLIPS")); m_editor.setProcessEnvironment(environment); m_editor.setWorkingDirectory(m_repoRoot); m_editor.setProcessChannelMode(QProcess::MergedChannels); m_editor.setStandardOutputFile(console, QIODevice::Append); + setStatus(QStringLiteral("Kdenlive is starting. Import or create media normally, save the project and render in the session folder, then close normally.")); m_activity->appendPlainText(QStringLiteral("Starting recording segment %1…").arg(number)); - setStatus(m_segment == 1 ? QStringLiteral("Editing session is recording. Save the project and final render in the session folder.") - : QStringLiteral("Recovery segment is recording. Complete the edit and close Kdenlive normally.")); m_editor.start(m_repoRoot + QStringLiteral("/video-path-pilot/run-video-path-pilot.sh"), {raw}); } - void editorFinished(int exitCode, QProcess::ExitStatus status) + void editorFinished(int exitCode, QProcess::ExitStatus) { - m_activity->appendPlainText(QStringLiteral("Kdenlive exited with code %1; checking recording…").arg(exitCode)); - m_workerPurpose = QStringLiteral("validate-segment"); + m_activity->appendPlainText(QStringLiteral("Kdenlive exited with code %1; checking the recording…").arg(exitCode)); + m_workerPurpose = QStringLiteral("validate"); const QString raw = QDir(m_session).filePath(QStringLiteral("raw-events-%1.jsonl").arg(m_segment, 3, 10, QLatin1Char('0'))); m_worker.start(QStringLiteral("python3"), {m_repoRoot + QStringLiteral("/video-path-pilot/validate_video_path.py"), raw}); - Q_UNUSED(status) } - void finishJob() + void finishSession() { m_finish->setEnabled(false); m_workerPurpose = QStringLiteral("finalize"); - setStatus(QStringLiteral("Resolving assets, normalizing the edit path, generating sample.json, and replaying canonical state…")); + setStatus(QStringLiteral("Discovering project assets, generating sample.json, reconstructing the edit, and comparing renders…")); m_worker.start(QStringLiteral("python3"), {m_repoRoot + QStringLiteral("/video-path-pilot/job_pipeline.py"), - QStringLiteral("finalize"), m_jobRoot, m_session}); + QStringLiteral("finalize-freeform"), m_session}); } void readWorker() { const QString output = QString::fromUtf8(m_worker.readAllStandardOutput()).trimmed(); const QString errors = QString::fromUtf8(m_worker.readAllStandardError()).trimmed(); - if (!output.isEmpty()) m_activity->appendPlainText(output); - if (!errors.isEmpty()) m_activity->appendPlainText(errors); + if (!output.isEmpty()) m_activity->appendPlainText(output); if (!errors.isEmpty()) m_activity->appendPlainText(errors); } void workerFinished(int exitCode, QProcess::ExitStatus status) { readWorker(); const bool success = status == QProcess::NormalExit && exitCode == 0; - if (m_workerPurpose == QStringLiteral("validate-segment")) { + if (m_workerPurpose == QStringLiteral("validate")) { if (success) { - writeSessionManifest(QStringLiteral("ready_to_finish")); - setStatus(QStringLiteral("Recording completed. Ensure exactly one .kdenlive project and one rendered video are in the session folder, then click Finish Job.")); - m_finish->setEnabled(true); m_start->setEnabled(true); + writeManifest(QStringLiteral("ready_to_finish")); m_finish->setEnabled(true); m_start->setEnabled(true); + setStatus(QStringLiteral("Recording completed. Put exactly one .kdenlive project and one rendered video in the session folder, then click Finish Session.")); } else { - writeSessionManifest(QStringLiteral("recovery_available")); - setStatus(QStringLiteral("Kdenlive did not close cleanly. Use Recover and Continue to reopen the isolated session and Kdenlive recovery."), true); - m_recover->setVisible(true); + writeManifest(QStringLiteral("recovery_available")); m_recover->setVisible(true); + setStatus(QStringLiteral("Recording ended unexpectedly. Use Recover and Continue, or start a fresh session if no edit was made."), true); } } else if (m_workerPurpose == QStringLiteral("finalize")) { if (success) { - writeSessionManifest(QStringLiteral("packaged")); - m_openCompleted->setEnabled(true); m_start->setEnabled(false); - QFile reportFile(m_jobRoot + QStringLiteral("/completed-sample/validation/reconstruction-report.json")); - bool mediaPassed = false; - if (reportFile.open(QIODevice::ReadOnly)) { - const auto report = QJsonDocument::fromJson(reportFile.readAll()).object(); - mediaPassed = report.value(QStringLiteral("media_project_reconstruction")).toString() == QStringLiteral("passed"); - } - if (mediaPassed) { - setStatus(QStringLiteral("Sample generated. Canonical replay, reconstructed render, and media comparison passed.")); - QMessageBox::information(this, QStringLiteral("Job complete"), QStringLiteral("The sample and reconstructed render passed validation.")); - } else { - setStatus(QStringLiteral("Sample generated and canonical replay passed, but this edit uses media features the reconstruction adapter does not yet support. It is not ready for client review."), true); - QMessageBox::warning(this, QStringLiteral("Reconstruction pending"), QStringLiteral("The sample was packaged, but media reconstruction did not pass. See reconstruction-report.json.")); - } + writeManifest(QStringLiteral("packaged")); m_openCompleted->setEnabled(true); m_start->setEnabled(true); + QFile reportFile(m_session + QStringLiteral("/completed-sample/validation/reconstruction-report.json")); bool mediaPassed = false; + if (reportFile.open(QIODevice::ReadOnly)) mediaPassed = QJsonDocument::fromJson(reportFile.readAll()).object() + .value(QStringLiteral("media_project_reconstruction")).toString() == QStringLiteral("passed"); + setStatus(mediaPassed + ? QStringLiteral("Sample and reconstructed media passed. The verbal task prompt is pending internal entry before client review.") + : QStringLiteral("Sample generated, but media reconstruction is unsupported or failed. Review reconstruction-report.json."), !mediaPassed); } else { - setStatus(QStringLiteral("Job packaging failed. Review Activity, correct the project/render/assets, and try Finish Job again."), true); - m_finish->setEnabled(true); + m_finish->setEnabled(true); setStatus(QStringLiteral("Sample generation failed. Review Activity and correct the project, render, or media files."), true); } } m_workerPurpose.clear(); } - QString m_repoRoot, m_jobRoot, m_session, m_configName, m_workerPurpose; - QStringList m_assetPaths; int m_segment{0}; - QProcess m_editor, m_worker; - QLabel *m_jobLabel{}, *m_task{}, *m_status{}, *m_sessionLabel{}; - QPushButton *m_openJob{}, *m_start{}, *m_recover{}, *m_finish{}, *m_openSession{}, *m_openCompleted{}; - QPlainTextEdit *m_activity{}; + QString m_repoRoot, m_session, m_configName, m_workerPurpose; int m_segment{0}; + QProcess m_editor, m_worker; QLabel *m_status{}, *m_sessionLabel{}; + QPushButton *m_start{}, *m_recover{}, *m_finish{}, *m_openSession{}, *m_openCompleted{}; QPlainTextEdit *m_activity{}; }; int main(int argc, char **argv) { - QApplication application(argc, argv); - QCoreApplication::setOrganizationName(QStringLiteral("Parsewave")); - QCoreApplication::setApplicationName(QStringLiteral("EditPathRecorder")); - RecorderWindow window; window.show(); return application.exec(); + QApplication application(argc, argv); QCoreApplication::setOrganizationName(QStringLiteral("Parsewave")); + QCoreApplication::setApplicationName(QStringLiteral("EditPathRecorder")); RecorderWindow window; window.show(); return application.exec(); } diff --git a/video-path-pilot/job_pipeline.py b/video-path-pilot/job_pipeline.py index c41f978..e6c8801 100755 --- a/video-path-pilot/job_pipeline.py +++ b/video-path-pilot/job_pipeline.py @@ -254,6 +254,57 @@ def finalize_job(args: argparse.Namespace) -> int: return 0 +def finalize_freeform(args: argparse.Namespace) -> int: + session = args.session_dir.resolve() + if (session / "completed-sample").exists(): raise ValueError("this session already has a completed sample") + project = args.project.resolve() if args.project else discover_one(session, {".kdenlive"}, "Kdenlive project") + output = args.output.resolve() if args.output else discover_one(session, VIDEO_SUFFIXES, "rendered video") + resources, settings = project_resources(project) + unique: dict[str, Path] = {} + for resource in resources.values(): + if resource.is_file(): unique.setdefault(sha256(resource), resource) + if not unique: raise ValueError("saved project contains no resolvable media resources") + generated = session / "generated-assignment" + if generated.exists(): raise ValueError(f"generated assignment already exists: {generated}") + (generated / "assets").mkdir(parents=True) + assets = [] + for index, (digest, source) in enumerate(sorted(unique.items()), 1): + asset_id = f"asset_{index:03d}"; target = generated / "assets" / f"{asset_id}{source.suffix.lower()}" + shutil.copy2(source, target) + assets.append({"asset_id": asset_id, "file": target.relative_to(generated).as_posix(), + "original_filename": source.name, "sha256": digest, "bytes": target.stat().st_size}) + dump(generated / "job.json", {"schema_version": "0.1.0", "job_id": session.name, + "task": {"prompt": "PROMPT_PENDING_INTERNAL_ENTRY"}, "project": settings, "assets": assets}) + inner = argparse.Namespace(job_dir=generated, session_dir=session, project=project, output=output) + finalize_job(inner) + source_sample = generated / "completed-sample"; target_sample = session / "completed-sample" + shutil.move(str(source_sample), str(target_sample)) + sample_path = target_sample / "sample.json"; sample = json.loads(sample_path.read_text(encoding="utf-8")) + sample["task"] = {"prompt": None, "prompt_status": "pending_internal_entry"} + sample["quality"]["ready_for_client_review"] = False + sample["quality"]["missing_requirements"] = ["task.prompt"] + dump(sample_path, sample) + print(f"freeform sample generated: {target_sample}") + print("task prompt: pending internal entry") + return 0 + + +def attach_prompt(args: argparse.Namespace) -> int: + sample_path = args.sample_dir.resolve() / "sample.json" + if not sample_path.is_file(): raise ValueError(f"sample.json not found: {sample_path}") + sample = json.loads(sample_path.read_text(encoding="utf-8")); prompt = args.prompt.strip() + if not prompt: raise ValueError("prompt must not be empty") + sample["task"] = {"prompt": prompt, "prompt_status": "provided"} + sample["quality"]["missing_requirements"] = [] + sample["quality"]["ready_for_client_review"] = (sample["quality"].get("canonical_reconstruction") == "passed" + and sample["quality"].get("media_reconstruction") == "passed") + dump(sample_path, sample) + errors = validate_sample(sample_path, check_files=True) + if errors: raise ValueError("sample failed after prompt attachment: " + "; ".join(errors)) + print(f"prompt attached; ready for client review: {str(sample['quality']['ready_for_client_review']).lower()}") + return 0 + + def parser() -> argparse.ArgumentParser: result = argparse.ArgumentParser(description=__doc__); sub = result.add_subparsers(dest="command", required=True) create = sub.add_parser("create-job"); create.add_argument("job_dir", type=Path); create.add_argument("--job-id", required=True) @@ -263,6 +314,10 @@ def parser() -> argparse.ArgumentParser: check = sub.add_parser("validate-job"); check.add_argument("job_dir", type=Path); check.set_defaults(function=validate_job_command) finish = sub.add_parser("finalize"); finish.add_argument("job_dir", type=Path); finish.add_argument("session_dir", type=Path) finish.add_argument("--project", type=Path); finish.add_argument("--output", type=Path); finish.set_defaults(function=finalize_job) + freeform = sub.add_parser("finalize-freeform"); freeform.add_argument("session_dir", type=Path) + freeform.add_argument("--project", type=Path); freeform.add_argument("--output", type=Path); freeform.set_defaults(function=finalize_freeform) + prompt = sub.add_parser("attach-prompt"); prompt.add_argument("sample_dir", type=Path); prompt.add_argument("--prompt", required=True) + prompt.set_defaults(function=attach_prompt) return result diff --git a/video-path-pilot/sample.schema.json b/video-path-pilot/sample.schema.json index f5aa999..f96e182 100644 --- a/video-path-pilot/sample.schema.json +++ b/video-path-pilot/sample.schema.json @@ -11,7 +11,8 @@ "type": "object", "required": ["prompt"], "properties": { - "prompt": {"type": "string", "minLength": 1} + "prompt": {"type": ["string", "null"], "minLength": 1}, + "prompt_status": {"enum": ["provided", "pending_internal_entry"]} } }, "project": { diff --git a/video-path-pilot/validate_sample.py b/video-path-pilot/validate_sample.py index 202edf1..a6b2801 100755 --- a/video-path-pilot/validate_sample.py +++ b/video-path-pilot/validate_sample.py @@ -32,7 +32,11 @@ def validate_sample(path: Path, check_files: bool = False) -> list[str]: if sample.get("schema_version") != "0.1.0": errors.append("unsupported schema_version") task = sample.get("task", {}) if "editor_plan" in task: errors.append("editor intent is prohibited: remove task.editor_plan") - if not isinstance(task.get("prompt"), str) or not task["prompt"].strip(): errors.append("task.prompt must be non-empty") + prompt = task.get("prompt") + if prompt is None: + if task.get("prompt_status") != "pending_internal_entry": errors.append("missing prompt must be marked pending_internal_entry") + elif not isinstance(prompt, str) or not prompt.strip(): + errors.append("task.prompt must be non-empty or explicitly pending") rate = sample.get("project", {}).get("frame_rate", {}) if not isinstance(rate.get("numerator"), int) or rate.get("numerator", 0) <= 0: errors.append("invalid frame-rate numerator") if not isinstance(rate.get("denominator"), int) or rate.get("denominator", 0) <= 0: errors.append("invalid frame-rate denominator") From b1ba38e4e2fd1b026edf99b8497dd577e9f40c1b Mon Sep 17 00:00:00 2001 From: Tenali Rama <225068477+Tenali-Rama@users.noreply.github.com> Date: Wed, 22 Jul 2026 17:01:13 +0530 Subject: [PATCH 06/14] Rebuild recorder launcher when sources change --- video-path-pilot/run-collector-app.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/video-path-pilot/run-collector-app.sh b/video-path-pilot/run-collector-app.sh index c4b1043..b8438e4 100755 --- a/video-path-pilot/run-collector-app.sh +++ b/video-path-pilot/run-collector-app.sh @@ -9,12 +9,12 @@ craft_root=${KDENLIVE_PILOT_CRAFT_ROOT:-/home/tenali/CraftRoot} binary="$repo_root/build/collector-gui/edit-path-recorder" export PATH="$craft_root/dev-utils/bin:$craft_root/bin:$craft_root/libexec:$PATH" -if [[ ! -x $binary ]]; then +if [[ ! -f $repo_root/build/collector-gui/build.ninja ]]; then mkdir -p "$repo_root/build/collector-gui" cmake -S "$script_dir/gui" -B "$repo_root/build/collector-gui" -GNinja \ -DCMAKE_PREFIX_PATH="$craft_root" - cmake --build "$repo_root/build/collector-gui" fi +cmake --build "$repo_root/build/collector-gui" export EDIT_PATH_REPO_ROOT="$repo_root" export LD_LIBRARY_PATH="$craft_root/lib:$craft_root/usr/lib/x86_64-linux-gnu${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" From 1f5083b7298fb1987d94cb7ccbea7f37bd10342f Mon Sep 17 00:00:00 2001 From: Tenali Rama <225068477+Tenali-Rama@users.noreply.github.com> Date: Wed, 22 Jul 2026 17:11:07 +0530 Subject: [PATCH 07/14] Persist session project for crash recovery --- documentation.md | 17 +++ src/main.cpp | 26 +++- video-path-pilot/EDITOR_WORKFLOW.md | 10 +- video-path-pilot/README.md | 3 +- video-path-pilot/gui/main.cpp | 219 +++++++++++++++++++--------- 5 files changed, 194 insertions(+), 81 deletions(-) diff --git a/documentation.md b/documentation.md index 2929b75..fcf7267 100644 --- a/documentation.md +++ b/documentation.md @@ -614,6 +614,23 @@ valid `session.end`, Kdenlive logged requested close events, session manifests reached `ready_to_finish`, and no core dump existed. The recording did not crash, although remote X11 responsiveness made the shutdown appear abrupt. +The first freeform interruption audit found a different failure mode in +`session_20260722_113348_7e8d3a1e`. Kdenlive stopped without `session.end`, the +manifest remained `recording`, no core dump was registered, and the console +ended while painting the imported clip. The JSONL lines that had already been +flushed survived, but the editor had never saved the initially untitled +project, so there was no project state for **Recover and Continue** to reopen. + +Crash recovery was consequently hardened around a session-owned project. A +new session now creates and opens `edit.kdenlive` automatically. On recovery, +the launcher passes that same file back to Kdenlive and starts the next +numbered JSONL/log segment. This stable project path also enables Kdenlive's +existing autosave/backup recovery to offer recent unsaved changes after a +force-kill. The editor should save normally and must not create a second +project file in the session folder. A GUI force-kill acceptance test is still +required because the autosave prompt and restored timeline cannot be verified +headlessly. + ### Privacy and security The collector can reveal editor behavior, project structure, local file paths, diff --git a/src/main.cpp b/src/main.cpp index c285284..dce9a25 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1,6 +1,6 @@ - /* - SPDX-FileCopyrightText: 2007 Marco Gittler - SPDX-FileCopyrightText: 2008 Jean-Baptiste Mardelle +/* + SPDX-FileCopyrightText: 2007 Marco Gittler + SPDX-FileCopyrightText: 2008 Jean-Baptiste Mardelle SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL */ @@ -290,18 +290,19 @@ int main(int argc, char *argv[]) KAboutData aboutData(QByteArray("kdenlive"), i18n("Kdenlive"), KDENLIVE_VERSION, i18n("An open source video editor."), KAboutLicense::GPL_V3, i18n("Copyright © 2007–2025 Kdenlive authors"), otherText, QStringLiteral("https://kdenlive.org")); // main developers (alphabetical) - aboutData.addAuthor(i18n("Jean-Baptiste Mardelle"), i18n("Core team member, main developer and maintainer, MLT, and KDE SC 4 / KF5 port"), QStringLiteral("jb@kdenlive.org")); + aboutData.addAuthor(i18n("Jean-Baptiste Mardelle"), i18n("Core team member, main developer and maintainer, MLT, and KDE SC 4 / KF5 port"), + QStringLiteral("jb@kdenlive.org")); // active developers with major involvement aboutData.addAuthor(i18n("Julius Künzel"), i18n("Core team member, feature development, packaging, bug fixing"), QStringLiteral("julius.kuenzel@kde.org")); aboutData.addAuthor(i18n("Vincent Pinon"), i18n("KF5 port, Windows cross-build, packaging, bug fixing"), QStringLiteral("vpinon@kde.org")); // other active developers (alphabetical) - aboutData.addAuthor(i18n("Eric Jiang"), i18n("Bug fixing and test improvements"), QStringLiteral("erjiang@alumni.iu.edu")); + aboutData.addAuthor(i18n("Eric Jiang"), i18n("Bug fixing and test improvements"), QStringLiteral("erjiang@alumni.iu.edu")); // non active developers with major improvement (alphabetical) aboutData.addAuthor(i18n("Simon A. Eugster"), i18n("Color scopes, decimal separator issue, bug fixing"), QStringLiteral("simon.eu@gmail.com")); aboutData.addAuthor(i18n("Jason Wood"), i18n("Original KDE 3 version author (not active anymore)"), QStringLiteral("jasonwood@blueyonder.co.uk")); // non developers (alphabetical) aboutData.addCredit(i18n("Farid Abdelnour"), i18n("Logo, promotion, testing")); - aboutData.addCredit(i18n("balooii"), i18n("Monitor, scopes, and timeline QOL improvements")); + aboutData.addCredit(i18n("balooii"), i18n("Monitor, scopes, and timeline QOL improvements")); aboutData.addCredit(i18n("Nicolas Carion"), i18n("Code re-architecture & timeline rewrite (2019)")); aboutData.addCredit(i18n("Dan Dennedy"), i18n("MLT maintainer, Bug fixing, etc.")); aboutData.addCredit(i18n("Darby Johnston"), i18n("OTIO rewrite")); @@ -363,7 +364,8 @@ int main(int argc, char *argv[]) QCommandLineOption debugOption(QStringLiteral("debug"), i18n("Show some development specific features in the UI, disable all exclude lists for assets.")); parser.addOption(debugOption); - QCommandLineOption saveDebugOption(QStringLiteral("setup-report"), i18n("Save a json report about components in the given path."), QStringLiteral("reportFile")); + QCommandLineOption saveDebugOption(QStringLiteral("setup-report"), i18n("Save a json report about components in the given path."), + QStringLiteral("reportFile")); parser.addOption(saveDebugOption); parser.addPositionalArgument(QStringLiteral("file"), i18n("Kdenlive document to open.")); @@ -566,6 +568,16 @@ int main(int argc, char *argv[]) result = EXIT_CLEAN_RESTART; } else { pCore->initGUI(parser.value(mltPathOption), app.url, clipsToLoad); + // Recorder sessions need a real project path from the beginning. This + // gives Kdenlive's normal autosave/backup recovery a stable target even + // when the editor has not manually used Save As before a crash. + const QString recorderProject = qEnvironmentVariable("KDENLIVE_VIDEO_PATH_PROJECT"); + if (!recorderProject.isEmpty() && app.url.isEmpty() && !QFileInfo::exists(recorderProject)) { + QDir().mkpath(QFileInfo(recorderProject).absolutePath()); + if (!pCore->projectManager()->saveFileAs(recorderProject, false)) { + qWarning() << "Could not create recorder project" << recorderProject; + } + } VideoPathRecorder::instance().captureTimelineCheckpoint(QStringLiteral("gui.ready")); result = app.exec(); } diff --git a/video-path-pilot/EDITOR_WORKFLOW.md b/video-path-pilot/EDITOR_WORKFLOW.md index c497478..8994802 100644 --- a/video-path-pilot/EDITOR_WORKFLOW.md +++ b/video-path-pilot/EDITOR_WORKFLOW.md @@ -10,13 +10,15 @@ must be prepared in the app before editing. 2. Click **Start Editing Session** and wait for the blank Kdenlive window. Remote X11 startup can take 15–60 seconds. 3. Make the requested video normally. Import or create assets whenever needed. -4. Save exactly one `.kdenlive` project directly in the session folder shown by - the recorder. +4. The recorder creates `edit.kdenlive` in the session folder automatically. + Save normally while editing; do not create a second project file. 5. Render exactly one final video (`.mp4`, `.mov`, `.mkv`, or `.webm`) into that same folder. 6. Close Kdenlive normally and wait for recording validation. -7. If Kdenlive ended unexpectedly, use **Recover and Continue**. A new numbered - event segment is created without overwriting prior evidence. +7. If Kdenlive ended unexpectedly, restart the recorder and use **Recover and + Continue**. It reopens `edit.kdenlive` (and Kdenlive may offer its latest + autosave) while creating a new numbered event segment without overwriting + prior evidence. 8. Click **Finish Session**. The app discovers resources from the saved project, hashes and copies them, resolves Kdenlive IDs, generates `sample.json`, reconstructs supported edits, renders, and compares media. diff --git a/video-path-pilot/README.md b/video-path-pilot/README.md index 0eec147..f47df82 100644 --- a/video-path-pilot/README.md +++ b/video-path-pilot/README.md @@ -23,7 +23,8 @@ video-path-pilot/run-collector-app.sh Choose **Run** if the file manager asks whether to display or execute the file. The one-screen app creates a session folder, launches blank Kdenlive with an -isolated configuration, records numbered segments, offers crash recovery, +isolated configuration, creates a session-owned `edit.kdenlive`, records +numbered segments, offers crash recovery, validates termination, and packages the completed sample. There is no assigned job or initialization screen. No terminal commands are required. diff --git a/video-path-pilot/gui/main.cpp b/video-path-pilot/gui/main.cpp index 4bfea00..073a73f 100644 --- a/video-path-pilot/gui/main.cpp +++ b/video-path-pilot/gui/main.cpp @@ -28,8 +28,7 @@ namespace { QString repositoryRoot() { const QString configured = qEnvironmentVariable("EDIT_PATH_REPO_ROOT"); - if (!configured.isEmpty() && QFileInfo::exists(configured + QStringLiteral("/video-path-pilot/job_pipeline.py"))) - return QDir(configured).absolutePath(); + if (!configured.isEmpty() && QFileInfo::exists(configured + QStringLiteral("/video-path-pilot/job_pipeline.py"))) return QDir(configured).absolutePath(); QDir current(QCoreApplication::applicationDirPath()); for (int depth = 0; depth < 6; ++depth) { if (QFileInfo::exists(current.filePath(QStringLiteral("video-path-pilot/job_pipeline.py")))) return current.absolutePath(); @@ -44,15 +43,22 @@ QString sessionsRoot() if (videos.isEmpty()) videos = QDir::homePath() + QStringLiteral("/Videos"); return QDir(videos).filePath(QStringLiteral("EditPathSessions")); } -} +} // namespace class RecorderWindow final : public QMainWindow { public: - RecorderWindow() : m_repoRoot(repositoryRoot()) + RecorderWindow() + : m_repoRoot(repositoryRoot()) { - setWindowTitle(QStringLiteral("Edit Path Recorder MVP")); resize(720, 500); buildUi(); restoreLastSession(); - if (m_repoRoot.isEmpty()) { m_start->setEnabled(false); setStatus(QStringLiteral("Recorder installation was not found."), true); } + setWindowTitle(QStringLiteral("Edit Path Recorder MVP")); + resize(720, 500); + buildUi(); + restoreLastSession(); + if (m_repoRoot.isEmpty()) { + m_start->setEnabled(false); + setStatus(QStringLiteral("Recorder installation was not found."), true); + } } protected: @@ -60,7 +66,8 @@ class RecorderWindow final : public QMainWindow { if (m_editor.state() != QProcess::NotRunning || m_worker.state() != QProcess::NotRunning) { QMessageBox::warning(this, QStringLiteral("Task active"), QStringLiteral("Wait for the active task or close Kdenlive normally.")); - event->ignore(); return; + event->ignore(); + return; } event->accept(); } @@ -68,35 +75,63 @@ class RecorderWindow final : public QMainWindow private: void buildUi() { - auto *central = new QWidget; auto *layout = new QVBoxLayout(central); + auto *central = new QWidget; + auto *layout = new QVBoxLayout(central); auto *title = new QLabel(QStringLiteral("

Edit Path Recorder

Start a session, make the video in Kdenlive, and finish the session.

")); - title->setWordWrap(true); layout->addWidget(title); - auto *instructions = new QLabel(QStringLiteral( - "You may import, download, or create media at any time while editing. When finished, save exactly one " - ".kdenlive project and one rendered video directly in the session folder, then close Kdenlive normally.")); - instructions->setWordWrap(true); layout->addWidget(instructions); - m_status = new QLabel; m_status->setWordWrap(true); layout->addWidget(m_status); setStatus(QStringLiteral("Ready to start.")); + title->setWordWrap(true); + layout->addWidget(title); + auto *instructions = + new QLabel(QStringLiteral("You may import, download, or create media at any time while editing. When finished, save exactly one " + ".kdenlive project and one rendered video directly in the session folder, then close Kdenlive normally.")); + instructions->setWordWrap(true); + layout->addWidget(instructions); + m_status = new QLabel; + m_status->setWordWrap(true); + layout->addWidget(m_status); + setStatus(QStringLiteral("Ready to start.")); layout->addWidget(new QLabel(QStringLiteral("Session folder"))); - m_sessionLabel = new QLabel(QStringLiteral("No session created")); m_sessionLabel->setWordWrap(true); - m_sessionLabel->setTextInteractionFlags(Qt::TextSelectableByMouse); layout->addWidget(m_sessionLabel); + m_sessionLabel = new QLabel(QStringLiteral("No session created")); + m_sessionLabel->setWordWrap(true); + m_sessionLabel->setTextInteractionFlags(Qt::TextSelectableByMouse); + layout->addWidget(m_sessionLabel); auto *primary = new QHBoxLayout; - m_start = new QPushButton(QStringLiteral("Start Editing Session")); m_start->setMinimumHeight(42); - m_recover = new QPushButton(QStringLiteral("Recover and Continue")); m_recover->setMinimumHeight(42); m_recover->setVisible(false); - m_finish = new QPushButton(QStringLiteral("Finish Session")); m_finish->setMinimumHeight(42); m_finish->setEnabled(false); - primary->addWidget(m_start); primary->addWidget(m_recover); primary->addWidget(m_finish); layout->addLayout(primary); + m_start = new QPushButton(QStringLiteral("Start Editing Session")); + m_start->setMinimumHeight(42); + m_recover = new QPushButton(QStringLiteral("Recover and Continue")); + m_recover->setMinimumHeight(42); + m_recover->setVisible(false); + m_finish = new QPushButton(QStringLiteral("Finish Session")); + m_finish->setMinimumHeight(42); + m_finish->setEnabled(false); + primary->addWidget(m_start); + primary->addWidget(m_recover); + primary->addWidget(m_finish); + layout->addLayout(primary); auto *secondary = new QHBoxLayout; - m_openSession = new QPushButton(QStringLiteral("Open Session Folder")); m_openSession->setEnabled(false); - m_openCompleted = new QPushButton(QStringLiteral("Open Generated Sample")); m_openCompleted->setEnabled(false); - secondary->addWidget(m_openSession); secondary->addWidget(m_openCompleted); secondary->addStretch(); layout->addLayout(secondary); - m_activity = new QPlainTextEdit; m_activity->setReadOnly(true); m_activity->setMaximumBlockCount(300); layout->addWidget(m_activity, 1); + m_openSession = new QPushButton(QStringLiteral("Open Session Folder")); + m_openSession->setEnabled(false); + m_openCompleted = new QPushButton(QStringLiteral("Open Generated Sample")); + m_openCompleted->setEnabled(false); + secondary->addWidget(m_openSession); + secondary->addWidget(m_openCompleted); + secondary->addStretch(); + layout->addLayout(secondary); + m_activity = new QPlainTextEdit; + m_activity->setReadOnly(true); + m_activity->setMaximumBlockCount(300); + layout->addWidget(m_activity, 1); setCentralWidget(central); connect(m_start, &QPushButton::clicked, this, &RecorderWindow::startNewSession); - connect(m_recover, &QPushButton::clicked, this, [this] { ++m_segment; launchSegment(); }); + connect(m_recover, &QPushButton::clicked, this, [this] { + ++m_segment; + launchSegment(); + }); connect(m_finish, &QPushButton::clicked, this, &RecorderWindow::finishSession); connect(m_openSession, &QPushButton::clicked, this, [this] { QDesktopServices::openUrl(QUrl::fromLocalFile(m_session)); }); - connect(m_openCompleted, &QPushButton::clicked, this, [this] { QDesktopServices::openUrl(QUrl::fromLocalFile(m_session + QStringLiteral("/completed-sample"))); }); + connect(m_openCompleted, &QPushButton::clicked, this, + [this] { QDesktopServices::openUrl(QUrl::fromLocalFile(m_session + QStringLiteral("/completed-sample"))); }); connect(&m_editor, qOverload(&QProcess::finished), this, &RecorderWindow::editorFinished); connect(&m_editor, &QProcess::started, this, [this] { writeManifest(QStringLiteral("recording")); @@ -104,7 +139,8 @@ class RecorderWindow final : public QMainWindow }); connect(&m_editor, &QProcess::errorOccurred, this, [this](QProcess::ProcessError error) { if (error == QProcess::FailedToStart) { - writeManifest(QStringLiteral("start_failed")); setStatus(QStringLiteral("Kdenlive could not start. Check X11 and the console log."), true); + writeManifest(QStringLiteral("start_failed")); + setStatus(QStringLiteral("Kdenlive could not start. Check X11 and the console log."), true); m_start->setEnabled(true); } }); @@ -115,19 +151,21 @@ class RecorderWindow final : public QMainWindow void setStatus(const QString &text, bool error = false) { - m_status->setText(text); m_status->setStyleSheet(error - ? QStringLiteral("padding:10px;background:#f7dddd;color:#7d1010;border-radius:4px;") - : QStringLiteral("padding:10px;background:#e2f2e5;color:#164d24;border-radius:4px;")); + m_status->setText(text); + m_status->setStyleSheet(error ? QStringLiteral("padding:10px;background:#f7dddd;color:#7d1010;border-radius:4px;") + : QStringLiteral("padding:10px;background:#e2f2e5;color:#164d24;border-radius:4px;")); } void writeManifest(const QString &status) { if (m_session.isEmpty()) return; QJsonObject manifest{{QStringLiteral("schema_version"), QStringLiteral("0.2.0")}, - {QStringLiteral("session_dir"), m_session}, {QStringLiteral("config_name"), m_configName}, - {QStringLiteral("segment"), m_segment}, {QStringLiteral("status"), status}, - {QStringLiteral("kdenlive_pid"), qint64(m_editor.processId())}, - {QStringLiteral("updated_at_utc"), QDateTime::currentDateTimeUtc().toString(Qt::ISODateWithMs)}}; + {QStringLiteral("session_dir"), m_session}, + {QStringLiteral("config_name"), m_configName}, + {QStringLiteral("segment"), m_segment}, + {QStringLiteral("status"), status}, + {QStringLiteral("kdenlive_pid"), qint64(m_editor.processId())}, + {QStringLiteral("updated_at_utc"), QDateTime::currentDateTimeUtc().toString(Qt::ISODateWithMs)}}; QFile file(QDir(m_session).filePath(QStringLiteral("session.json"))); if (file.open(QIODevice::WriteOnly | QIODevice::Truncate)) file.write(QJsonDocument(manifest).toJson(QJsonDocument::Indented)); QSettings().setValue(QStringLiteral("lastSession"), m_session); @@ -140,14 +178,21 @@ class RecorderWindow final : public QMainWindow if (previous.isEmpty() || !file.open(QIODevice::ReadOnly)) return; const auto manifest = QJsonDocument::fromJson(file.readAll()).object(); if (manifest.value(QStringLiteral("schema_version")).toString() != QStringLiteral("0.2.0")) return; - m_session = previous; m_configName = manifest.value(QStringLiteral("config_name")).toString(); - m_segment = manifest.value(QStringLiteral("segment")).toInt(); m_sessionLabel->setText(m_session); m_openSession->setEnabled(true); + m_session = previous; + m_configName = manifest.value(QStringLiteral("config_name")).toString(); + m_segment = manifest.value(QStringLiteral("segment")).toInt(); + m_sessionLabel->setText(m_session); + m_openSession->setEnabled(true); const QString status = manifest.value(QStringLiteral("status")).toString(); - if (status == QStringLiteral("ready_to_finish")) { m_finish->setEnabled(true); setStatus(QStringLiteral("Previous recording is ready to finish.")); } - else if (status == QStringLiteral("recovery_available") || status == QStringLiteral("recording")) { - m_recover->setVisible(true); setStatus(QStringLiteral("Previous session was interrupted. Verify Kdenlive is closed, then recover or start a fresh session."), true); + if (status == QStringLiteral("ready_to_finish")) { + m_finish->setEnabled(true); + setStatus(QStringLiteral("Previous recording is ready to finish.")); + } else if (status == QStringLiteral("recovery_available") || status == QStringLiteral("recording")) { + m_recover->setVisible(true); + setStatus(QStringLiteral("Previous session was interrupted. Verify Kdenlive is closed, then recover or start a fresh session."), true); } else if (status == QStringLiteral("packaged")) { - m_openCompleted->setEnabled(true); setStatus(QStringLiteral("Previous sample was generated.")); + m_openCompleted->setEnabled(true); + setStatus(QStringLiteral("Previous sample was generated.")); } } @@ -156,26 +201,42 @@ class RecorderWindow final : public QMainWindow const QString stamp = QDateTime::currentDateTimeUtc().toString(QStringLiteral("yyyyMMdd_HHmmss")); const QString suffix = QUuid::createUuid().toString(QUuid::WithoutBraces).left(8); m_session = QDir(sessionsRoot()).filePath(QStringLiteral("session_%1_%2").arg(stamp, suffix)); - m_configName = QStringLiteral("edit-path-%1rc").arg(suffix); m_segment = 1; - if (!QDir().mkpath(m_session)) { setStatus(QStringLiteral("Could not create session folder."), true); return; } - m_sessionLabel->setText(m_session); m_openSession->setEnabled(true); m_openCompleted->setEnabled(false); - writeManifest(QStringLiteral("created")); launchSegment(); + m_configName = QStringLiteral("edit-path-%1rc").arg(suffix); + m_segment = 1; + if (!QDir().mkpath(m_session)) { + setStatus(QStringLiteral("Could not create session folder."), true); + return; + } + m_sessionLabel->setText(m_session); + m_openSession->setEnabled(true); + m_openCompleted->setEnabled(false); + writeManifest(QStringLiteral("created")); + launchSegment(); } void launchSegment() { - m_recover->setVisible(false); m_start->setEnabled(false); m_finish->setEnabled(false); + m_recover->setVisible(false); + m_start->setEnabled(false); + m_finish->setEnabled(false); const QString number = QStringLiteral("%1").arg(m_segment, 3, 10, QLatin1Char('0')); const QString raw = QDir(m_session).filePath(QStringLiteral("raw-events-%1.jsonl").arg(number)); const QString console = QDir(m_session).filePath(QStringLiteral("kdenlive-console-%1.log").arg(number)); + const QString project = QDir(m_session).filePath(QStringLiteral("edit.kdenlive")); QProcessEnvironment environment = QProcessEnvironment::systemEnvironment(); environment.insert(QStringLiteral("KDENLIVE_VIDEO_PATH_CONFIG"), m_configName); + environment.insert(QStringLiteral("KDENLIVE_VIDEO_PATH_PROJECT"), project); environment.remove(QStringLiteral("KDENLIVE_VIDEO_PATH_CLIPS")); - m_editor.setProcessEnvironment(environment); m_editor.setWorkingDirectory(m_repoRoot); - m_editor.setProcessChannelMode(QProcess::MergedChannels); m_editor.setStandardOutputFile(console, QIODevice::Append); - setStatus(QStringLiteral("Kdenlive is starting. Import or create media normally, save the project and render in the session folder, then close normally.")); + m_editor.setProcessEnvironment(environment); + m_editor.setWorkingDirectory(m_repoRoot); + m_editor.setProcessChannelMode(QProcess::MergedChannels); + m_editor.setStandardOutputFile(console, QIODevice::Append); + setStatus( + QStringLiteral("Kdenlive is starting. Import or create media normally, save the project and render in the session folder, then close normally.")); m_activity->appendPlainText(QStringLiteral("Starting recording segment %1…").arg(number)); - m_editor.start(m_repoRoot + QStringLiteral("/video-path-pilot/run-video-path-pilot.sh"), {raw}); + QStringList arguments{raw}; + if (QFileInfo::exists(project)) arguments.append(project); + m_editor.start(m_repoRoot + QStringLiteral("/video-path-pilot/run-video-path-pilot.sh"), arguments); } void editorFinished(int exitCode, QProcess::ExitStatus) @@ -188,53 +249,73 @@ class RecorderWindow final : public QMainWindow void finishSession() { - m_finish->setEnabled(false); m_workerPurpose = QStringLiteral("finalize"); + m_finish->setEnabled(false); + m_workerPurpose = QStringLiteral("finalize"); setStatus(QStringLiteral("Discovering project assets, generating sample.json, reconstructing the edit, and comparing renders…")); - m_worker.start(QStringLiteral("python3"), {m_repoRoot + QStringLiteral("/video-path-pilot/job_pipeline.py"), - QStringLiteral("finalize-freeform"), m_session}); + m_worker.start(QStringLiteral("python3"), + {m_repoRoot + QStringLiteral("/video-path-pilot/job_pipeline.py"), QStringLiteral("finalize-freeform"), m_session}); } void readWorker() { const QString output = QString::fromUtf8(m_worker.readAllStandardOutput()).trimmed(); const QString errors = QString::fromUtf8(m_worker.readAllStandardError()).trimmed(); - if (!output.isEmpty()) m_activity->appendPlainText(output); if (!errors.isEmpty()) m_activity->appendPlainText(errors); + if (!output.isEmpty()) m_activity->appendPlainText(output); + if (!errors.isEmpty()) m_activity->appendPlainText(errors); } void workerFinished(int exitCode, QProcess::ExitStatus status) { - readWorker(); const bool success = status == QProcess::NormalExit && exitCode == 0; + readWorker(); + const bool success = status == QProcess::NormalExit && exitCode == 0; if (m_workerPurpose == QStringLiteral("validate")) { if (success) { - writeManifest(QStringLiteral("ready_to_finish")); m_finish->setEnabled(true); m_start->setEnabled(true); - setStatus(QStringLiteral("Recording completed. Put exactly one .kdenlive project and one rendered video in the session folder, then click Finish Session.")); + writeManifest(QStringLiteral("ready_to_finish")); + m_finish->setEnabled(true); + m_start->setEnabled(true); + setStatus(QStringLiteral( + "Recording completed. Put exactly one .kdenlive project and one rendered video in the session folder, then click Finish Session.")); } else { - writeManifest(QStringLiteral("recovery_available")); m_recover->setVisible(true); + writeManifest(QStringLiteral("recovery_available")); + m_recover->setVisible(true); setStatus(QStringLiteral("Recording ended unexpectedly. Use Recover and Continue, or start a fresh session if no edit was made."), true); } } else if (m_workerPurpose == QStringLiteral("finalize")) { if (success) { - writeManifest(QStringLiteral("packaged")); m_openCompleted->setEnabled(true); m_start->setEnabled(true); - QFile reportFile(m_session + QStringLiteral("/completed-sample/validation/reconstruction-report.json")); bool mediaPassed = false; - if (reportFile.open(QIODevice::ReadOnly)) mediaPassed = QJsonDocument::fromJson(reportFile.readAll()).object() - .value(QStringLiteral("media_project_reconstruction")).toString() == QStringLiteral("passed"); + writeManifest(QStringLiteral("packaged")); + m_openCompleted->setEnabled(true); + m_start->setEnabled(true); + QFile reportFile(m_session + QStringLiteral("/completed-sample/validation/reconstruction-report.json")); + bool mediaPassed = false; + if (reportFile.open(QIODevice::ReadOnly)) + mediaPassed = QJsonDocument::fromJson(reportFile.readAll()).object().value(QStringLiteral("media_project_reconstruction")).toString() == + QStringLiteral("passed"); setStatus(mediaPassed - ? QStringLiteral("Sample and reconstructed media passed. The verbal task prompt is pending internal entry before client review.") - : QStringLiteral("Sample generated, but media reconstruction is unsupported or failed. Review reconstruction-report.json."), !mediaPassed); + ? QStringLiteral("Sample and reconstructed media passed. The verbal task prompt is pending internal entry before client review.") + : QStringLiteral("Sample generated, but media reconstruction is unsupported or failed. Review reconstruction-report.json."), + !mediaPassed); } else { - m_finish->setEnabled(true); setStatus(QStringLiteral("Sample generation failed. Review Activity and correct the project, render, or media files."), true); + m_finish->setEnabled(true); + setStatus(QStringLiteral("Sample generation failed. Review Activity and correct the project, render, or media files."), true); } } m_workerPurpose.clear(); } - QString m_repoRoot, m_session, m_configName, m_workerPurpose; int m_segment{0}; - QProcess m_editor, m_worker; QLabel *m_status{}, *m_sessionLabel{}; - QPushButton *m_start{}, *m_recover{}, *m_finish{}, *m_openSession{}, *m_openCompleted{}; QPlainTextEdit *m_activity{}; + QString m_repoRoot, m_session, m_configName, m_workerPurpose; + int m_segment{0}; + QProcess m_editor, m_worker; + QLabel *m_status{}, *m_sessionLabel{}; + QPushButton *m_start{}, *m_recover{}, *m_finish{}, *m_openSession{}, *m_openCompleted{}; + QPlainTextEdit *m_activity{}; }; int main(int argc, char **argv) { - QApplication application(argc, argv); QCoreApplication::setOrganizationName(QStringLiteral("Parsewave")); - QCoreApplication::setApplicationName(QStringLiteral("EditPathRecorder")); RecorderWindow window; window.show(); return application.exec(); + QApplication application(argc, argv); + QCoreApplication::setOrganizationName(QStringLiteral("Parsewave")); + QCoreApplication::setApplicationName(QStringLiteral("EditPathRecorder")); + RecorderWindow window; + window.show(); + return application.exec(); } From 9b1912bc278cb332efefd5b2a60dfc9d088d41c5 Mon Sep 17 00:00:00 2001 From: Tenali Rama <225068477+Tenali-Rama@users.noreply.github.com> Date: Wed, 22 Jul 2026 17:17:15 +0530 Subject: [PATCH 08/14] Defer recorder project creation until GUI startup --- documentation.md | 7 +++++++ src/main.cpp | 11 +++++++---- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/documentation.md b/documentation.md index fcf7267..9baaca3 100644 --- a/documentation.md +++ b/documentation.md @@ -631,6 +631,13 @@ project file in the session folder. A GUI force-kill acceptance test is still required because the autosave prompt and restored timeline cannot be verified headlessly. +The first GUI run of that hardening exposed an initialization-order regression: +calling Kdenlive's save path immediately after `initGUI()` but before Qt's event +loop caused the application to exit during startup. The session correctly +became `recovery_available`, but contained only `session.start` and no project. +Project creation is now deferred until the GUI event loop is active; recovery +still passes an existing `edit.kdenlive` on Kdenlive's command line. + ### Privacy and security The collector can reveal editor behavior, project structure, local file paths, diff --git a/src/main.cpp b/src/main.cpp index dce9a25..1a24e90 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -56,6 +56,7 @@ SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL #include #include #include +#include #include #include //new @@ -573,10 +574,12 @@ int main(int argc, char *argv[]) // when the editor has not manually used Save As before a crash. const QString recorderProject = qEnvironmentVariable("KDENLIVE_VIDEO_PATH_PROJECT"); if (!recorderProject.isEmpty() && app.url.isEmpty() && !QFileInfo::exists(recorderProject)) { - QDir().mkpath(QFileInfo(recorderProject).absolutePath()); - if (!pCore->projectManager()->saveFileAs(recorderProject, false)) { - qWarning() << "Could not create recorder project" << recorderProject; - } + QTimer::singleShot(1000, &app, [recorderProject]() { + QDir().mkpath(QFileInfo(recorderProject).absolutePath()); + if (!pCore->projectManager()->saveFileAs(recorderProject, false)) { + qWarning() << "Could not create recorder project" << recorderProject; + } + }); } VideoPathRecorder::instance().captureTimelineCheckpoint(QStringLiteral("gui.ready")); result = app.exec(); From 16ea006abcafaf3c142a9bcac8a8f8b53b4197dd Mon Sep 17 00:00:00 2001 From: Tenali Rama <225068477+Tenali-Rama@users.noreply.github.com> Date: Wed, 22 Jul 2026 17:24:25 +0530 Subject: [PATCH 09/14] Launch Kdenlive before showing session controls --- documentation.md | 9 ++++ video-path-pilot/EDITOR_WORKFLOW.md | 9 ++-- video-path-pilot/README.md | 11 ++--- video-path-pilot/gui/main.cpp | 64 ++++++++++++++++++++--------- 4 files changed, 65 insertions(+), 28 deletions(-) diff --git a/documentation.md b/documentation.md index 9baaca3..ff1616a 100644 --- a/documentation.md +++ b/documentation.md @@ -638,6 +638,15 @@ became `recovery_available`, but contained only `session.start` and no project. Project creation is now deferred until the GUI event loop is active; recovery still passes an existing `edit.kdenlive` on Kdenlive's command line. +The editor-facing lifecycle was then simplified after the recorder wrapper +itself rendered black and became unresponsive over remote X11 before any new +session was created. The recorder is now a hidden supervisor during editing. +Launching the product opens Kdenlive directly and creates a session +automatically; only after Kdenlive exits does the supervisor show completion, +packaging, or recovery controls. A prior interrupted session is resumed +automatically only when its session-owned `edit.kdenlive` exists. This removes +the redundant initialization screen from the normal editor workflow. + ### Privacy and security The collector can reveal editor behavior, project structure, local file paths, diff --git a/video-path-pilot/EDITOR_WORKFLOW.md b/video-path-pilot/EDITOR_WORKFLOW.md index 8994802..f5f5ebb 100644 --- a/video-path-pilot/EDITOR_WORKFLOW.md +++ b/video-path-pilot/EDITOR_WORKFLOW.md @@ -6,15 +6,16 @@ The editor may find, download, generate, or import media at any point. Nothing must be prepared in the app before editing. -1. Double-click `run-collector-app.sh` and choose **Run**. -2. Click **Start Editing Session** and wait for the blank Kdenlive window. - Remote X11 startup can take 15–60 seconds. +1. Double-click `run-collector-app.sh` and choose **Run**. The supervisor stays + hidden and Kdenlive opens directly; remote X11 startup can take 15–60 seconds. +2. There is no initialization screen or Start button before editing. 3. Make the requested video normally. Import or create assets whenever needed. 4. The recorder creates `edit.kdenlive` in the session folder automatically. Save normally while editing; do not create a second project file. 5. Render exactly one final video (`.mp4`, `.mov`, `.mkv`, or `.webm`) into that same folder. -6. Close Kdenlive normally and wait for recording validation. +6. Close Kdenlive normally. The completion screen then appears and validates + the recording. 7. If Kdenlive ended unexpectedly, restart the recorder and use **Recover and Continue**. It reopens `edit.kdenlive` (and Kdenlive may offer its latest autosave) while creating a new numbered event segment without overwriting diff --git a/video-path-pilot/README.md b/video-path-pilot/README.md index f47df82..5b15cc7 100644 --- a/video-path-pilot/README.md +++ b/video-path-pilot/README.md @@ -22,11 +22,12 @@ video-path-pilot/run-collector-app.sh ``` Choose **Run** if the file manager asks whether to display or execute the file. -The one-screen app creates a session folder, launches blank Kdenlive with an -isolated configuration, creates a session-owned `edit.kdenlive`, records -numbered segments, offers crash recovery, -validates termination, and packages the completed sample. There is no assigned -job or initialization screen. No terminal commands are required. +The app starts as a hidden supervisor: it creates a session folder and launches +blank Kdenlive directly with an isolated configuration. During editing it +creates a session-owned `edit.kdenlive` and records numbered segments. After +Kdenlive closes, the supervisor shows a completion or recovery screen that +validates termination and packages the completed sample. There is no assigned +job, initialization screen, or terminal workflow. Canonical state replay must reproduce every recorded state hash. A first MLT media adapter reconstructs cut/trim/move edits with normal-speed clips and no diff --git a/video-path-pilot/gui/main.cpp b/video-path-pilot/gui/main.cpp index 073a73f..4770302 100644 --- a/video-path-pilot/gui/main.cpp +++ b/video-path-pilot/gui/main.cpp @@ -20,6 +20,7 @@ #include #include #include +#include #include #include #include @@ -56,8 +57,19 @@ class RecorderWindow final : public QMainWindow buildUi(); restoreLastSession(); if (m_repoRoot.isEmpty()) { - m_start->setEnabled(false); setStatus(QStringLiteral("Recorder installation was not found."), true); + show(); + } else { + QTimer::singleShot(0, this, [this] { + if (m_showExistingCompletion) { + showCompletionWindow(); + } else if (m_autoRecover) { + ++m_segment; + launchSegment(); + } else { + startNewSession(); + } + }); } } @@ -77,18 +89,17 @@ class RecorderWindow final : public QMainWindow { auto *central = new QWidget; auto *layout = new QVBoxLayout(central); - auto *title = new QLabel(QStringLiteral("

Edit Path Recorder

Start a session, make the video in Kdenlive, and finish the session.

")); - title->setWordWrap(true); - layout->addWidget(title); - auto *instructions = - new QLabel(QStringLiteral("You may import, download, or create media at any time while editing. When finished, save exactly one " - ".kdenlive project and one rendered video directly in the session folder, then close Kdenlive normally.")); - instructions->setWordWrap(true); - layout->addWidget(instructions); + m_title = new QLabel(QStringLiteral("

Editing Session

Kdenlive has closed. Review the session status below.

")); + m_title->setWordWrap(true); + layout->addWidget(m_title); + m_instructions = new QLabel(QStringLiteral( + "Before finishing, ensure the final rendered video is in the session folder. The project is saved automatically as edit.kdenlive.")); + m_instructions->setWordWrap(true); + layout->addWidget(m_instructions); m_status = new QLabel; m_status->setWordWrap(true); layout->addWidget(m_status); - setStatus(QStringLiteral("Ready to start.")); + setStatus(QStringLiteral("Checking the editing session…")); layout->addWidget(new QLabel(QStringLiteral("Session folder"))); m_sessionLabel = new QLabel(QStringLiteral("No session created")); m_sessionLabel->setWordWrap(true); @@ -96,8 +107,9 @@ class RecorderWindow final : public QMainWindow layout->addWidget(m_sessionLabel); auto *primary = new QHBoxLayout; - m_start = new QPushButton(QStringLiteral("Start Editing Session")); + m_start = new QPushButton(QStringLiteral("Start Another Editing Session")); m_start->setMinimumHeight(42); + m_start->setVisible(false); m_recover = new QPushButton(QStringLiteral("Recover and Continue")); m_recover->setMinimumHeight(42); m_recover->setVisible(false); @@ -141,7 +153,8 @@ class RecorderWindow final : public QMainWindow if (error == QProcess::FailedToStart) { writeManifest(QStringLiteral("start_failed")); setStatus(QStringLiteral("Kdenlive could not start. Check X11 and the console log."), true); - m_start->setEnabled(true); + m_start->setVisible(true); + showCompletionWindow(); } }); connect(&m_worker, &QProcess::readyReadStandardOutput, this, &RecorderWindow::readWorker); @@ -187,12 +200,15 @@ class RecorderWindow final : public QMainWindow if (status == QStringLiteral("ready_to_finish")) { m_finish->setEnabled(true); setStatus(QStringLiteral("Previous recording is ready to finish.")); + m_showExistingCompletion = true; } else if (status == QStringLiteral("recovery_available") || status == QStringLiteral("recording")) { - m_recover->setVisible(true); - setStatus(QStringLiteral("Previous session was interrupted. Verify Kdenlive is closed, then recover or start a fresh session."), true); + const QString project = QDir(previous).filePath(QStringLiteral("edit.kdenlive")); + m_autoRecover = QFileInfo::exists(project); } else if (status == QStringLiteral("packaged")) { m_openCompleted->setEnabled(true); setStatus(QStringLiteral("Previous sample was generated.")); + m_start->setVisible(true); + m_showExistingCompletion = true; } } @@ -216,8 +232,9 @@ class RecorderWindow final : public QMainWindow void launchSegment() { + hide(); m_recover->setVisible(false); - m_start->setEnabled(false); + m_start->setVisible(false); m_finish->setEnabled(false); const QString number = QStringLiteral("%1").arg(m_segment, 3, 10, QLatin1Char('0')); const QString raw = QDir(m_session).filePath(QStringLiteral("raw-events-%1.jsonl").arg(number)); @@ -241,6 +258,7 @@ class RecorderWindow final : public QMainWindow void editorFinished(int exitCode, QProcess::ExitStatus) { + showCompletionWindow(); m_activity->appendPlainText(QStringLiteral("Kdenlive exited with code %1; checking the recording…").arg(exitCode)); m_workerPurpose = QStringLiteral("validate"); const QString raw = QDir(m_session).filePath(QStringLiteral("raw-events-%1.jsonl").arg(m_segment, 3, 10, QLatin1Char('0'))); @@ -272,19 +290,20 @@ class RecorderWindow final : public QMainWindow if (success) { writeManifest(QStringLiteral("ready_to_finish")); m_finish->setEnabled(true); - m_start->setEnabled(true); + m_start->setVisible(true); setStatus(QStringLiteral( "Recording completed. Put exactly one .kdenlive project and one rendered video in the session folder, then click Finish Session.")); } else { writeManifest(QStringLiteral("recovery_available")); m_recover->setVisible(true); + m_start->setVisible(true); setStatus(QStringLiteral("Recording ended unexpectedly. Use Recover and Continue, or start a fresh session if no edit was made."), true); } } else if (m_workerPurpose == QStringLiteral("finalize")) { if (success) { writeManifest(QStringLiteral("packaged")); m_openCompleted->setEnabled(true); - m_start->setEnabled(true); + m_start->setVisible(true); QFile reportFile(m_session + QStringLiteral("/completed-sample/validation/reconstruction-report.json")); bool mediaPassed = false; if (reportFile.open(QIODevice::ReadOnly)) @@ -302,10 +321,18 @@ class RecorderWindow final : public QMainWindow m_workerPurpose.clear(); } + void showCompletionWindow() + { + show(); + raise(); + activateWindow(); + } + QString m_repoRoot, m_session, m_configName, m_workerPurpose; int m_segment{0}; QProcess m_editor, m_worker; - QLabel *m_status{}, *m_sessionLabel{}; + bool m_autoRecover{false}, m_showExistingCompletion{false}; + QLabel *m_title{}, *m_instructions{}, *m_status{}, *m_sessionLabel{}; QPushButton *m_start{}, *m_recover{}, *m_finish{}, *m_openSession{}, *m_openCompleted{}; QPlainTextEdit *m_activity{}; }; @@ -316,6 +343,5 @@ int main(int argc, char **argv) QCoreApplication::setOrganizationName(QStringLiteral("Parsewave")); QCoreApplication::setApplicationName(QStringLiteral("EditPathRecorder")); RecorderWindow window; - window.show(); return application.exec(); } From 0b1a4b4e6871afd85dadd1be2b055b3df3e4d065 Mon Sep 17 00:00:00 2001 From: Tenali Rama <225068477+Tenali-Rama@users.noreply.github.com> Date: Wed, 22 Jul 2026 17:27:46 +0530 Subject: [PATCH 10/14] Preserve undo and redo in normalized samples --- documentation.md | 6 ++++-- video-path-pilot/VOCABULARY.md | 9 +++++---- video-path-pilot/normalize_sample.py | 24 +++++++++++++++++------- video-path-pilot/tests/test_mvp.py | 16 ++++++++++++++-- 4 files changed, 40 insertions(+), 15 deletions(-) diff --git a/documentation.md b/documentation.md index ff1616a..483fc32 100644 --- a/documentation.md +++ b/documentation.md @@ -460,8 +460,10 @@ normalizer improvements. There is no `initial.kdenlive`: editors begin from a blank project and the recorder establishes the canonical baseline. The final video is mandatory because it is the target artifact and enables human review. -`normalize_sample.py` removes undone or abandoned commands from the clean path -while retaining them in raw evidence. Accepted operations use integer frames, +`normalize_sample.py` preserves undo and redo chronologically in the sample as +`history.undo` and `history.redo`, including their reverse/restored changes and +resulting hashes. A final-branch-only view can be derived later if required; +the MVP favors retaining more training information. Operations use integer frames, software-independent entity names, sample-local canonical IDs, before/after changes, resulting state hashes, and pointers to raw events. Kdenlive labels are isolated under `extensions.kdenlive`. Ambiguous outcomes deliberately use diff --git a/video-path-pilot/VOCABULARY.md b/video-path-pilot/VOCABULARY.md index 4eff553..eb3469d 100644 --- a/video-path-pilot/VOCABULARY.md +++ b/video-path-pilot/VOCABULARY.md @@ -37,7 +37,8 @@ UUIDs or project-file resolution before scaled collection. ## Undo and redo -Undo/redo and abandoned edits remain in `evidence/raw-events.jsonl`. The clean -`edit_path.operations` contains only the final accepted branch. This teaches -the intended edit rather than editor correction behavior while preserving the -evidence needed to audit normalization. +Undo and redo are preserved chronologically in `edit_path.operations` as +`history.undo` and `history.redo`. Each contains the actual reverse or restored +state change and resulting state hash. Raw UI/history evidence also remains in +`evidence/raw-events*.jsonl`. The MVP intentionally retains more information; +a final-branch-only view can be derived later without recollecting data. diff --git a/video-path-pilot/normalize_sample.py b/video-path-pilot/normalize_sample.py index cf5e418..cce485c 100755 --- a/video-path-pilot/normalize_sample.py +++ b/video-path-pilot/normalize_sample.py @@ -20,7 +20,7 @@ def read_jsonl(path: Path) -> list[dict]: def accepted_commits(events: list[dict]) -> list[dict]: - """Return the final successful branch; raw undo/redo remains in evidence.""" + """Return the final branch for legacy reconstruction helpers.""" stack: list[dict] = [] redo: list[dict] = [] for event in events: @@ -65,6 +65,16 @@ def operation_name(diff: dict) -> str: return "timeline.change" +def event_operation_name(event: dict) -> str: + """Name every state-changing event without discarding editing history.""" + boundary = event.get("boundary") + if boundary == "undo": + return "history.undo" + if boundary == "redo": + return "history.redo" + return operation_name(event.get("diff", {})) + + def normalized_change(change: dict, ids: dict[tuple[str, str], str], assets: dict[str, str]) -> dict: entity = str(change.get("entity")) native = str(change.get("native_id")) @@ -125,23 +135,23 @@ def build_sample(root: Path, metadata: dict) -> dict: ids: dict[tuple[str, str], str] = {} asset_refs: dict[str, str] = dict(metadata.get("native_asset_bindings", {})) operations = [] - commits = [event for events in event_groups for event in accepted_commits(events)] - for index, event in enumerate(commits, 1): + timeline_events = [event for events in event_groups for event in events if event.get("event_type") == "state.diff"] + for index, event in enumerate(timeline_events, 1): diff = event.get("diff", {}) operations.append({ "operation_id": f"op_{index:04d}", - "operation": operation_name(diff), + "operation": event_operation_name(event), "changes": [normalized_change(change, ids, asset_refs) for change in diff.get("changes", [])], "resulting_state_hash": event.get("after_hash"), "evidence": {"raw_event_id": event.get("event_id"), "raw_sequence": event.get("sequence")}, - "extensions": {"kdenlive": {"command_label": event.get("label")}}, + "extensions": {"kdenlive": {"command_label": event.get("label"), "boundary": event.get("boundary")}}, }) first_checkpoint = next((event for event in event_groups[0] if event.get("event_type") == "state.checkpoint"), None) if not first_checkpoint: raise ValueError("recording has no canonical checkpoint") initial_native = copy.deepcopy(first_checkpoint["snapshot"]) final_native = copy.deepcopy(initial_native) - for event in commits: + for event in timeline_events: apply_native_diff(final_native, event.get("diff", {})) initial_state = normalized_state(initial_native, ids, asset_refs) final_state = normalized_state(final_native, ids, asset_refs) @@ -159,7 +169,7 @@ def build_sample(root: Path, metadata: dict) -> dict: "output": {"video": metadata["artifacts"]["final_video"], "sha256": metadata["artifacts"]["final_video_sha256"]}, "quality": { "raw_session_complete": True, - "undo_redo_removed_from_edit_path": True, + "undo_redo_preserved_in_edit_path": True, "asset_binding_method": metadata["asset_binding_method"], "unresolved_asset_ids": unresolved, "review_status": "needs_human_review", diff --git a/video-path-pilot/tests/test_mvp.py b/video-path-pilot/tests/test_mvp.py index 42878a6..1f47567 100644 --- a/video-path-pilot/tests/test_mvp.py +++ b/video-path-pilot/tests/test_mvp.py @@ -17,7 +17,7 @@ class MvpTests(unittest.TestCase): - def test_undo_is_removed_and_redo_is_restored(self): + def test_legacy_final_branch_helper(self): a = {"event_type": "state.diff", "boundary": "commit", "event_id": "a"} b = {"event_type": "state.diff", "boundary": "commit", "event_id": "b"} undo = {"event_type": "state.diff", "boundary": "undo"} @@ -42,6 +42,16 @@ def test_build_and_validate_sample(self): "diff": {"changes": [{"entity": "clip", "native_id": 8, "change": "added", "after": {"asset_reference": "4", "track_native_id": 3, "timeline_start_frame": 0, "duration_frames": 25}}]}, }, {"event_type": "session.end", "sequence": 4}] + added = events[2]["diff"]["changes"][0]["after"] + events[3:3] = [{ + "event_type": "state.diff", "boundary": "undo", "sequence": 4, + "event_id": "raw-3", "label": "Undo Insert Clip", "after_hash": "c" * 64, + "diff": {"changes": [{"entity": "clip", "native_id": 8, "change": "removed", "before": added}]}, + }, { + "event_type": "state.diff", "boundary": "redo", "sequence": 5, + "event_id": "raw-4", "label": "Redo Insert Clip", "after_hash": HASH_B, + "diff": {"changes": [{"entity": "clip", "native_id": 8, "change": "added", "after": added}]}, + }] raw = root / "evidence/raw-events.jsonl" raw.write_text("".join(json.dumps(e) + "\n" for e in events)) sha = lambda path: hashlib.sha256(path.read_bytes()).hexdigest() @@ -57,7 +67,9 @@ def test_build_and_validate_sample(self): "native_project": "internal/final.kdenlive", "native_project_sha256": sha(root / "internal/final.kdenlive"), "raw_events": [{"file": "evidence/raw-events.jsonl", "sha256": sha(raw)}]}} sample = build_sample(root, metadata) - self.assertEqual(sample["edit_path"]["operations"][0]["operation"], "clip.insert") + self.assertEqual([operation["operation"] for operation in sample["edit_path"]["operations"]], + ["clip.insert", "history.undo", "history.redo"]) + self.assertTrue(sample["quality"]["undo_redo_preserved_in_edit_path"]) self.assertNotIn("rationale", sample) self.assertNotIn("editor_plan", sample["task"]) path = root / "sample.json" From 45f4f0733e684c1579d3f214b6d0a1846ad583a7 Mon Sep 17 00:00:00 2001 From: Tenali Rama <225068477+Tenali-Rama@users.noreply.github.com> Date: Wed, 22 Jul 2026 17:46:27 +0530 Subject: [PATCH 11/14] Add portable Windows MVP build --- .github/workflows/windows-portable.yml | 106 +++++++++++++++++++++++ CMakeLists.txt | 14 ++- documentation.md | 18 ++++ video-path-pilot/README.md | 14 +++ video-path-pilot/gui/CMakeLists.txt | 5 +- video-path-pilot/gui/main.cpp | 30 +++++-- video-path-pilot/run-collector-app.sh | 2 +- video-path-pilot/run-video-path-pilot.sh | 7 +- 8 files changed, 186 insertions(+), 10 deletions(-) create mode 100644 .github/workflows/windows-portable.yml diff --git a/.github/workflows/windows-portable.yml b/.github/workflows/windows-portable.yml new file mode 100644 index 0000000..992a3cb --- /dev/null +++ b/.github/workflows/windows-portable.yml @@ -0,0 +1,106 @@ +name: Windows portable MVP + +on: + workflow_dispatch: + +permissions: + contents: read + +jobs: + build-windows-portable: + runs-on: windows-2022 + timeout-minutes: 360 + + steps: + - name: Check out the instrumented Kdenlive fork + uses: actions/checkout@v4 + + - name: Bootstrap KDE Craft + shell: pwsh + run: | + $ErrorActionPreference = "Stop" + Invoke-WebRequest ` + https://raw.githubusercontent.com/KDE/craft/master/setup/install_craft.ps1 ` + -OutFile "$env:RUNNER_TEMP\install_craft.ps1" + & "$env:RUNNER_TEMP\install_craft.ps1" ` + -root C:\CraftRoot ` + -python (Get-Command python.exe).Source ` + -use-defaults + + - name: Allow the EditPath supervisor in the Kdenlive package + shell: pwsh + run: | + $ErrorActionPreference = "Stop" + $blueprint = Get-ChildItem C:\CraftRoot -Recurse -Filter kdenlive.py | + Where-Object { $_.FullName -match 'craft-blueprints-kde.*kdenlive' } | + Select-Object -First 1 + if (-not $blueprint) { throw "Kdenlive Craft blueprint was not found" } + $text = Get-Content $blueprint.FullName -Raw + $text = $text.Replace('bin/(?!(ff|kdenlive|kioworker|melt|update-mime-database|snoretoast|drmingw|data/kdenlive)).*', + 'bin/(?!(ff|kdenlive|EditPath|kioworker|melt|update-mime-database|snoretoast|drmingw|data/kdenlive)).*') + Set-Content $blueprint.FullName $text -Encoding UTF8 + + - name: Build this checkout with Craft + shell: pwsh + run: | + $ErrorActionPreference = "Stop" + . C:\CraftRoot\craft\craftenv.ps1 + craft --ci-mode --src-dir "$env:GITHUB_WORKSPACE" kde/kdemultimedia/kdenlive + + - name: Create the upstream portable package + shell: pwsh + run: | + $ErrorActionPreference = "Stop" + $settings = 'C:\CraftRoot\etc\CraftSettings.ini' + $text = Get-Content $settings -Raw + $text = $text.Replace('#PackageType = SevenZipPackager', 'PackageType = SevenZipPackager') + Set-Content $settings $text -Encoding UTF8 + . C:\CraftRoot\craft\craftenv.ps1 + craft --ci-mode --src-dir "$env:GITHUB_WORKSPACE" --package kde/kdemultimedia/kdenlive + + - name: Assemble the EditPath portable folder + shell: pwsh + run: | + $ErrorActionPreference = "Stop" + $archive = Get-ChildItem C:\CraftRoot -Recurse -File -Filter '*kdenlive*.7z' | + Sort-Object LastWriteTimeUtc -Descending | + Select-Object -First 1 + if (-not $archive) { throw "Craft did not produce a Kdenlive 7z package" } + + $portable = Join-Path $env:RUNNER_TEMP 'EditPath-Windows-x64' + New-Item -ItemType Directory -Force $portable | Out-Null + & 7z x $archive.FullName "-o$portable" -y + if ($LASTEXITCODE -ne 0) { throw "Could not extract the Craft package" } + + $editPath = Get-ChildItem $portable -Recurse -File -Filter EditPath.exe | Select-Object -First 1 + $kdenlive = Get-ChildItem $portable -Recurse -File -Filter kdenlive.exe | Select-Object -First 1 + if (-not $editPath) { throw "EditPath.exe is missing from the portable package" } + if (-not $kdenlive) { throw "kdenlive.exe is missing from the portable package" } + + $bin = $editPath.Directory.FullName + $pythonZip = Join-Path $env:RUNNER_TEMP 'python-embed.zip' + Invoke-WebRequest ` + https://www.python.org/ftp/python/3.11.9/python-3.11.9-embed-amd64.zip ` + -OutFile $pythonZip + $pythonDir = Join-Path $bin 'python' + Expand-Archive $pythonZip $pythonDir -Force + Remove-Item (Join-Path $pythonDir 'python311._pth') -ErrorAction SilentlyContinue + + @" + EditPath portable MVP + + Start: bin\EditPath.exe + Do not start bin\kdenlive.exe directly; doing so bypasses recording. + Sessions are stored in the current user's Videos\EditPathSessions folder. + "@ | Set-Content (Join-Path $portable 'START-HERE.txt') -Encoding UTF8 + + $output = Join-Path $env:GITHUB_WORKSPACE 'EditPath-Windows-x64.zip' + Compress-Archive -Path (Join-Path $portable '*') -DestinationPath $output -CompressionLevel Optimal + + - name: Upload portable Windows MVP + uses: actions/upload-artifact@v4 + with: + name: EditPath-Windows-x64 + path: EditPath-Windows-x64.zip + if-no-files-found: error + retention-days: 14 diff --git a/CMakeLists.txt b/CMakeLists.txt index 9cb6f07..e9f160d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -24,6 +24,7 @@ option(BUILD_TESTING "Build tests" ON) option(CRASH_AUTO_TEST "Auto-generate testcases upon some crashes (uses RTTR library, needed for fuzzing)" OFF) option(BUILD_FUZZING "Build fuzzing target" OFF) option(BUILD_QCH "Build source code documentation in QCH format (for e.g. Qt Assistant, Qt Creator & KDevelop)" OFF) +option(BUILD_EDIT_PATH_RECORDER "Build the Edit Path recorder supervisor" ON) add_feature_info(QCH ${BUILD_QCH} "Source code documentation in QCH format (for e.g. Qt Assistant, Qt Creator & KDevelop)") # shall we use DBus? @@ -232,6 +233,18 @@ add_subdirectory(src) add_subdirectory(renderer) add_subdirectory(thumbnailer) add_subdirectory(data) +if(BUILD_EDIT_PATH_RECORDER) + add_subdirectory(video-path-pilot/gui) + install(FILES + video-path-pilot/job_pipeline.py + video-path-pilot/media_reconstruct.py + video-path-pilot/normalize_sample.py + video-path-pilot/validate_sample.py + video-path-pilot/validate_video_path.py + video-path-pilot/sample.schema.json + video-path-pilot/video-path.schema.json + DESTINATION ${KDE_INSTALL_BINDIR}/video-path-pilot) +endif() # Install ki18n_install(po) @@ -271,4 +284,3 @@ feature_summary(WHAT ALL FATAL_ON_MISSING_REQUIRED_PACKAGES) # pre-commit hook include(KDEGitCommitHooks) kde_configure_git_pre_commit_hook(CHECKS CLANG_FORMAT) - diff --git a/documentation.md b/documentation.md index 483fc32..22a530c 100644 --- a/documentation.md +++ b/documentation.md @@ -649,6 +649,24 @@ packaging, or recovery controls. A prior interrupted session is resumed automatically only when its session-owned `edit.kdenlive` exists. This removes the redundant initialization screen from the normal editor workflow. +### Windows portable build + +Windows is the editor deployment target. The supervisor now has a native +Windows launch path: it starts the adjacent `kdenlive.exe` directly with the +isolated recorder configuration and JSONL environment instead of invoking a +Bash script. Validation and finalization use `bin/python/python.exe`, an +embedded standard-library Python runtime included in the portable package. +Linux retains its development shell launcher; its recovery argument handling +was corrected to accept and reopen an existing project. + +The manually triggered `.github/workflows/windows-portable.yml` workflow +bootstraps KDE Craft on a Windows 2022 runner, compiles this checkout through +the maintained Qt 6 Kdenlive blueprint, creates the dependency-complete Craft +archive, injects embedded Python, verifies `EditPath.exe` and `kdenlive.exe`, +and uploads `EditPath-Windows-x64.zip`. The first artifact is intentionally a +portable, unsigned engineering build. Installer creation, code signing, and +update delivery follow only after functional testing on the editor's machine. + ### Privacy and security The collector can reveal editor behavior, project structure, local file paths, diff --git a/video-path-pilot/README.md b/video-path-pilot/README.md index 5b15cc7..bfda96c 100644 --- a/video-path-pilot/README.md +++ b/video-path-pilot/README.md @@ -29,6 +29,20 @@ Kdenlive closes, the supervisor shows a completion or recovery screen that validates termination and packages the completed sample. There is no assigned job, initialization screen, or terminal workflow. +### Windows portable MVP + +The editor deliverable is built by the manually triggered **Windows portable +MVP** GitHub Actions workflow. It uses the maintained KDE Craft Kdenlive +blueprint to compile this checkout and its dependencies for 64-bit Windows, +then adds an embedded Python runtime for local validation and sample packaging. +The uploaded artifact is `EditPath-Windows-x64.zip`. + +After extracting the archive, start `bin\\EditPath.exe`. Do not start +`bin\\kdenlive.exe` directly because that bypasses session supervision and +recording. The initial MVP artifact is unsigned, so Windows may display a +SmartScreen warning. Code signing and an installer are later distribution +steps; the portable package is the first functional test target. + Canonical state replay must reproduce every recorded state hash. A first MLT media adapter reconstructs cut/trim/move edits with normal-speed clips and no effects/transitions, renders `reconstructed.mp4`, and compares resolution, diff --git a/video-path-pilot/gui/CMakeLists.txt b/video-path-pilot/gui/CMakeLists.txt index 5812038..7eeb121 100644 --- a/video-path-pilot/gui/CMakeLists.txt +++ b/video-path-pilot/gui/CMakeLists.txt @@ -2,12 +2,15 @@ # SPDX-License-Identifier: GPL-3.0-only cmake_minimum_required(VERSION 3.20) -project(EditPathRecorder VERSION 0.1.0 LANGUAGES CXX) +if(CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR) + project(EditPathRecorder VERSION 0.1.0 LANGUAGES CXX) +endif() set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) find_package(Qt6 6.5 REQUIRED COMPONENTS Core Gui Widgets) qt_add_executable(edit-path-recorder main.cpp) +set_target_properties(edit-path-recorder PROPERTIES OUTPUT_NAME EditPath) target_link_libraries(edit-path-recorder PRIVATE Qt6::Core Qt6::Gui Qt6::Widgets) install(TARGETS edit-path-recorder RUNTIME DESTINATION bin) diff --git a/video-path-pilot/gui/main.cpp b/video-path-pilot/gui/main.cpp index 4770302..ba667c8 100644 --- a/video-path-pilot/gui/main.cpp +++ b/video-path-pilot/gui/main.cpp @@ -38,6 +38,17 @@ QString repositoryRoot() return {}; } +QString pythonExecutable() +{ +#ifdef Q_OS_WIN + const QString bundled = QDir(QCoreApplication::applicationDirPath()).filePath(QStringLiteral("python/python.exe")); + if (QFileInfo::exists(bundled)) return bundled; + return QStringLiteral("python.exe"); +#else + return QStringLiteral("python3"); +#endif +} + QString sessionsRoot() { QString videos = QStandardPaths::writableLocation(QStandardPaths::MoviesLocation); @@ -243,6 +254,7 @@ class RecorderWindow final : public QMainWindow QProcessEnvironment environment = QProcessEnvironment::systemEnvironment(); environment.insert(QStringLiteral("KDENLIVE_VIDEO_PATH_CONFIG"), m_configName); environment.insert(QStringLiteral("KDENLIVE_VIDEO_PATH_PROJECT"), project); + environment.insert(QStringLiteral("KDENLIVE_VIDEO_PATH_LOG"), raw); environment.remove(QStringLiteral("KDENLIVE_VIDEO_PATH_CLIPS")); m_editor.setProcessEnvironment(environment); m_editor.setWorkingDirectory(m_repoRoot); @@ -251,9 +263,18 @@ class RecorderWindow final : public QMainWindow setStatus( QStringLiteral("Kdenlive is starting. Import or create media normally, save the project and render in the session folder, then close normally.")); m_activity->appendPlainText(QStringLiteral("Starting recording segment %1…").arg(number)); - QStringList arguments{raw}; + QString program; + QStringList arguments; +#ifdef Q_OS_WIN + program = QDir(QCoreApplication::applicationDirPath()).filePath(QStringLiteral("kdenlive.exe")); + arguments = {QStringLiteral("--config"), m_configName, QStringLiteral("--no-welcome")}; + if (QFileInfo::exists(project)) arguments.append(project); +#else + program = m_repoRoot + QStringLiteral("/video-path-pilot/run-video-path-pilot.sh"); + arguments = {raw}; if (QFileInfo::exists(project)) arguments.append(project); - m_editor.start(m_repoRoot + QStringLiteral("/video-path-pilot/run-video-path-pilot.sh"), arguments); +#endif + m_editor.start(program, arguments); } void editorFinished(int exitCode, QProcess::ExitStatus) @@ -262,7 +283,7 @@ class RecorderWindow final : public QMainWindow m_activity->appendPlainText(QStringLiteral("Kdenlive exited with code %1; checking the recording…").arg(exitCode)); m_workerPurpose = QStringLiteral("validate"); const QString raw = QDir(m_session).filePath(QStringLiteral("raw-events-%1.jsonl").arg(m_segment, 3, 10, QLatin1Char('0'))); - m_worker.start(QStringLiteral("python3"), {m_repoRoot + QStringLiteral("/video-path-pilot/validate_video_path.py"), raw}); + m_worker.start(pythonExecutable(), {m_repoRoot + QStringLiteral("/video-path-pilot/validate_video_path.py"), raw}); } void finishSession() @@ -270,8 +291,7 @@ class RecorderWindow final : public QMainWindow m_finish->setEnabled(false); m_workerPurpose = QStringLiteral("finalize"); setStatus(QStringLiteral("Discovering project assets, generating sample.json, reconstructing the edit, and comparing renders…")); - m_worker.start(QStringLiteral("python3"), - {m_repoRoot + QStringLiteral("/video-path-pilot/job_pipeline.py"), QStringLiteral("finalize-freeform"), m_session}); + m_worker.start(pythonExecutable(), {m_repoRoot + QStringLiteral("/video-path-pilot/job_pipeline.py"), QStringLiteral("finalize-freeform"), m_session}); } void readWorker() diff --git a/video-path-pilot/run-collector-app.sh b/video-path-pilot/run-collector-app.sh index b8438e4..78bd60b 100755 --- a/video-path-pilot/run-collector-app.sh +++ b/video-path-pilot/run-collector-app.sh @@ -6,7 +6,7 @@ set -euo pipefail script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) repo_root=$(cd -- "$script_dir/.." && pwd) craft_root=${KDENLIVE_PILOT_CRAFT_ROOT:-/home/tenali/CraftRoot} -binary="$repo_root/build/collector-gui/edit-path-recorder" +binary="$repo_root/build/collector-gui/EditPath" export PATH="$craft_root/dev-utils/bin:$craft_root/bin:$craft_root/libexec:$PATH" if [[ ! -f $repo_root/build/collector-gui/build.ninja ]]; then diff --git a/video-path-pilot/run-video-path-pilot.sh b/video-path-pilot/run-video-path-pilot.sh index 5927cfe..7ef9764 100755 --- a/video-path-pilot/run-video-path-pilot.sh +++ b/video-path-pilot/run-video-path-pilot.sh @@ -4,8 +4,8 @@ set -euo pipefail -if [[ $# -ne 1 ]]; then - echo "usage: $0 /absolute/path/session.jsonl" >&2 +if [[ $# -lt 1 || $# -gt 2 ]]; then + echo "usage: $0 /absolute/path/session.jsonl [/absolute/path/project.kdenlive]" >&2 exit 2 fi @@ -47,5 +47,8 @@ fi if [[ -n ${KDENLIVE_VIDEO_PATH_CLIPS:-} ]]; then arguments+=(-i "$KDENLIVE_VIDEO_PATH_CLIPS") fi +if [[ $# -eq 2 ]]; then + arguments+=("$2") +fi exec "$binary" "${arguments[@]}" From f48e9f3089c5d5d21242cd0af3e034e11d94f1d5 Mon Sep 17 00:00:00 2001 From: Tenali Rama <225068477+Tenali-Rama@users.noreply.github.com> Date: Wed, 22 Jul 2026 17:47:14 +0530 Subject: [PATCH 12/14] Trigger initial Windows artifact build --- .github/workflows/windows-portable.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/windows-portable.yml b/.github/workflows/windows-portable.yml index 992a3cb..f9ec0de 100644 --- a/.github/workflows/windows-portable.yml +++ b/.github/workflows/windows-portable.yml @@ -2,6 +2,11 @@ name: Windows portable MVP on: workflow_dispatch: + push: + branches: + - feature/gui-collector-mvp + paths: + - .github/workflows/windows-portable.yml permissions: contents: read From a12511d25c224c1fe1df5cdc3dbb22e9276ac243 Mon Sep 17 00:00:00 2001 From: Tenali Rama <225068477+Tenali-Rama@users.noreply.github.com> Date: Wed, 22 Jul 2026 18:02:38 +0530 Subject: [PATCH 13/14] Add local Windows build and test handoff --- .github/workflows/windows-portable.yml | 83 +--------- WINDOWS_BUILD.md | 60 +++++++ WINDOWS_TEST_PLAN.md | 63 ++++++++ packaging/windows/build-editpath.ps1 | 209 +++++++++++++++++++++++++ 4 files changed, 336 insertions(+), 79 deletions(-) create mode 100644 WINDOWS_BUILD.md create mode 100644 WINDOWS_TEST_PLAN.md create mode 100644 packaging/windows/build-editpath.ps1 diff --git a/.github/workflows/windows-portable.yml b/.github/workflows/windows-portable.yml index f9ec0de..3625e46 100644 --- a/.github/workflows/windows-portable.yml +++ b/.github/workflows/windows-portable.yml @@ -20,92 +20,17 @@ jobs: - name: Check out the instrumented Kdenlive fork uses: actions/checkout@v4 - - name: Bootstrap KDE Craft + - name: Build and assemble the portable MVP shell: pwsh run: | $ErrorActionPreference = "Stop" - Invoke-WebRequest ` - https://raw.githubusercontent.com/KDE/craft/master/setup/install_craft.ps1 ` - -OutFile "$env:RUNNER_TEMP\install_craft.ps1" - & "$env:RUNNER_TEMP\install_craft.ps1" ` - -root C:\CraftRoot ` - -python (Get-Command python.exe).Source ` - -use-defaults - - - name: Allow the EditPath supervisor in the Kdenlive package - shell: pwsh - run: | - $ErrorActionPreference = "Stop" - $blueprint = Get-ChildItem C:\CraftRoot -Recurse -Filter kdenlive.py | - Where-Object { $_.FullName -match 'craft-blueprints-kde.*kdenlive' } | - Select-Object -First 1 - if (-not $blueprint) { throw "Kdenlive Craft blueprint was not found" } - $text = Get-Content $blueprint.FullName -Raw - $text = $text.Replace('bin/(?!(ff|kdenlive|kioworker|melt|update-mime-database|snoretoast|drmingw|data/kdenlive)).*', - 'bin/(?!(ff|kdenlive|EditPath|kioworker|melt|update-mime-database|snoretoast|drmingw|data/kdenlive)).*') - Set-Content $blueprint.FullName $text -Encoding UTF8 - - - name: Build this checkout with Craft - shell: pwsh - run: | - $ErrorActionPreference = "Stop" - . C:\CraftRoot\craft\craftenv.ps1 - craft --ci-mode --src-dir "$env:GITHUB_WORKSPACE" kde/kdemultimedia/kdenlive - - - name: Create the upstream portable package - shell: pwsh - run: | - $ErrorActionPreference = "Stop" - $settings = 'C:\CraftRoot\etc\CraftSettings.ini' - $text = Get-Content $settings -Raw - $text = $text.Replace('#PackageType = SevenZipPackager', 'PackageType = SevenZipPackager') - Set-Content $settings $text -Encoding UTF8 - . C:\CraftRoot\craft\craftenv.ps1 - craft --ci-mode --src-dir "$env:GITHUB_WORKSPACE" --package kde/kdemultimedia/kdenlive - - - name: Assemble the EditPath portable folder - shell: pwsh - run: | - $ErrorActionPreference = "Stop" - $archive = Get-ChildItem C:\CraftRoot -Recurse -File -Filter '*kdenlive*.7z' | - Sort-Object LastWriteTimeUtc -Descending | - Select-Object -First 1 - if (-not $archive) { throw "Craft did not produce a Kdenlive 7z package" } - - $portable = Join-Path $env:RUNNER_TEMP 'EditPath-Windows-x64' - New-Item -ItemType Directory -Force $portable | Out-Null - & 7z x $archive.FullName "-o$portable" -y - if ($LASTEXITCODE -ne 0) { throw "Could not extract the Craft package" } - - $editPath = Get-ChildItem $portable -Recurse -File -Filter EditPath.exe | Select-Object -First 1 - $kdenlive = Get-ChildItem $portable -Recurse -File -Filter kdenlive.exe | Select-Object -First 1 - if (-not $editPath) { throw "EditPath.exe is missing from the portable package" } - if (-not $kdenlive) { throw "kdenlive.exe is missing from the portable package" } - - $bin = $editPath.Directory.FullName - $pythonZip = Join-Path $env:RUNNER_TEMP 'python-embed.zip' - Invoke-WebRequest ` - https://www.python.org/ftp/python/3.11.9/python-3.11.9-embed-amd64.zip ` - -OutFile $pythonZip - $pythonDir = Join-Path $bin 'python' - Expand-Archive $pythonZip $pythonDir -Force - Remove-Item (Join-Path $pythonDir 'python311._pth') -ErrorAction SilentlyContinue - - @" - EditPath portable MVP - - Start: bin\EditPath.exe - Do not start bin\kdenlive.exe directly; doing so bypasses recording. - Sessions are stored in the current user's Videos\EditPathSessions folder. - "@ | Set-Content (Join-Path $portable 'START-HERE.txt') -Encoding UTF8 - - $output = Join-Path $env:GITHUB_WORKSPACE 'EditPath-Windows-x64.zip' - Compress-Archive -Path (Join-Path $portable '*') -DestinationPath $output -CompressionLevel Optimal + Set-ExecutionPolicy -Scope Process Bypass + .\packaging\windows\build-editpath.ps1 - name: Upload portable Windows MVP uses: actions/upload-artifact@v4 with: name: EditPath-Windows-x64 - path: EditPath-Windows-x64.zip + path: windows-output/EditPath-Windows-x64.zip if-no-files-found: error retention-days: 14 diff --git a/WINDOWS_BUILD.md b/WINDOWS_BUILD.md new file mode 100644 index 0000000..7becd12 --- /dev/null +++ b/WINDOWS_BUILD.md @@ -0,0 +1,60 @@ +# Building the Windows portable MVP + +This produces an unsigned, portable 64-bit Windows engineering build. It does +not modify the official Kdenlive installation and does not require the editor +to install Python. + +## Requirements + +- 64-bit Windows 10 or Windows 11; +- at least 40 GB free disk space and 8 GB RAM (16 GB preferred); +- a stable internet connection and several hours for the first build; +- [Git for Windows](https://git-scm.com/download/win); +- [Python 3.11 or newer, 64-bit](https://www.python.org/downloads/windows/), + with **Add Python to PATH** selected; +- [Visual Studio 2022 Build Tools](https://visualstudio.microsoft.com/downloads/) + with **Desktop development with C++** selected. + +Administrator access is useful for installing prerequisites, but the resulting +portable application does not require administrator access. + +## Build + +Open PowerShell in the repository root and run: + +```powershell +Set-ExecutionPolicy -Scope Process Bypass +.\packaging\windows\build-editpath.ps1 +``` + +Prefer a short checkout path such as `C:\src\edit-path`; long or space-heavy +paths can cause problems in Windows C++ dependency builds. + +The script verifies prerequisites before downloading or compiling anything. It +then bootstraps KDE Craft under `C:\CraftRoot`, builds this exact checkout, +packages all runtime dependencies, embeds Python, generates synthetic test +media, and verifies both application executables. + +The first build may take several hours. Keep PowerShell open and prevent the +computer from sleeping. A failed build can normally be retried with the same +command; Craft reuses completed dependencies. + +## Result + +Successful output is written to: + +```text +windows-output\ +├── EditPath-Windows-x64\ +├── EditPath-Windows-x64.zip +└── build-manifest.json +``` + +Run `windows-output\EditPath-Windows-x64\bin\EditPath.exe`. Do not run +`kdenlive.exe` directly because that bypasses recording. Windows SmartScreen +may warn because the MVP has not yet been code-signed; use **More info → Run +anyway** only for an artifact built from the company repository. + +If the script fails, save the complete PowerShell output and send the last 100 +lines along with `windows-output\build-manifest.json` if it exists. Do not +delete `C:\CraftRoot`, because it contains reusable dependency builds. diff --git a/WINDOWS_TEST_PLAN.md b/WINDOWS_TEST_PLAN.md new file mode 100644 index 0000000..4cbfd06 --- /dev/null +++ b/WINDOWS_TEST_PLAN.md @@ -0,0 +1,63 @@ +# Windows MVP acceptance test + +Use the synthetic files shipped in the portable package's `test-media` folder. +Perform the normal-session test before the crash-recovery test. + +## Editing assignment + +> Create a 12–18 second 1280×720 video using both supplied video assets. Cut +> unwanted sections, arrange material from both videos, add the supplied audio +> track, adjust its timing, perform at least one undo and redo, and render the +> final result as MP4. This is an operational test; no editor explanation or +> creative intent should be entered. + +## Test A: normal session + +1. Extract `EditPath-Windows-x64.zip` to a normal writable folder. +2. Double-click `bin\EditPath.exe`. Do not open `kdenlive.exe`. +3. Confirm Kdenlive opens directly with no terminal or initialization screen. +4. Import all three files from `test-media`. +5. Add both videos to the timeline and make at least two cuts. +6. Move or trim one clip at a visible frame boundary. +7. Add `test-audio.wav` and move it to a deliberate timeline position. +8. Press **Ctrl+Z** once and **Ctrl+Shift+Z** once. +9. Save normally. Confirm no second project filename is requested and the + session contains `edit.kdenlive`. +10. Render one MP4 directly into the displayed session folder. +11. Close Kdenlive normally. +12. Confirm the Edit Path completion screen appears, then click **Finish + Session**. +13. Open the generated sample and confirm `sample.json`, assets, final video, + raw events, native project, and validation reports exist. + +Record PASS/FAIL and notes for every check: + +- Kdenlive opened directly. +- No terminal/init screen appeared. +- Editing and preview worked. +- `edit.kdenlive` was created. +- Final MP4 rendered. +- Completion screen appeared only after closing Kdenlive. +- `sample.json` was generated. +- Operations contain integer frame positions/state changes. +- `history.undo` and `history.redo` are present. +- Asset IDs and SHA-256 hashes are present. +- Reconstruction report exists and states passed, unsupported, or failed with + an explicit reason. + +## Test B: crash recovery + +1. Start `bin\EditPath.exe` again. +2. Import `test-video-1.mp4`, put it on the timeline, and press **Ctrl+S**. +3. Make another visible edit and press **Ctrl+S** again. +4. Open Windows Task Manager, select Kdenlive, and choose **End task**. Do not + terminate EditPath. +5. Confirm the recovery screen appears. +6. Choose **Recover and Continue**. +7. Confirm `edit.kdenlive` reopens and the saved timeline edit remains. +8. Make one additional edit, save, render an MP4, and close normally. +9. Finish the session and confirm multiple numbered raw-event and console-log + segments were retained. + +Do not report a test as passed if Kdenlive merely opened. A successful MVP test +must complete packaging and inspect the resulting `sample.json`. diff --git a/packaging/windows/build-editpath.ps1 b/packaging/windows/build-editpath.ps1 new file mode 100644 index 0000000..20276c9 --- /dev/null +++ b/packaging/windows/build-editpath.ps1 @@ -0,0 +1,209 @@ +# SPDX-FileCopyrightText: 2026 Video Path Pilot contributors +# SPDX-License-Identifier: GPL-3.0-only + +[CmdletBinding()] +param( + [string]$CraftRoot = "C:\CraftRoot", + [string]$OutputDirectory = "", + [switch]$SkipTestMedia +) + +$ErrorActionPreference = "Stop" +Set-StrictMode -Version Latest + +function Stop-Build([string]$Message) { + throw "EditPath build prerequisite failed: $Message" +} + +if (-not [Environment]::Is64BitOperatingSystem) { + Stop-Build "64-bit Windows 10 or 11 is required." +} + +$sourceRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..")).Path +if (-not (Test-Path (Join-Path $sourceRoot "CMakeLists.txt"))) { + Stop-Build "run this script from a complete EditPath repository checkout." +} +if (-not $OutputDirectory) { + $OutputDirectory = Join-Path $sourceRoot "windows-output" +} +New-Item -ItemType Directory -Force $OutputDirectory | Out-Null + +$sourceDriveName = (Split-Path -Qualifier $sourceRoot).TrimEnd('\').TrimEnd(':') +$sourceDrive = Get-PSDrive -Name $sourceDriveName +if ($sourceDrive.Free -lt 40GB) { + Stop-Build "at least 40 GB free space is required on $($sourceDrive.Name): (available: $([math]::Round($sourceDrive.Free / 1GB, 1)) GB)." +} + +$git = Get-Command git.exe -ErrorAction SilentlyContinue +if (-not $git) { + Stop-Build "Git for Windows is required: https://git-scm.com/download/win" +} + +$python = Get-Command python.exe -ErrorAction SilentlyContinue +if (-not $python) { + Stop-Build "64-bit Python 3.11 or newer is required: https://www.python.org/downloads/windows/" +} +$pythonVersion = & $python.Source -c "import sys; print('.'.join(map(str, sys.version_info[:2])))" +if ([version]$pythonVersion -lt [version]"3.11") { + Stop-Build "Python 3.11 or newer is required; found $pythonVersion." +} +$python64Bit = & $python.Source -c "import sys; print(sys.maxsize > 2**32)" +if ($python64Bit -ne "True") { + Stop-Build "the installed Python must be 64-bit." +} + +$vswhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" +if (-not (Test-Path $vswhere)) { + Stop-Build "Visual Studio 2022 Build Tools with 'Desktop development with C++' is required: https://visualstudio.microsoft.com/downloads/" +} +$visualStudio = & $vswhere -latest -products * -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath +if (-not $visualStudio) { + Stop-Build "install the Visual Studio 2022 'Desktop development with C++' workload." +} + +Write-Host "Source: $sourceRoot" +Write-Host "Craft: $CraftRoot" +Write-Host "Output: $OutputDirectory" +Write-Host "The first build can take several hours. Do not close this window." + +$craftEnvironment = Join-Path $CraftRoot "craft\craftenv.ps1" +if (-not (Test-Path $craftEnvironment)) { + if (Test-Path $CraftRoot) { + $existing = Get-ChildItem $CraftRoot -Force -ErrorAction SilentlyContinue + if ($existing) { + Stop-Build "$CraftRoot exists but is not a valid Craft installation. Rename it or choose -CraftRoot with an empty path." + } + } + New-Item -ItemType Directory -Force $CraftRoot | Out-Null + $bootstrap = Join-Path $env:TEMP "install_craft.ps1" + Invoke-WebRequest https://raw.githubusercontent.com/KDE/craft/master/setup/install_craft.ps1 -OutFile $bootstrap + & $bootstrap -root $CraftRoot -python $python.Source -use-defaults +} +if (-not (Test-Path $craftEnvironment)) { + Stop-Build "Craft bootstrap did not create $craftEnvironment." +} + +. $craftEnvironment + +$blueprint = Get-ChildItem $CraftRoot -Recurse -Filter kdenlive.py | + Where-Object { $_.FullName -match 'craft-blueprints-kde.*kdenlive' } | + Select-Object -First 1 +if (-not $blueprint) { + Stop-Build "the Kdenlive Craft blueprint was not found." +} +$blueprintText = Get-Content $blueprint.FullName -Raw +$oldFilter = 'bin/(?!(ff|kdenlive|kioworker|melt|update-mime-database|snoretoast|drmingw|data/kdenlive)).*' +$newFilter = 'bin/(?!(ff|kdenlive|EditPath|kioworker|melt|update-mime-database|snoretoast|drmingw|data/kdenlive)).*' +if ($blueprintText.Contains($oldFilter)) { + $blueprintText = $blueprintText.Replace($oldFilter, $newFilter) + Set-Content $blueprint.FullName $blueprintText -Encoding UTF8 +} elseif (-not $blueprintText.Contains($newFilter)) { + Stop-Build "the Craft Kdenlive executable filter changed; update this script before building." +} + +Write-Host "Building EditPath and all required Kdenlive dependencies..." +craft --ci-mode --src-dir $sourceRoot kde/kdemultimedia/kdenlive +if ($LASTEXITCODE -ne 0) { Stop-Build "Craft compilation failed." } + +$settings = Join-Path $CraftRoot "etc\CraftSettings.ini" +$settingsText = Get-Content $settings -Raw +if ($settingsText.Contains('#PackageType = SevenZipPackager')) { + $settingsText = $settingsText.Replace('#PackageType = SevenZipPackager', 'PackageType = SevenZipPackager') + Set-Content $settings $settingsText -Encoding UTF8 +} + +Write-Host "Creating dependency-complete portable package..." +craft --ci-mode --src-dir $sourceRoot --package kde/kdemultimedia/kdenlive +if ($LASTEXITCODE -ne 0) { Stop-Build "Craft packaging failed." } + +$archive = Get-ChildItem $CraftRoot -Recurse -File -Filter '*kdenlive*.7z' | + Where-Object { $_.Name -notmatch '(debug|symbols|src)' } | + Sort-Object LastWriteTimeUtc -Descending | + Select-Object -First 1 +if (-not $archive) { Stop-Build "Craft did not produce a Kdenlive 7z package." } + +$sevenZip = Get-Command 7z.exe -ErrorAction SilentlyContinue +if (-not $sevenZip) { + $sevenZipCandidate = Join-Path $CraftRoot "bin\7z.exe" + if (Test-Path $sevenZipCandidate) { $sevenZip = Get-Item $sevenZipCandidate } +} +if (-not $sevenZip) { Stop-Build "7z.exe was not found after Craft packaging." } +$sevenZipPath = if ($sevenZip -is [IO.FileInfo]) { $sevenZip.FullName } else { $sevenZip.Source } + +$portable = Join-Path $OutputDirectory "EditPath-Windows-x64" +if (Test-Path $portable) { + $backup = "$portable.previous.$(Get-Date -Format 'yyyyMMdd-HHmmss')" + Move-Item $portable $backup + Write-Host "Previous output preserved at $backup" +} +New-Item -ItemType Directory -Force $portable | Out-Null +& $sevenZipPath x $archive.FullName "-o$portable" -y +if ($LASTEXITCODE -ne 0) { Stop-Build "could not extract the Craft package." } + +$editPath = Get-ChildItem $portable -Recurse -File -Filter EditPath.exe | Select-Object -First 1 +$kdenlive = Get-ChildItem $portable -Recurse -File -Filter kdenlive.exe | Select-Object -First 1 +if (-not $editPath) { Stop-Build "EditPath.exe is missing from the portable package." } +if (-not $kdenlive) { Stop-Build "kdenlive.exe is missing from the portable package." } +if ($editPath.Directory.FullName -ne $kdenlive.Directory.FullName) { + Stop-Build "EditPath.exe and kdenlive.exe were not packaged together." +} + +$bin = $editPath.Directory.FullName +$pythonZip = Join-Path $env:TEMP "python-3.11.9-embed-amd64.zip" +if (-not (Test-Path $pythonZip)) { + Invoke-WebRequest https://www.python.org/ftp/python/3.11.9/python-3.11.9-embed-amd64.zip -OutFile $pythonZip +} +$pythonDirectory = Join-Path $bin "python" +New-Item -ItemType Directory -Force $pythonDirectory | Out-Null +Expand-Archive $pythonZip $pythonDirectory -Force +Remove-Item (Join-Path $pythonDirectory "python311._pth") -ErrorAction SilentlyContinue + +if (-not $SkipTestMedia) { + $ffmpeg = Get-ChildItem $portable -Recurse -File -Filter ffmpeg.exe | Select-Object -First 1 + if (-not $ffmpeg) { Stop-Build "ffmpeg.exe is missing; synthetic test media cannot be generated." } + $testMedia = Join-Path $portable "test-media" + New-Item -ItemType Directory -Force $testMedia | Out-Null + & $ffmpeg.FullName -hide_banner -loglevel error -y -f lavfi -i "testsrc2=size=1280x720:rate=30:duration=8" ` + -f lavfi -i "sine=frequency=440:sample_rate=48000:duration=8" -c:v mpeg4 -q:v 4 -c:a aac -shortest ` + (Join-Path $testMedia "test-video-1.mp4") + if ($LASTEXITCODE -ne 0) { Stop-Build "failed to generate test-video-1.mp4." } + & $ffmpeg.FullName -hide_banner -loglevel error -y -f lavfi -i "smptebars=size=1280x720:rate=30:duration=8" ` + -f lavfi -i "sine=frequency=660:sample_rate=48000:duration=8" -c:v mpeg4 -q:v 4 -c:a aac -shortest ` + (Join-Path $testMedia "test-video-2.mp4") + if ($LASTEXITCODE -ne 0) { Stop-Build "failed to generate test-video-2.mp4." } + & $ffmpeg.FullName -hide_banner -loglevel error -y -f lavfi -i "sine=frequency=220:sample_rate=48000:duration=12" ` + -c:a pcm_s16le (Join-Path $testMedia "test-audio.wav") + if ($LASTEXITCODE -ne 0) { Stop-Build "failed to generate test-audio.wav." } +} + +@" +EditPath portable MVP + +START: bin\EditPath.exe +DO NOT start bin\kdenlive.exe directly; that bypasses recording. +Sessions: %USERPROFILE%\Videos\EditPathSessions +Test instructions: WINDOWS_TEST_PLAN.md in the source repository. +"@ | Set-Content (Join-Path $portable "START-HERE.txt") -Encoding UTF8 +Copy-Item (Join-Path $sourceRoot "WINDOWS_TEST_PLAN.md") (Join-Path $portable "WINDOWS_TEST_PLAN.md") + +$outputZip = Join-Path $OutputDirectory "EditPath-Windows-x64.zip" +if (Test-Path $outputZip) { + Move-Item $outputZip "$outputZip.previous.$(Get-Date -Format 'yyyyMMdd-HHmmss')" +} +Compress-Archive -Path (Join-Path $portable '*') -DestinationPath $outputZip -CompressionLevel Optimal + +$manifest = [ordered]@{ + built_at_utc = (Get-Date).ToUniversalTime().ToString("o") + source_commit = (& git -C $sourceRoot rev-parse HEAD).Trim() + archive = $outputZip + editpath_exe = $editPath.FullName.Substring($portable.Length + 1) + kdenlive_exe = $kdenlive.FullName.Substring($portable.Length + 1) + test_media_included = -not $SkipTestMedia +} +$manifest | ConvertTo-Json | Set-Content (Join-Path $OutputDirectory "build-manifest.json") -Encoding UTF8 + +Write-Host "" +Write-Host "BUILD COMPLETE" -ForegroundColor Green +Write-Host "Portable folder: $portable" +Write-Host "Shareable ZIP: $outputZip" +Write-Host "Start executable: $($editPath.FullName)" From 629553ce9dc0d6fe2716e62bc191c98d3b81049b Mon Sep 17 00:00:00 2001 From: Tenali Rama <225068477+Tenali-Rama@users.noreply.github.com> Date: Wed, 22 Jul 2026 18:23:27 +0530 Subject: [PATCH 14/14] Harden Windows build with preflight and self-test --- WINDOWS_BUILD.md | 15 +++++++- WINDOWS_TEST_PLAN.md | 25 +++++++------ documentation.md | 9 +++++ packaging/windows/build-editpath.ps1 | 42 +++++++++++++++++++++ video-path-pilot/README.md | 3 ++ video-path-pilot/gui/main.cpp | 55 ++++++++++++++++++++++++++++ 6 files changed, 136 insertions(+), 13 deletions(-) diff --git a/WINDOWS_BUILD.md b/WINDOWS_BUILD.md index 7becd12..f00535d 100644 --- a/WINDOWS_BUILD.md +++ b/WINDOWS_BUILD.md @@ -20,6 +20,15 @@ portable application does not require administrator access. ## Build +Run the fast prerequisite check first. It does not download or compile Kdenlive: + +```powershell +Set-ExecutionPolicy -Scope Process Bypass +.\packaging\windows\build-editpath.ps1 -PreflightOnly +``` + +Only continue when it prints `PREFLIGHT PASSED`. + Open PowerShell in the repository root and run: ```powershell @@ -34,6 +43,9 @@ The script verifies prerequisites before downloading or compiling anything. It then bootstraps KDE Craft under `C:\CraftRoot`, builds this exact checkout, packages all runtime dependencies, embeds Python, generates synthetic test media, and verifies both application executables. +It also prevents sleep while its process is running, writes the complete output +to `windows-output\windows-build.log`, and runs the packaged applications' +non-interactive version/self-tests before creating the ZIP. The first build may take several hours. Keep PowerShell open and prevent the computer from sleeping. A failed build can normally be retried with the same @@ -56,5 +68,6 @@ may warn because the MVP has not yet been code-signed; use **More info → Run anyway** only for an artifact built from the company repository. If the script fails, save the complete PowerShell output and send the last 100 -lines along with `windows-output\build-manifest.json` if it exists. Do not +lines of `windows-output\windows-build.log` along with +`windows-output\build-manifest.json` if it exists. Do not delete `C:\CraftRoot`, because it contains reusable dependency builds. diff --git a/WINDOWS_TEST_PLAN.md b/WINDOWS_TEST_PLAN.md index 4cbfd06..3eca26b 100644 --- a/WINDOWS_TEST_PLAN.md +++ b/WINDOWS_TEST_PLAN.md @@ -14,20 +14,21 @@ Perform the normal-session test before the crash-recovery test. ## Test A: normal session 1. Extract `EditPath-Windows-x64.zip` to a normal writable folder. -2. Double-click `bin\EditPath.exe`. Do not open `kdenlive.exe`. -3. Confirm Kdenlive opens directly with no terminal or initialization screen. -4. Import all three files from `test-media`. -5. Add both videos to the timeline and make at least two cuts. -6. Move or trim one clip at a visible frame boundary. -7. Add `test-audio.wav` and move it to a deliberate timeline position. -8. Press **Ctrl+Z** once and **Ctrl+Shift+Z** once. -9. Save normally. Confirm no second project filename is requested and the +2. Confirm `SELF-TEST.json` exists and contains `"passed": true`. +3. Double-click `bin\EditPath.exe`. Do not open `kdenlive.exe`. +4. Confirm Kdenlive opens directly with no terminal or initialization screen. +5. Import all three files from `test-media`. +6. Add both videos to the timeline and make at least two cuts. +7. Move or trim one clip at a visible frame boundary. +8. Add `test-audio.wav` and move it to a deliberate timeline position. +9. Press **Ctrl+Z** once and **Ctrl+Shift+Z** once. +10. Save normally. Confirm no second project filename is requested and the session contains `edit.kdenlive`. -10. Render one MP4 directly into the displayed session folder. -11. Close Kdenlive normally. -12. Confirm the Edit Path completion screen appears, then click **Finish +11. Render one MP4 directly into the displayed session folder. +12. Close Kdenlive normally. +13. Confirm the Edit Path completion screen appears, then click **Finish Session**. -13. Open the generated sample and confirm `sample.json`, assets, final video, +14. Open the generated sample and confirm `sample.json`, assets, final video, raw events, native project, and validation reports exist. Record PASS/FAIL and notes for every check: diff --git a/documentation.md b/documentation.md index 22a530c..8d2bea9 100644 --- a/documentation.md +++ b/documentation.md @@ -667,6 +667,15 @@ and uploads `EditPath-Windows-x64.zip`. The first artifact is intentionally a portable, unsigned engineering build. Installer creation, code signing, and update delivery follow only after functional testing on the editor's machine. +To reduce first-artifact turnaround, the local build script now provides a +fast `-PreflightOnly` prerequisite check, appends a durable +`windows-build.log`, and prevents sleep while compiling. Before emitting the +ZIP it runs `EditPath.exe --self-test`, `kdenlive.exe --version`, invokes the +embedded Python validator, verifies FFmpeg-generated synthetic media, and +requires a passing `SELF-TEST.json`. This catches missing executables, packaged +scripts, Python runtime failures, and basic dependency-layout mistakes before +the editor receives the artifact. + ### Privacy and security The collector can reveal editor behavior, project structure, local file paths, diff --git a/packaging/windows/build-editpath.ps1 b/packaging/windows/build-editpath.ps1 index 20276c9..16d81ee 100644 --- a/packaging/windows/build-editpath.ps1 +++ b/packaging/windows/build-editpath.ps1 @@ -5,6 +5,7 @@ param( [string]$CraftRoot = "C:\CraftRoot", [string]$OutputDirectory = "", + [switch]$PreflightOnly, [switch]$SkipTestMedia ) @@ -12,6 +13,7 @@ $ErrorActionPreference = "Stop" Set-StrictMode -Version Latest function Stop-Build([string]$Message) { + try { Stop-Transcript | Out-Null } catch { } throw "EditPath build prerequisite failed: $Message" } @@ -27,6 +29,8 @@ if (-not $OutputDirectory) { $OutputDirectory = Join-Path $sourceRoot "windows-output" } New-Item -ItemType Directory -Force $OutputDirectory | Out-Null +$buildLog = Join-Path $OutputDirectory "windows-build.log" +Start-Transcript -Path $buildLog -Append | Out-Null $sourceDriveName = (Split-Path -Qualifier $sourceRoot).TrimEnd('\').TrimEnd(':') $sourceDrive = Get-PSDrive -Name $sourceDriveName @@ -61,6 +65,24 @@ if (-not $visualStudio) { Stop-Build "install the Visual Studio 2022 'Desktop development with C++' workload." } +if ($PreflightOnly) { + Write-Host "PREFLIGHT PASSED" -ForegroundColor Green + Write-Host "Windows, disk space, Git, 64-bit Python, and Visual Studio C++ tools are ready." + Write-Host "Next command: .\packaging\windows\build-editpath.ps1" + Stop-Transcript | Out-Null + return +} + +Add-Type -TypeDefinition @' +using System; +using System.Runtime.InteropServices; +public static class EditPathPower { + [DllImport("kernel32.dll")] + public static extern uint SetThreadExecutionState(uint flags); +} +'@ +[EditPathPower]::SetThreadExecutionState(0x80000001) | Out-Null + Write-Host "Source: $sourceRoot" Write-Host "Craft: $CraftRoot" Write-Host "Output: $OutputDirectory" @@ -158,6 +180,20 @@ New-Item -ItemType Directory -Force $pythonDirectory | Out-Null Expand-Archive $pythonZip $pythonDirectory -Force Remove-Item (Join-Path $pythonDirectory "python311._pth") -ErrorAction SilentlyContinue +$selfTestReport = Join-Path $portable "SELF-TEST.json" +$env:EDIT_PATH_SELF_TEST_REPORT = $selfTestReport +& $editPath.FullName --self-test +$selfTestExitCode = $LASTEXITCODE +Remove-Item Env:\EDIT_PATH_SELF_TEST_REPORT -ErrorAction SilentlyContinue +if ($selfTestExitCode -ne 0 -or -not (Test-Path $selfTestReport)) { + Stop-Build "the packaged EditPath runtime self-test failed." +} +$selfTest = Get-Content $selfTestReport -Raw | ConvertFrom-Json +if (-not $selfTest.passed) { Stop-Build "the packaged runtime reported a failed dependency check." } + +& $kdenlive.FullName --version +if ($LASTEXITCODE -ne 0) { Stop-Build "kdenlive.exe could not start for its version check." } + if (-not $SkipTestMedia) { $ffmpeg = Get-ChildItem $portable -Recurse -File -Filter ffmpeg.exe | Select-Object -First 1 if (-not $ffmpeg) { Stop-Build "ffmpeg.exe is missing; synthetic test media cannot be generated." } @@ -174,6 +210,10 @@ if (-not $SkipTestMedia) { & $ffmpeg.FullName -hide_banner -loglevel error -y -f lavfi -i "sine=frequency=220:sample_rate=48000:duration=12" ` -c:a pcm_s16le (Join-Path $testMedia "test-audio.wav") if ($LASTEXITCODE -ne 0) { Stop-Build "failed to generate test-audio.wav." } + $generatedMedia = Get-ChildItem $testMedia -File + if ($generatedMedia.Count -ne 3 -or ($generatedMedia | Where-Object Length -eq 0)) { + Stop-Build "synthetic test-media verification failed." + } } @" @@ -207,3 +247,5 @@ Write-Host "BUILD COMPLETE" -ForegroundColor Green Write-Host "Portable folder: $portable" Write-Host "Shareable ZIP: $outputZip" Write-Host "Start executable: $($editPath.FullName)" +[EditPathPower]::SetThreadExecutionState(0x80000000) | Out-Null +Stop-Transcript | Out-Null diff --git a/video-path-pilot/README.md b/video-path-pilot/README.md index bfda96c..a4b2aac 100644 --- a/video-path-pilot/README.md +++ b/video-path-pilot/README.md @@ -36,6 +36,9 @@ MVP** GitHub Actions workflow. It uses the maintained KDE Craft Kdenlive blueprint to compile this checkout and its dependencies for 64-bit Windows, then adds an embedded Python runtime for local validation and sample packaging. The uploaded artifact is `EditPath-Windows-x64.zip`. +The local/hosted build runs `EditPath.exe --self-test` before creating the ZIP; +the resulting `SELF-TEST.json` must report `passed: true`. A separate +`-PreflightOnly` mode checks the Windows machine before the long Craft build. After extracting the archive, start `bin\\EditPath.exe`. Do not start `bin\\kdenlive.exe` directly because that bypasses session supervision and diff --git a/video-path-pilot/gui/main.cpp b/video-path-pilot/gui/main.cpp index ba667c8..2a7d8d2 100644 --- a/video-path-pilot/gui/main.cpp +++ b/video-path-pilot/gui/main.cpp @@ -20,6 +20,7 @@ #include #include #include +#include #include #include #include @@ -357,11 +358,65 @@ class RecorderWindow final : public QMainWindow QPlainTextEdit *m_activity{}; }; +int runSelfTest() +{ + const QString appDirectory = QCoreApplication::applicationDirPath(); + const QString root = repositoryRoot(); + QJsonObject checks; + auto checkFile = [&checks](const QString &name, const QString &path) { + const bool present = QFileInfo::exists(path); + checks.insert(name, QJsonObject{{QStringLiteral("passed"), present}, {QStringLiteral("path"), QDir::toNativeSeparators(path)}}); + return present; + }; + +#ifdef Q_OS_WIN + const QString kdenlive = QDir(appDirectory).filePath(QStringLiteral("kdenlive.exe")); + const QString ffmpeg = QDir(appDirectory).filePath(QStringLiteral("ffmpeg.exe")); +#else + const QString kdenlive = QDir(appDirectory).filePath(QStringLiteral("kdenlive")); + const QString ffmpeg = QStandardPaths::findExecutable(QStringLiteral("ffmpeg")); +#endif + bool passed = !root.isEmpty(); + checks.insert(QStringLiteral("application_root"), + QJsonObject{{QStringLiteral("passed"), !root.isEmpty()}, {QStringLiteral("path"), QDir::toNativeSeparators(root)}}); + passed = checkFile(QStringLiteral("kdenlive"), kdenlive) && passed; + passed = checkFile(QStringLiteral("ffmpeg"), ffmpeg) && passed; + const QString validator = QDir(root).filePath(QStringLiteral("video-path-pilot/validate_video_path.py")); + passed = checkFile(QStringLiteral("validator"), validator) && passed; + const QString pipeline = QDir(root).filePath(QStringLiteral("video-path-pilot/job_pipeline.py")); + passed = checkFile(QStringLiteral("pipeline"), pipeline) && passed; + QString python = pythonExecutable(); + if (!QFileInfo(python).isAbsolute()) python = QStandardPaths::findExecutable(python); + passed = checkFile(QStringLiteral("python"), python) && passed; + + QProcess validatorTest; + validatorTest.start(python, {validator, QStringLiteral("--help")}); + const bool validatorStarted = validatorTest.waitForStarted(10000); + const bool validatorFinished = validatorStarted && validatorTest.waitForFinished(30000); + const bool validatorPassed = validatorFinished && validatorTest.exitStatus() == QProcess::NormalExit && validatorTest.exitCode() == 0; + QJsonObject pipelineCheck{{QStringLiteral("passed"), validatorPassed}, {QStringLiteral("exit_code"), validatorFinished ? validatorTest.exitCode() : -1}}; + if (!validatorPassed) pipelineCheck.insert(QStringLiteral("error"), validatorTest.errorString()); + checks.insert(QStringLiteral("python_pipeline"), pipelineCheck); + passed = validatorPassed && passed; + + const QJsonObject report{ + {QStringLiteral("schema_version"), QStringLiteral("0.1.0")}, {QStringLiteral("passed"), passed}, {QStringLiteral("checks"), checks}}; + const QByteArray encoded = QJsonDocument(report).toJson(QJsonDocument::Indented); + QTextStream(stdout) << QString::fromUtf8(encoded); + const QString reportPath = qEnvironmentVariable("EDIT_PATH_SELF_TEST_REPORT"); + if (!reportPath.isEmpty()) { + QFile reportFile(reportPath); + if (!reportFile.open(QIODevice::WriteOnly | QIODevice::Truncate) || reportFile.write(encoded) != encoded.size()) return EXIT_FAILURE; + } + return passed ? EXIT_SUCCESS : EXIT_FAILURE; +} + int main(int argc, char **argv) { QApplication application(argc, argv); QCoreApplication::setOrganizationName(QStringLiteral("Parsewave")); QCoreApplication::setApplicationName(QStringLiteral("EditPathRecorder")); + if (application.arguments().contains(QStringLiteral("--self-test"))) return runSelfTest(); RecorderWindow window; return application.exec(); }