diff --git a/.github/workflows/windows-portable.yml b/.github/workflows/windows-portable.yml new file mode 100644 index 0000000..3625e46 --- /dev/null +++ b/.github/workflows/windows-portable.yml @@ -0,0 +1,36 @@ +name: Windows portable MVP + +on: + workflow_dispatch: + push: + branches: + - feature/gui-collector-mvp + paths: + - .github/workflows/windows-portable.yml + +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: Build and assemble the portable MVP + shell: pwsh + run: | + $ErrorActionPreference = "Stop" + 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: windows-output/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/WINDOWS_BUILD.md b/WINDOWS_BUILD.md new file mode 100644 index 0000000..f00535d --- /dev/null +++ b/WINDOWS_BUILD.md @@ -0,0 +1,73 @@ +# 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 + +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 +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. +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 +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 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 new file mode 100644 index 0000000..3eca26b --- /dev/null +++ b/WINDOWS_TEST_PLAN.md @@ -0,0 +1,64 @@ +# 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. 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`. +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**. +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: + +- 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/documentation.md b/documentation.md index 100c515..8d2bea9 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 @@ -485,6 +487,195 @@ 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. + +### 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. + +### 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. + +### 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. + +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. + +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. + +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. + +### 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. + +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 new file mode 100644 index 0000000..16d81ee --- /dev/null +++ b/packaging/windows/build-editpath.ps1 @@ -0,0 +1,251 @@ +# SPDX-FileCopyrightText: 2026 Video Path Pilot contributors +# SPDX-License-Identifier: GPL-3.0-only + +[CmdletBinding()] +param( + [string]$CraftRoot = "C:\CraftRoot", + [string]$OutputDirectory = "", + [switch]$PreflightOnly, + [switch]$SkipTestMedia +) + +$ErrorActionPreference = "Stop" +Set-StrictMode -Version Latest + +function Stop-Build([string]$Message) { + try { Stop-Transcript | Out-Null } catch { } + 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 +$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 +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." +} + +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" +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 + +$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." } + $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." } + $generatedMedia = Get-ChildItem $testMedia -File + if ($generatedMedia.Count -ne 3 -or ($generatedMedia | Where-Object Length -eq 0)) { + Stop-Build "synthetic test-media verification failed." + } +} + +@" +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)" +[EditPathPower]::SetThreadExecutionState(0x80000000) | Out-Null +Stop-Transcript | Out-Null diff --git a/src/main.cpp b/src/main.cpp index c285284..1a24e90 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 */ @@ -56,6 +56,7 @@ SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL #include #include #include +#include #include #include //new @@ -290,18 +291,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 +365,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 +569,18 @@ 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)) { + 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(); } diff --git a/video-path-pilot/EDITOR_WORKFLOW.md b/video-path-pilot/EDITOR_WORKFLOW.md index a8183ea..f5f5ebb 100644 --- a/video-path-pilot/EDITOR_WORKFLOW.md +++ b/video-path-pilot/EDITOR_WORKFLOW.md @@ -1,66 +1,34 @@ -# Editor workflow for the two-sample MVP - -Use a fresh directory for every sample. Do not reuse a crashed or incomplete -recording. Commands below are run from the repository root. - -## 1. 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. - -## 2. 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. - -## 3. 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." -``` - -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 - -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. +# 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**. 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. 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 + 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 e2fd1c6..a4b2aac 100644 --- a/video-path-pilot/README.md +++ b/video-path-pilot/README.md @@ -5,24 +5,65 @@ 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 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`. -## MVP collector +## MVP recorder + +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 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. + +### 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`. +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 +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, +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: ```bash 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. +`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/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/gui/CMakeLists.txt b/video-path-pilot/gui/CMakeLists.txt new file mode 100644 index 0000000..7eeb121 --- /dev/null +++ b/video-path-pilot/gui/CMakeLists.txt @@ -0,0 +1,16 @@ +# SPDX-FileCopyrightText: 2026 Video Path Pilot contributors +# SPDX-License-Identifier: GPL-3.0-only + +cmake_minimum_required(VERSION 3.20) +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 new file mode 100644 index 0000000..2a7d8d2 --- /dev/null +++ b/video-path-pilot/gui/main.cpp @@ -0,0 +1,422 @@ +// 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 +#include + +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(); + 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(); + if (!current.cdUp()) break; + } + 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); + 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()) + { + setWindowTitle(QStringLiteral("Edit Path Recorder MVP")); + resize(720, 500); + buildUi(); + restoreLastSession(); + if (m_repoRoot.isEmpty()) { + 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(); + } + }); + } + } + +protected: + void closeEvent(QCloseEvent *event) override + { + 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(); + } + +private: + void buildUi() + { + auto *central = new QWidget; + auto *layout = new QVBoxLayout(central); + 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("Checking the editing session…")); + 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 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); + 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); + setCentralWidget(central); + + 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::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_editor, qOverload(&QProcess::finished), this, &RecorderWindow::editorFinished); + connect(&m_editor, &QProcess::started, this, [this] { + 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) { + writeManifest(QStringLiteral("start_failed")); + setStatus(QStringLiteral("Kdenlive could not start. Check X11 and the console log."), true); + m_start->setVisible(true); + showCompletionWindow(); + } + }); + 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 &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 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)}}; + 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("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); + 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.")); + m_showExistingCompletion = true; + } else if (status == QStringLiteral("recovery_available") || status == QStringLiteral("recording")) { + 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; + } + } + + void startNewSession() + { + 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(); + } + + void launchSegment() + { + hide(); + m_recover->setVisible(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)); + 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.insert(QStringLiteral("KDENLIVE_VIDEO_PATH_LOG"), raw); + 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)); + 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); +#endif + m_editor.start(program, arguments); + } + + 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'))); + m_worker.start(pythonExecutable(), {m_repoRoot + QStringLiteral("/video-path-pilot/validate_video_path.py"), raw}); + } + + void finishSession() + { + 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(pythonExecutable(), {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); + } + + void workerFinished(int exitCode, QProcess::ExitStatus status) + { + 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->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->setVisible(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 { + m_finish->setEnabled(true); + setStatus(QStringLiteral("Sample generation failed. Review Activity and correct the project, render, or media files."), true); + } + } + 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; + 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{}; +}; + +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(); +} 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..e6c8801 --- /dev/null +++ b/video-path-pilot/job_pipeline.py @@ -0,0 +1,331 @@ +#!/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 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) + 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) + 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 + + +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 629e557..cce485c 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]: @@ -18,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: @@ -63,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")) @@ -82,7 +94,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,59 +103,82 @@ 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): + 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")}}, }) - 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}) + 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 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) + 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"], "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"]}, + "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, - "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", + "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 new file mode 100755 index 0000000..78bd60b --- /dev/null +++ b/video-path-pilot/run-collector-app.sh @@ -0,0 +1,26 @@ +#!/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/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 + mkdir -p "$repo_root/build/collector-gui" + cmake -S "$script_dir/gui" -B "$repo_root/build/collector-gui" -GNinja \ + -DCMAKE_PREFIX_PATH="$craft_root" +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}" +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 c6dd0c6..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 @@ -40,4 +40,15 @@ 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 +if [[ -n ${KDENLIVE_VIDEO_PATH_CLIPS:-} ]]; then + arguments+=(-i "$KDENLIVE_VIDEO_PATH_CLIPS") +fi +if [[ $# -eq 2 ]]; then + arguments+=("$2") +fi + +exec "$binary" "${arguments[@]}" diff --git a/video-path-pilot/sample.schema.json b/video-path-pilot/sample.schema.json index cf8a226..f96e182 100644 --- a/video-path-pilot/sample.schema.json +++ b/video-path-pilot/sample.schema.json @@ -3,16 +3,16 @@ "$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", "null"], "minLength": 1}, + "prompt_status": {"enum": ["provided", "pending_internal_entry"]} } }, "project": { diff --git a/video-path-pilot/sample_collector.py b/video-path-pilot/sample_collector.py deleted file mode 100755 index d301d05..0000000 --- a/video-path-pilot/sample_collector.py +++ /dev/null @@ -1,225 +0,0 @@ -#!/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.""" - -from __future__ import annotations - -import argparse -import hashlib -import json -import shutil -import subprocess -import sys -import uuid -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}, - "editor_plan": args.plan, - "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) - (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}") - 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_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) - 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["editor_review"] = args.review - 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("--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) - init.add_argument("--height", type=int, default=1080) - 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) - - 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("--review", required=True, help="editor's final assessment") - 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 c07829a..1f47567 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 @@ -16,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"} @@ -32,32 +33,88 @@ 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_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}] + 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() 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": "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.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" 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 19e2347..a6b2801 100755 --- a/video-path-pilot/validate_sample.py +++ b/video-path-pilot/validate_sample.py @@ -26,12 +26,17 @@ 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") + 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") @@ -41,16 +46,22 @@ 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)] 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