diff --git a/.cargo/config.toml b/.cargo/config.toml index 533e9503..6d017ab4 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -9,3 +9,18 @@ # tauri.conf.json. Keep the two in sync. [env] MACOSX_DEPLOYMENT_TARGET = { value = "11.0", force = true } + +# whisper.cpp is compiled as a static library, but CMake otherwise selects the +# dynamic MSVC C/C++ runtime on Windows. That leaves the installed application +# dependent on whichever MSVCP140/VCRUNTIME140 version happens to be present on +# the machine and can fail before Rust's main() with STATUS_ENTRYPOINT_NOT_FOUND. +# CMP0091 makes CMAKE_MSVC_RUNTIME_LIBRARY authoritative for the older +# whisper.cpp CMake project; non-MSVC generators ignore both values. +CMAKE_POLICY_DEFAULT_CMP0091 = { value = "NEW", force = false } +CMAKE_MSVC_RUNTIME_LIBRARY = { value = "MultiThreaded", force = false } + +# Rust's MSVC target otherwise links VCRUNTIME/UCRT dynamically even when the +# C++ libraries above use /MT. Keep the Windows executable self-contained and +# make every cc-rs consumer observe the same crt-static target feature. +[target.x86_64-pc-windows-msvc] +rustflags = ["-C", "target-feature=+crt-static"] diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..f4b83cfe --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +src-tauri/Cargo.toml text eol=lf diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index df91812d..de8651f5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,12 +35,39 @@ concurrency: cancel-in-progress: true jobs: + motion-canvas: + name: Motion Canvas (locked build / audit / license) + if: github.event_name != 'workflow_dispatch' || inputs.red_task == 'none' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version: 22 + cache: npm + cache-dependency-path: plugins/motion-canvas-studio/package-lock.json + + - name: Install locked Motion Canvas dependencies + run: npm --prefix plugins/motion-canvas-studio ci --ignore-scripts + + - name: Audit dependencies and licenses + run: | + npm --prefix plugins/motion-canvas-studio audit --audit-level=moderate + npm --prefix plugins/motion-canvas-studio run licenses + + - name: Test and reproduce embedded runner + run: | + npm --prefix plugins/motion-canvas-studio test + npm --prefix plugins/motion-canvas-studio run build + git diff --exit-code -- plugins/motion-canvas-studio/bundle/runner.html plugins/motion-canvas-studio/package-lock.json + rust: name: Rust (fmt / clippy / test) if: github.event_name != 'workflow_dispatch' || inputs.red_task == 'none' runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # The playback engine (#53) is now a DEFAULT feature, so the workspace # fmt/clippy/test steps below compile it (cpal + axum/tokio + the wgpu @@ -72,13 +99,13 @@ jobs: fonts-dejavu-core - name: Cache cargo - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 with: path: | ~/.cargo/registry ~/.cargo/git target - key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.toml') }} + key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.toml', 'Cargo.lock') }} restore-keys: ${{ runner.os }}-cargo- - name: Validate Windows product gate contract @@ -86,6 +113,19 @@ jobs: python3 -B scripts/check_windows_product_ci.py python3 -B -m unittest discover -s scripts -p 'test_check_windows_product_ci.py' + - name: Validate release workflow contract + run: | + python3 -B scripts/check_release_workflow.py + python3 -B -m unittest discover -s scripts -p 'test_check_release_workflow.py' + + - name: Validate C1B CI and evidence contracts + run: | + ruby scripts/tests/validate-c1b-ci-test.rb + ruby scripts/tests/validate-c1b-evidence-test.rb + + - name: Provisioner unit tests (scripts/tests) + run: python3 -B -m unittest discover -s scripts/tests -p 'test_*.py' + - name: cargo fmt run: cargo fmt --all --check @@ -98,7 +138,9 @@ jobs: run: cargo clippy --workspace --all-targets -- -D warnings - name: cargo test - run: cargo test --workspace + env: + OPENTAKE_MOTION_TRACE: '1' + run: cargo test --workspace -- --test-threads=1 - name: live playback transport integration (fail closed) run: | @@ -129,7 +171,7 @@ jobs: set -euo pipefail [[ "$TARGET_SHA" =~ ^[0-9a-fA-F]{40}$ ]] - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 with: ref: ${{ env.TARGET_SHA }} fetch-depth: 0 @@ -144,27 +186,36 @@ jobs: expected="$(printf '%s' "$TARGET_SHA" | tr '[:upper:]' '[:lower:]')" test "$actual" = "$expected" git cat-file -e "${expected}^{commit}" + test "$(git rev-parse 'HEAD^{tree}')" = "$(git rev-parse "${expected}^{tree}")" test -z "$(git status --porcelain=v1 --untracked-files=all)" printf 'sha=%s\n' "$actual" >> "$GITHUB_OUTPUT" - name: Install Rust toolchain run: rustup component add rustfmt clippy - - uses: pnpm/action-setup@v4 + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 with: version: 10 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 with: node-version: 22 cache: pnpm cache-dependency-path: web/pnpm-lock.yaml - - name: Install FFmpeg - run: choco install ffmpeg --no-progress -y + - uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b + with: + ruby-version: '3.3' + + - name: Provision checksum-pinned packaged FFmpeg sidecars + run: python scripts/provision_ffmpeg_sidecars.py --target x86_64-pc-windows-msvc + + - name: Verify sidecar supply and probe/decode/encode boundary without PATH + shell: bash + run: ruby scripts/tests/packaged-sidecars-test.rb --name packaged_macos_windows_sidecars_resolve_and_execute - name: Cache cargo - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 with: path: | ~/.cargo/registry @@ -175,16 +226,260 @@ jobs: - name: Install locked Web dependencies run: pnpm -C web install --frozen-lockfile + - name: Re-assert immutable Windows product source before gates + shell: bash + env: + BOUND_SHA: ${{ steps.bind.outputs.sha }} + run: | + set -euo pipefail + actual="$(git rev-parse HEAD | tr '[:upper:]' '[:lower:]')" + expected="$(printf '%s' "$BOUND_SHA" | tr '[:upper:]' '[:lower:]')" + test "$actual" = "$expected" + git cat-file -e "${expected}^{commit}" + test "$(git rev-parse 'HEAD^{tree}')" = "$(git rev-parse "${expected}^{tree}")" + git diff --cached --quiet --exit-code + git diff --quiet --exit-code + test -z "$(git status --porcelain=v1 --untracked-files=all)" + - name: Rust formatting run: cargo fmt --all --check - name: Rust workspace clippy run: cargo clippy --workspace --all-targets -- -D warnings + - name: Windows Chromium motion capture regression + shell: bash + env: + OPENTAKE_MOTION_TRACE: '1' + run: | + cargo test -p opentake-motion --features chromium --lib \ + renderer::tests::chromium_skeleton_reports_unavailable_not_panic \ + -- --exact --nocapture --test-threads=1 + cargo test -p opentake-motion --features chromium --test chromium \ + virtual_time_network_csp_timeout_cleanup_and_frame_identity \ + -- --exact --nocapture --test-threads=1 + cargo test -p opentake-tauri --test motion_command \ + sandbox_progress_cancel_validated_mp4_result \ + -- --exact --nocapture --test-threads=1 + + - name: Windows Chromium 4K resource budget + shell: pwsh + env: + OPENTAKE_MOTION_TRACE: '1' + RECEIPT_SHA: ${{ steps.bind.outputs.sha }} + run: | + $ErrorActionPreference = 'Stop' + # macOS completes this focused test in 12.26s. 180s gives cold Windows CI + # nearly 15x headroom; 2 GiB bounds one 4K browser tree below runner capacity. + $elapsedLimitSeconds = 180 + $peakLimitBytes = 2GB + $sampleIntervalMilliseconds = 200 + $receiptPath = Join-Path $env:RUNNER_TEMP 'windows-motion-4k-resource-receipt.json' + $stdoutPath = Join-Path $env:RUNNER_TEMP 'opentake-motion-4k.stdout.log' + $stderrPath = Join-Path $env:RUNNER_TEMP 'opentake-motion-4k.stderr.log' + Remove-Item $stdoutPath, $stderrPath -Force -ErrorAction SilentlyContinue + + $cargoArguments = @( + 'test', '-p', 'opentake-motion', '--features', 'chromium', + '--test', 'chromium', + 'four_k_single_frame_opaque_and_transparent_budget_smoke', + '--', '--exact', '--nocapture', '--test-threads=1' + ) + $cargoProcess = @{ + FilePath = 'cargo.exe' + ArgumentList = $cargoArguments + NoNewWindow = $true + PassThru = $true + RedirectStandardOutput = $stdoutPath + RedirectStandardError = $stderrPath + } + $browserNames = @('chrome', 'msedge') + $baselineSignatures = @{} + $monitorErrors = [System.Collections.Generic.List[string]]::new() + $sampleCount = 0 + $peakProcessCount = 0 + [int64]$peakWorkingSetBytes = 0 + $cargoExit = -1 + $cargo = $null + $abortCargo = $false + $stopwatch = [System.Diagnostics.Stopwatch]::StartNew() + + function Get-BrowserWorkingSetSnapshot { + param([string[]]$Names) + try { + $processes = @(Get-Process -ErrorAction Stop | Where-Object { + $Names -contains $_.ProcessName.ToLowerInvariant() + }) + } catch { + throw "browser process enumeration failed: $($_.Exception.Message)" + } + $snapshot = [System.Collections.Generic.List[object]]::new() + foreach ($process in $processes) { + try { + if ($process.HasExited) { continue } + $started = $process.StartTime.ToUniversalTime().Ticks + $workingSet = [int64]$process.WorkingSet64 + $snapshot.Add([pscustomobject]@{ + signature = "$($process.ProcessName):$($process.Id):$started" + working_set_bytes = $workingSet + }) + } catch [System.InvalidOperationException] { + # A process may exit between enumeration and property reads. + continue + } catch { + throw "browser process inspection failed for pid $($process.Id): $($_.Exception.Message)" + } + } + return @($snapshot) + } + + try { + foreach ($process in @(Get-BrowserWorkingSetSnapshot -Names $browserNames)) { + $baselineSignatures[$process.signature] = $true + } + $cargo = Start-Process @cargoProcess + while (-not $cargo.HasExited) { + if ($stopwatch.Elapsed.TotalSeconds -gt $elapsedLimitSeconds) { + $monitorErrors.Add("elapsed time exceeded ${elapsedLimitSeconds}s while cargo test was running") + $abortCargo = $true + break + } + try { + $newBrowserProcesses = @( + Get-BrowserWorkingSetSnapshot -Names $browserNames | Where-Object { + -not $baselineSignatures.ContainsKey($_.signature) + } + ) + if ($newBrowserProcesses.Count -gt 0) { + [int64]$totalWorkingSetBytes = 0 + foreach ($process in $newBrowserProcesses) { + $totalWorkingSetBytes += [int64]$process.working_set_bytes + } + $sampleCount += 1 + $peakProcessCount = [Math]::Max($peakProcessCount, $newBrowserProcesses.Count) + $peakWorkingSetBytes = [Math]::Max($peakWorkingSetBytes, $totalWorkingSetBytes) + if ($peakWorkingSetBytes -gt $peakLimitBytes) { + $monitorErrors.Add("peak browser WorkingSet $peakWorkingSetBytes exceeded $peakLimitBytes bytes while cargo test was running") + $abortCargo = $true + break + } + } + } catch { + $monitorErrors.Add($_.Exception.Message) + $abortCargo = $true + break + } + Start-Sleep -Milliseconds $sampleIntervalMilliseconds + $cargo.Refresh() + } + + if ($abortCargo -and -not $cargo.HasExited) { + try { + $killer = Start-Process -FilePath 'taskkill.exe' ` + -ArgumentList @('/PID', $cargo.Id, '/T', '/F') ` + -NoNewWindow -Wait -PassThru + if ($killer.ExitCode -ne 0) { + $monitorErrors.Add("taskkill failed with exit $($killer.ExitCode)") + } + } catch { + $monitorErrors.Add("cargo process-tree cleanup failed: $($_.Exception.Message)") + } + } + if ($null -ne $cargo) { + if (-not $cargo.WaitForExit(10000)) { + $monitorErrors.Add('cargo process did not exit after bounded cleanup') + } else { + $cargoExit = $cargo.ExitCode + } + } + } catch { + $monitorErrors.Add("cargo launch or monitor failure: $($_.Exception.Message)") + } finally { + $stopwatch.Stop() + try { + if ($null -ne $cargo -and -not $cargo.HasExited) { + $killer = Start-Process -FilePath 'taskkill.exe' ` + -ArgumentList @('/PID', $cargo.Id, '/T', '/F') ` + -NoNewWindow -Wait -PassThru + if ($killer.ExitCode -ne 0) { + $monitorErrors.Add("final taskkill failed with exit $($killer.ExitCode)") + } + } + } catch { + $monitorErrors.Add("final cargo process-tree cleanup failed: $($_.Exception.Message)") + } + try { + if (Test-Path $stdoutPath -PathType Leaf) { + Get-Content $stdoutPath | Write-Output + } + } catch { + $monitorErrors.Add("cargo stdout collection failed: $($_.Exception.Message)") + } + try { + if (Test-Path $stderrPath -PathType Leaf) { + Get-Content $stderrPath | Write-Output + } + } catch { + $monitorErrors.Add("cargo stderr collection failed: $($_.Exception.Message)") + } + } + + $elapsedSeconds = [Math]::Round($stopwatch.Elapsed.TotalSeconds, 3) + if ($sampleCount -eq 0) { $monitorErrors.Add('no new Chrome or Edge working-set samples were captured') } + if ($elapsedSeconds -gt $elapsedLimitSeconds) { + $monitorErrors.Add("elapsed time ${elapsedSeconds}s exceeded ${elapsedLimitSeconds}s") + } + if ($peakWorkingSetBytes -gt $peakLimitBytes) { + $monitorErrors.Add("peak browser WorkingSet $peakWorkingSetBytes exceeded $peakLimitBytes bytes") + } + if ($env:RECEIPT_SHA -notmatch '^[0-9a-fA-F]{40}$') { + $monitorErrors.Add('receipt source SHA is not a 40-hex commit') + } + $sourceSha = [string]$env:RECEIPT_SHA + $monitorExit = if ($monitorErrors.Count -eq 0) { 0 } else { 1 } + $aggregateExit = if ($cargoExit -eq 0 -and $monitorExit -eq 0) { 0 } else { 1 } + $receipt = [ordered]@{ + schema = 'opentake-windows-motion-4k-resource-receipt-v1' + repository = '${{ github.repository }}' + run_id = '${{ github.run_id }}' + run_attempt = '${{ github.run_attempt }}' + runner_os = '${{ runner.os }}' + runner_arch = '${{ runner.arch }}' + source_sha = $sourceSha.ToLowerInvariant() + test = 'four_k_single_frame_opaque_and_transparent_budget_smoke' + process_selection = 'all chrome/msedge identities absent from the pre-test baseline' + sample_interval_ms = $sampleIntervalMilliseconds + sample_count = $sampleCount + peak_browser_process_count = $peakProcessCount + peak_working_set_bytes = $peakWorkingSetBytes + peak_working_set_limit_bytes = $peakLimitBytes + elapsed_seconds = $elapsedSeconds + elapsed_limit_seconds = $elapsedLimitSeconds + cargo_exit = $cargoExit + monitor_exit = $monitorExit + aggregate_exit = $aggregateExit + monitor_errors = @($monitorErrors) + } + $receiptJson = $receipt | ConvertTo-Json -Depth 6 -Compress + $receiptJson | Set-Content -Encoding utf8NoBOM $receiptPath + Write-Output "OPENTAKE_WINDOWS_MOTION_4K_RECEIPT=$receiptJson" + if ($aggregateExit -ne 0) { throw 'Windows Chromium 4K resource budget failed' } + + - name: Upload Windows Chromium 4K resource receipt + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: windows-motion-4k-resource-${{ steps.bind.outputs.sha }} + path: ${{ runner.temp }}/windows-motion-4k-resource-receipt.json + if-no-files-found: error + retention-days: 30 + - name: Web editor behavior suite run: pnpm -C web test - name: Rust workspace tests + env: + OPENTAKE_MOTION_TRACE: '1' run: cargo test --workspace -- --test-threads=1 - name: Minimal-feature Tauri clippy @@ -200,6 +495,55 @@ jobs: Remove-Item 'target/release/bundle/nsis' -Recurse -Force -ErrorAction SilentlyContinue & .\web\node_modules\.bin\tauri.cmd build --ci --bundles msi,nsis + - name: Install NSIS package and execute installed product without PATH + shell: pwsh + run: | + $installer = @(Get-ChildItem 'target/release/bundle/nsis/*.exe' -File) + if ($installer.Count -ne 1) { throw 'expected exactly one NSIS installer' } + $process = Start-Process -FilePath $installer[0].FullName -ArgumentList '/S' -Wait -PassThru + if ($process.ExitCode -ne 0) { throw "silent NSIS install failed: $($process.ExitCode)" } + $candidates = @( + (Join-Path $env:LOCALAPPDATA 'OpenTake/opentake.exe'), + (Join-Path $env:LOCALAPPDATA 'Programs/OpenTake/opentake.exe'), + (Join-Path $env:ProgramFiles 'OpenTake/opentake.exe') + ) + $application = $candidates | Where-Object { Test-Path $_ } | Select-Object -First 1 + if (-not $application) { + $uninstall = Get-ItemProperty 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*' ` + -ErrorAction SilentlyContinue | Where-Object { $_.DisplayName -eq 'OpenTake' } | Select-Object -First 1 + if ($uninstall.InstallLocation) { + $application = Get-ChildItem $uninstall.InstallLocation -Filter 'opentake.exe' -File ` + -ErrorAction SilentlyContinue | Select-Object -ExpandProperty FullName -First 1 + } + } + if (-not $application) { throw "installed OpenTake executable not found: $($candidates -join ', ')" } + $installDirectory = Split-Path -Parent $application + ruby scripts/tests/packaged-sidecars-test.rb ` + --name packaged_macos_windows_sidecars_resolve_and_execute ` + --package $installDirectory + $app = Start-Process -FilePath $application -PassThru + Start-Sleep -Seconds 5 + if ($app.HasExited) { + throw "installed OpenTake exited during launch smoke test: $($app.ExitCode)" + } + Stop-Process -Id $app.Id -Force + Wait-Process -Id $app.Id -ErrorAction SilentlyContinue + + - name: Re-assert immutable Windows product source after gates + shell: bash + env: + BOUND_SHA: ${{ steps.bind.outputs.sha }} + run: | + set -euo pipefail + actual="$(git rev-parse HEAD | tr '[:upper:]' '[:lower:]')" + expected="$(printf '%s' "$BOUND_SHA" | tr '[:upper:]' '[:lower:]')" + test "$actual" = "$expected" + git cat-file -e "${expected}^{commit}" + test "$(git rev-parse 'HEAD^{tree}')" = "$(git rev-parse "${expected}^{tree}")" + git diff --cached --quiet --exit-code + git diff --quiet --exit-code + test -z "$(git status --porcelain=v1 --untracked-files=all)" + - name: Bind installers to the exact source SHA shell: pwsh env: @@ -229,7 +573,7 @@ jobs: $receipt | ConvertTo-Json -Depth 6 | Set-Content -Encoding utf8NoBOM windows-product-receipt.json - name: Upload exact-SHA Windows installers - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 with: name: opentake-windows-${{ steps.bind.outputs.sha }} path: | @@ -243,57 +587,413 @@ jobs: name: Windows (cancel / reparse safety) if: github.event_name != 'workflow_dispatch' || inputs.red_task == 'none' runs-on: windows-latest + timeout-minutes: 60 + env: + TARGET_SHA: ${{ github.event_name == 'workflow_dispatch' && inputs.commit_sha || github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} steps: - - uses: actions/checkout@v4 + - name: Validate immutable Windows security SHA input + shell: bash + run: | + set -euo pipefail + [[ "$TARGET_SHA" =~ ^[0-9a-fA-F]{40}$ ]] + + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + ref: ${{ env.TARGET_SHA }} + fetch-depth: 0 + persist-credentials: false + + - name: Assert exact Windows security checkout + id: bind-security + shell: bash + run: | + set -euo pipefail + actual="$(git rev-parse HEAD | tr '[:upper:]' '[:lower:]')" + expected="$(printf '%s' "$TARGET_SHA" | tr '[:upper:]' '[:lower:]')" + test "$actual" = "$expected" + git cat-file -e "${expected}^{commit}" + test "$(git rev-parse 'HEAD^{tree}')" = "$(git rev-parse "${expected}^{tree}")" + test -z "$(git status --porcelain=v1 --untracked-files=all)" + printf 'sha=%s\n' "$actual" >> "$GITHUB_OUTPUT" - name: Install Rust toolchain run: rustup component add rustfmt - - name: Install FFmpeg - run: choco install ffmpeg --no-progress -y + - name: Provision checksum-pinned packaged FFmpeg sidecars + run: python scripts/provision_ffmpeg_sidecars.py --target x86_64-pc-windows-msvc - name: Cache cargo - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 with: path: | ~/.cargo/registry ~/.cargo/git - target - key: ${{ runner.os }}-security-cargo-${{ hashFiles('**/Cargo.toml') }} + key: ${{ runner.os }}-security-cargo-${{ hashFiles('**/Cargo.toml', 'Cargo.lock') }} restore-keys: ${{ runner.os }}-security-cargo- + - name: Re-assert immutable Windows security source before gates + shell: bash + env: + BOUND_SHA: ${{ steps.bind-security.outputs.sha }} + run: | + set -euo pipefail + actual="$(git rev-parse HEAD | tr '[:upper:]' '[:lower:]')" + expected="$(printf '%s' "$BOUND_SHA" | tr '[:upper:]' '[:lower:]')" + test "$actual" = "$expected" + git cat-file -e "${expected}^{commit}" + test "$(git rev-parse 'HEAD^{tree}')" = "$(git rev-parse "${expected}^{tree}")" + git diff --cached --quiet --exit-code + git diff --quiet --exit-code + test -z "$(git status --porcelain=v1 --untracked-files=all)" + - name: Portable FFmpeg cancellation lifecycle + id: security-cancellation shell: pwsh + env: + OPENTAKE_FFMPEG: ${{ github.workspace }}\src-tauri\binaries\ffmpeg-x86_64-pc-windows-msvc.exe run: | + & $env:OPENTAKE_FFMPEG -version + if ($LASTEXITCODE -ne 0) { throw 'checksum-pinned packaged FFmpeg is not runnable' } cargo test -p opentake-media --lib windows_cancelling_running_pcm_child_reaps_both_pipe_readers cargo test -p opentake-media --lib windows_cancelling_mux_wait_reaps_child + - name: Race-free helper process-tree containment + id: security-process-tree + shell: pwsh + run: >- + cargo test -p opentake-process-tree --lib + tests::windows_suspended_job_contains_fast_exit_descendant + -- --exact --nocapture --test-threads=1 + + - name: Verify portable Tauri test image imports + id: security-native-imports + shell: pwsh + run: | + $securityEvidenceDir = Join-Path $env:RUNNER_TEMP 'windows-security-evidence' + New-Item -ItemType Directory -Force $securityEvidenceDir | Out-Null + $importsPath = Join-Path $securityEvidenceDir 'windows-tauri-test-imports.txt' + $manifestPath = Join-Path $securityEvidenceDir 'windows-tauri-test.manifest' + $manifestLogPath = Join-Path $securityEvidenceDir 'windows-tauri-test-manifest.txt' + $exportProbePath = Join-Path $securityEvidenceDir 'windows-tauri-export-probe.txt' + cargo test -p opentake-tauri --lib --no-run + $testImage = Get-ChildItem 'target/debug/deps/opentake_tauri_lib-*.exe' -File | + Sort-Object LastWriteTime -Descending | + Select-Object -First 1 + if (-not $testImage) { throw 'compiled Tauri test image not found' } + $vswhere = Join-Path ${env:ProgramFiles(x86)} 'Microsoft Visual Studio/Installer/vswhere.exe' + $dumpbin = & $vswhere -latest -products * -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 ` + -find 'VC/Tools/MSVC/**/bin/Hostx64/x64/dumpbin.exe' | + Select-Object -First 1 + if (-not $dumpbin) { throw 'dumpbin.exe not found' } + $previousVsLang = $env:VSLANG + try { + $env:VSLANG = '1033' + $dependentsOutput = @(& $dumpbin /DEPENDENTS $testImage.FullName 2>&1) + $dependentsExit = $LASTEXITCODE + if ($dependentsExit -ne 0) { throw "dumpbin /DEPENDENTS failed: $dependentsExit" } + $importOutput = @(& $dumpbin /IMPORTS $testImage.FullName 2>&1) + $importsExit = $LASTEXITCODE + if ($importsExit -ne 0) { throw "dumpbin /IMPORTS failed: $importsExit" } + } finally { + $env:VSLANG = $previousVsLang + } + $dependentsLines = @($dependentsOutput | ForEach-Object { "$_" }) + $importLines = @($importOutput | ForEach-Object { "$_" }) + @($dependentsLines + $importLines) | Set-Content $importsPath + $imports = @($dependentsLines + $importLines) -join [Environment]::NewLine + if ($imports -match '(?im)^\s*(MSVCP140|VCRUNTIME140(?:_1)?|api-ms-win-crt-[^\s]+|onnxruntime|DirectML)\.dll\s*$') { + throw 'Tauri test image retains a non-portable native runtime dependency' + } + + $mt = Get-ChildItem (Join-Path ${env:ProgramFiles(x86)} 'Windows Kits/10/bin') ` + -Filter mt.exe -File -Recurse | + Where-Object { $_.FullName -match '\\x64\\mt\.exe$' } | + Sort-Object FullName -Descending | + Select-Object -First 1 + if (-not $mt) { throw 'mt.exe not found' } + $manifestLog = & $mt.FullName "-inputresource:$($testImage.FullName);#1" ` + "-out:$manifestPath" 2>&1 + $manifestLog | Set-Content $manifestLogPath + if ($LASTEXITCODE -ne 0) { throw 'Tauri test image has no readable RT_MANIFEST resource #1' } + Get-Content $manifestPath | Add-Content $manifestLogPath + if ((Get-Content $manifestPath -Raw) -notmatch 'Microsoft\.Windows\.Common-Controls') { + throw 'Tauri test image manifest does not activate Common Controls v6' + } + + Add-Type @' + using System; + using System.Runtime.InteropServices; + public static class OpenTakeNativeExportProbe { + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + public static extern IntPtr LoadLibraryExW(string path, IntPtr file, uint flags); + [DllImport("kernel32.dll", CharSet = CharSet.Ansi, SetLastError = true, ExactSpelling = true)] + public static extern IntPtr GetProcAddress(IntPtr module, string name); + } + '@ + + function Parse-OpenTakeDumpbinImports { + param([AllowEmptyCollection()][string[]] $Lines) + $moduleNames = [System.Collections.Generic.List[string]]::new() + $importsToProbe = [System.Collections.Generic.List[object]]::new() + $seenModules = [System.Collections.Generic.HashSet[string]]::new( + [System.StringComparer]::OrdinalIgnoreCase + ) + $currentDll = $null + $inImportSection = $false + $importSectionCount = 0 + $closedImportSectionCount = 0 + foreach ($line in $Lines) { + if ($line -match '^\s*Section contains the following imports:\s*$') { + if ($inImportSection) { throw 'nested dumpbin import section' } + $inImportSection = $true + $currentDll = $null + $importSectionCount += 1 + continue + } + if ($inImportSection -and $line -match '^\s*Summary\s*$') { + $inImportSection = $false + $currentDll = $null + $closedImportSectionCount += 1 + continue + } + if (-not $inImportSection) { continue } + if ([string]::IsNullOrWhiteSpace($line)) { continue } + if ($line -match '^\s{4}([A-Za-z0-9_.-]+\.dll)\s*$') { + $currentDll = $Matches[1] + if ($seenModules.Add($currentDll)) { $moduleNames.Add($currentDll) } + continue + } + if (-not $currentDll) { + throw "unrecognized non-empty dumpbin import line: $line" + } + if ($line -match '^\s+[0-9A-Fa-f]+\s+(?:Import Address Table|Import Name Table|time date stamp|Index of first forwarder reference)\s*$') { + continue + } + if ($line -match '^\s+[0-9A-Fa-f]+\s+(\S+)\s*$') { + $importsToProbe.Add([pscustomobject]@{ Dll = $currentDll; Name = $Matches[1] }) + continue + } + throw "unrecognized non-empty dumpbin import line: $line" + } + if ($inImportSection -or $closedImportSectionCount -ne $importSectionCount) { + throw 'dumpbin import section was not terminated by Summary' + } + if ($importSectionCount -eq 0) { throw 'dumpbin import section was not found' } + if ($moduleNames.Count -eq 0) { throw 'dumpbin import parser found no DLL modules' } + if ($importsToProbe.Count -eq 0) { throw 'dumpbin import parser found no symbols' } + return [pscustomobject]@{ + ModuleNames = @($moduleNames) + ImportsToProbe = @($importsToProbe) + } + } + + function Assert-DumpbinImportFixtureRejected { + param( + [string] $Label, + [AllowEmptyCollection()][string[]] $Lines, + [string] $ExpectedMessage + ) + $failure = $null + try { $null = Parse-OpenTakeDumpbinImports -Lines $Lines } catch { + $failure = $_.Exception.Message + } + if (-not $failure) { throw "dumpbin parser accepted rejected fixture: $Label" } + if ($failure -notlike "*$ExpectedMessage*") { + throw "dumpbin parser rejected fixture '$Label' for the wrong reason: $failure" + } + } + + function Assert-DumpbinImportFixtureAccepted { + param([string] $Label, [string[]] $Lines) + $parsed = Parse-OpenTakeDumpbinImports -Lines $Lines + if (@($parsed.ModuleNames).Count -ne 1 -or @($parsed.ImportsToProbe).Count -ne 1) { + throw "dumpbin parser misread accepted fixture: $Label" + } + } + + Assert-DumpbinImportFixtureRejected -Label 'empty output' -Lines @() ` + -ExpectedMessage 'import section was not found' + Assert-DumpbinImportFixtureRejected -Label 'localized header' -Lines @( + ' La sección contiene las importaciones siguientes:' + ) -ExpectedMessage 'import section was not found' + Assert-DumpbinImportFixtureRejected -Label 'unknown import layout' -Lines @( + ' Section contains the following imports:', + ' KERNEL32.dll', + ' localized or changed layout', + ' Summary' + ) -ExpectedMessage 'unrecognized non-empty dumpbin import line' + Assert-DumpbinImportFixtureAccepted -Label 'canonical import layout' -Lines @( + 'Dump of file fixture.exe', + '', + ' Section contains the following imports:', + '', + ' KERNEL32.dll', + ' 140001000 Import Address Table', + ' 140002000 Import Name Table', + ' 0 time date stamp', + ' 0 Index of first forwarder reference', + '', + ' 123 GetCurrentProcessId', + '', + ' Summary' + ) + + $parsedImports = Parse-OpenTakeDumpbinImports -Lines $importLines + $moduleNames = @($parsedImports.ModuleNames) + $importsToProbe = @($parsedImports.ImportsToProbe) + $modules = @{} + $missingExports = @() + foreach ($moduleName in $moduleNames) { + $modules[$moduleName] = [OpenTakeNativeExportProbe]::LoadLibraryExW( + $moduleName, [IntPtr]::Zero, 0x00000800 + ) + } + foreach ($import in $importsToProbe) { + $currentDll = $import.Dll + $name = $import.Name + $module = $modules[$currentDll] + $activationContextExport = + $currentDll -ieq 'comctl32.dll' -and $name -eq 'TaskDialogIndirect' + if (-not $activationContextExport -and ($module -eq [IntPtr]::Zero -or + [OpenTakeNativeExportProbe]::GetProcAddress($module, $name) -eq [IntPtr]::Zero)) { + $missingExports += "$currentDll!$name" + } + } + "PARSED modules=$($moduleNames.Count) imports=$($importsToProbe.Count)" | + Set-Content $exportProbePath + $modules.GetEnumerator() | Sort-Object Name | ForEach-Object { + "LOAD $($_.Name)=$($_.Value)" + } | Add-Content $exportProbePath + $missingExports | ForEach-Object { "MISSING $_" } | Add-Content $exportProbePath + if ($missingExports.Count -ne 0) { + throw "Tauri test image imports unavailable system exports: $($missingExports -join ', ')" + } + - name: Reserved output identity and reparse safety + id: security-reparse shell: pwsh run: | cargo test -p opentake-tauri --lib windows_project_media_junction_is_rejected_without_writing_target cargo test -p opentake-tauri --lib windows_directory_handoff_blocks_junction_replacement_before_child_create cargo test -p opentake-tauri --lib windows_retained_output_handle_blocks_final_name_replacement + cargo test -p opentake-tauri --lib retained_external_source_rejects_windows_reparse_contract + + - name: Re-assert immutable Windows security source after gates + if: always() + shell: bash + env: + BOUND_SHA: ${{ steps.bind-security.outputs.sha }} + run: | + set -euo pipefail + actual="$(git rev-parse HEAD | tr '[:upper:]' '[:lower:]')" + expected="$(printf '%s' "$BOUND_SHA" | tr '[:upper:]' '[:lower:]')" + test "$actual" = "$expected" + git cat-file -e "${expected}^{commit}" + test "$(git rev-parse 'HEAD^{tree}')" = "$(git rev-parse "${expected}^{tree}")" + git diff --cached --quiet --exit-code + git diff --quiet --exit-code + test -z "$(git status --porcelain=v1 --untracked-files=all)" + + - name: Build Windows security JSON receipt + if: always() + shell: pwsh + env: + RECEIPT_SHA: ${{ steps.bind-security.outputs.sha }} + run: | + $requested = $env:TARGET_SHA.ToLowerInvariant() + $checkedOut = $env:RECEIPT_SHA.ToLowerInvariant() + if ($requested -notmatch '^[0-9a-f]{40}$') { throw 'requested SHA is not immutable 40-hex' } + if ($checkedOut -notmatch '^[0-9a-f]{40}$') { throw 'checked-out SHA receipt binding is missing' } + if ($checkedOut -ne $requested) { throw 'requested and checked-out SHA differ' } + $commands = @( + [ordered]@{ + id = 'cancellation' + command = 'packaged FFmpeg -version; cargo test opentake-media Windows cancellation lifecycle' + result = '${{ steps.security-cancellation.outcome }}' + }, + [ordered]@{ + id = 'process-tree' + command = 'cargo test -p opentake-process-tree --lib tests::windows_suspended_job_contains_fast_exit_descendant -- --exact --nocapture --test-threads=1' + result = '${{ steps.security-process-tree.outcome }}' + }, + [ordered]@{ + id = 'native-imports' + command = 'cargo test -p opentake-tauri --lib --no-run; verify imports, manifest, and native exports' + result = '${{ steps.security-native-imports.outcome }}' + }, + [ordered]@{ + id = 'reparse-safety' + command = 'cargo test opentake-tauri Windows retained-output and reparse safety contracts' + result = '${{ steps.security-reparse.outcome }}' + } + ) + $aggregate = if (@($commands | Where-Object { $_.result -ne 'success' }).Count -eq 0) { 0 } else { 1 } + New-Item -ItemType Directory -Force 'windows-security-receipt' | Out-Null + $receipt = [ordered]@{ + schema = 'opentake-windows-security-receipt-v1' + repository = '${{ github.repository }}' + workflow = '${{ github.workflow }}' + workflow_file = '.github/workflows/ci.yml' + run_id = '${{ github.run_id }}' + run_attempt = '${{ github.run_attempt }}' + job = '${{ github.job }}' + event_name = '${{ github.event_name }}' + requested_sha = $env:TARGET_SHA.ToLowerInvariant() + checked_out_sha = $env:RECEIPT_SHA.ToLowerInvariant() + runner_os = '${{ runner.os }}' + runner_arch = '${{ runner.arch }}' + commands = @($commands) + aggregate_exit = $aggregate + } + $receipt | ConvertTo-Json -Depth 8 | Set-Content -Encoding utf8NoBOM 'windows-security-receipt/receipt.json' + + - name: Upload exact-SHA Windows security receipt + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: windows-security-${{ steps.bind-security.outputs.sha }} + path: | + windows-security-receipt/receipt.json + ${{ runner.temp }}/windows-security-evidence/windows-tauri-test-imports.txt + ${{ runner.temp }}/windows-security-evidence/windows-tauri-test-manifest.txt + ${{ runner.temp }}/windows-security-evidence/windows-tauri-test.manifest + ${{ runner.temp }}/windows-security-evidence/windows-tauri-export-probe.txt + if-no-files-found: error + retention-days: 30 + + - name: Enforce Windows security aggregate + if: always() + shell: pwsh + run: | + $path = 'windows-security-receipt/receipt.json' + if (-not (Test-Path $path -PathType Leaf)) { throw 'Windows security receipt is missing' } + $receipt = Get-Content $path -Raw | ConvertFrom-Json + if ($receipt.checked_out_sha -ne '${{ steps.bind-security.outputs.sha }}') { + throw 'Windows security receipt SHA is not checkout-bound' + } + if ([int]$receipt.aggregate_exit -ne 0) { throw 'Windows security aggregate failed' } + $failed = @($receipt.commands | Where-Object { $_.result -ne 'success' }) + if ($failed.Count -ne 0) { throw "Windows security gates did not all succeed: $($failed.id -join ', ')" } web: name: Web (install / build) if: github.event_name != 'workflow_dispatch' || inputs.red_task == 'none' runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 - - uses: pnpm/action-setup@v4 + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 with: version: 10 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 with: node-version: 22 cache: pnpm cache-dependency-path: web/pnpm-lock.yaml - name: pnpm install - run: pnpm -C web install + run: pnpm -C web install --frozen-lockfile - name: pnpm build run: pnpm -C web build @@ -305,34 +1005,178 @@ jobs: name: Windows (library capability security) if: github.event_name != 'workflow_dispatch' || inputs.red_task == 'none' runs-on: windows-latest + timeout-minutes: 60 + env: + TARGET_SHA: ${{ github.event_name == 'workflow_dispatch' && inputs.commit_sha || github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} steps: - - uses: actions/checkout@v4 + - name: Validate immutable Windows library SHA input + shell: bash + run: | + set -euo pipefail + [[ "$TARGET_SHA" =~ ^[0-9a-fA-F]{40}$ ]] + + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + ref: ${{ env.TARGET_SHA }} + fetch-depth: 0 + persist-credentials: false + + - name: Assert exact Windows library checkout + id: bind-library + shell: bash + run: | + set -euo pipefail + actual="$(git rev-parse HEAD | tr '[:upper:]' '[:lower:]')" + expected="$(printf '%s' "$TARGET_SHA" | tr '[:upper:]' '[:lower:]')" + test "$actual" = "$expected" + git cat-file -e "${expected}^{commit}" + test "$(git rev-parse 'HEAD^{tree}')" = "$(git rev-parse "${expected}^{tree}")" + test -z "$(git status --porcelain=v1 --untracked-files=all)" + printf 'sha=%s\n' "$actual" >> "$GITHUB_OUTPUT" - name: Install Rust toolchain run: rustup component add rustfmt clippy + - name: Provision checksum-pinned packaged FFmpeg sidecars + run: python scripts/provision_ffmpeg_sidecars.py --target x86_64-pc-windows-msvc + - name: Cache cargo - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 with: path: | ~/.cargo/registry ~/.cargo/git - target - key: ${{ runner.os }}-library-security-${{ hashFiles('**/Cargo.toml') }} + key: ${{ runner.os }}-library-security-${{ hashFiles('**/Cargo.toml', 'Cargo.lock') }} restore-keys: ${{ runner.os }}-library-security- + - name: Re-assert immutable Windows library source before gates + shell: bash + env: + BOUND_SHA: ${{ steps.bind-library.outputs.sha }} + run: | + set -euo pipefail + actual="$(git rev-parse HEAD | tr '[:upper:]' '[:lower:]')" + expected="$(printf '%s' "$BOUND_SHA" | tr '[:upper:]' '[:lower:]')" + test "$actual" = "$expected" + git cat-file -e "${expected}^{commit}" + test "$(git rev-parse 'HEAD^{tree}')" = "$(git rev-parse "${expected}^{tree}")" + git diff --cached --quiet --exit-code + git diff --quiet --exit-code + test -z "$(git status --porcelain=v1 --untracked-files=all)" + - name: Test retained-handle and junction defenses + id: library-media + shell: pwsh run: cargo test -p opentake-media library::tests -- --test-threads=1 - name: Test complete bundle publication and recovery + id: library-project + shell: pwsh run: cargo test -p opentake-project -- --test-threads=1 - name: Test Tauri project-library commit guards + id: library-tauri + shell: pwsh run: cargo test -p opentake-tauri library::tests -- --test-threads=1 - name: Clippy capability-backed library + id: library-clippy + shell: pwsh run: cargo clippy -p opentake-media --all-targets -- -D warnings + - name: Re-assert immutable Windows library source after gates + if: always() + shell: bash + env: + BOUND_SHA: ${{ steps.bind-library.outputs.sha }} + run: | + set -euo pipefail + actual="$(git rev-parse HEAD | tr '[:upper:]' '[:lower:]')" + expected="$(printf '%s' "$BOUND_SHA" | tr '[:upper:]' '[:lower:]')" + test "$actual" = "$expected" + git cat-file -e "${expected}^{commit}" + test "$(git rev-parse 'HEAD^{tree}')" = "$(git rev-parse "${expected}^{tree}")" + git diff --cached --quiet --exit-code + git diff --quiet --exit-code + test -z "$(git status --porcelain=v1 --untracked-files=all)" + + - name: Build Windows library security JSON receipt + if: always() + shell: pwsh + env: + RECEIPT_SHA: ${{ steps.bind-library.outputs.sha }} + run: | + $requested = $env:TARGET_SHA.ToLowerInvariant() + $checkedOut = $env:RECEIPT_SHA.ToLowerInvariant() + if ($requested -notmatch '^[0-9a-f]{40}$') { throw 'requested SHA is not immutable 40-hex' } + if ($checkedOut -notmatch '^[0-9a-f]{40}$') { throw 'checked-out SHA receipt binding is missing' } + if ($checkedOut -ne $requested) { throw 'requested and checked-out SHA differ' } + $commands = @( + [ordered]@{ + id = 'media-library' + command = 'cargo test -p opentake-media library::tests -- --test-threads=1' + result = '${{ steps.library-media.outcome }}' + }, + [ordered]@{ + id = 'project' + command = 'cargo test -p opentake-project -- --test-threads=1' + result = '${{ steps.library-project.outcome }}' + }, + [ordered]@{ + id = 'tauri-library' + command = 'cargo test -p opentake-tauri library::tests -- --test-threads=1' + result = '${{ steps.library-tauri.outcome }}' + }, + [ordered]@{ + id = 'media-clippy' + command = 'cargo clippy -p opentake-media --all-targets -- -D warnings' + result = '${{ steps.library-clippy.outcome }}' + } + ) + $aggregate = if (@($commands | Where-Object { $_.result -ne 'success' }).Count -eq 0) { 0 } else { 1 } + New-Item -ItemType Directory -Force 'windows-library-security-receipt' | Out-Null + $receipt = [ordered]@{ + schema = 'opentake-windows-library-security-receipt-v1' + repository = '${{ github.repository }}' + workflow = '${{ github.workflow }}' + workflow_file = '.github/workflows/ci.yml' + run_id = '${{ github.run_id }}' + run_attempt = '${{ github.run_attempt }}' + job = '${{ github.job }}' + event_name = '${{ github.event_name }}' + requested_sha = $env:TARGET_SHA.ToLowerInvariant() + checked_out_sha = $env:RECEIPT_SHA.ToLowerInvariant() + runner_os = '${{ runner.os }}' + runner_arch = '${{ runner.arch }}' + commands = @($commands) + aggregate_exit = $aggregate + } + $receipt | ConvertTo-Json -Depth 8 | Set-Content -Encoding utf8NoBOM 'windows-library-security-receipt/receipt.json' + + - name: Upload exact-SHA Windows library security receipt + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: windows-library-security-${{ steps.bind-library.outputs.sha }} + path: | + windows-library-security-receipt/receipt.json + if-no-files-found: error + retention-days: 30 + + - name: Enforce Windows library security aggregate + if: always() + shell: pwsh + run: | + $path = 'windows-library-security-receipt/receipt.json' + if (-not (Test-Path $path -PathType Leaf)) { throw 'Windows library security receipt is missing' } + $receipt = Get-Content $path -Raw | ConvertFrom-Json + if ($receipt.checked_out_sha -ne '${{ steps.bind-library.outputs.sha }}') { + throw 'Windows library security receipt SHA is not checkout-bound' + } + if ([int]$receipt.aggregate_exit -ne 0) { throw 'Windows library security aggregate failed' } + $failed = @($receipt.commands | Where-Object { $_.result -ne 'success' }) + if ($failed.Count -ne 0) { throw "Windows library security gates did not all succeed: $($failed.id -join ', ')" } + safe-filesystem: name: Safe filesystem (${{ matrix.receipt_id }}) if: github.event_name != 'workflow_dispatch' || inputs.red_task == 'none' @@ -356,14 +1200,13 @@ jobs: timeout-minutes: 35 env: TARGET_SHA: ${{ github.event_name == 'workflow_dispatch' && inputs.commit_sha || github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} - RECEIPT_DIR: c1b-native-receipt steps: - name: Validate immutable SHA input shell: bash run: | set -euo pipefail [[ "$TARGET_SHA" =~ ^[0-9a-fA-F]{40}$ ]] - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 with: ref: ${{ env.TARGET_SHA }} fetch-depth: 0 @@ -382,7 +1225,7 @@ jobs: shell: bash run: rustup component add rustfmt clippy - name: Cache cargo - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 with: path: | ~/.cargo/registry @@ -401,15 +1244,22 @@ jobs: if ($errors.Count -ne 0) { throw ($errors | Out-String) } - name: Re-assert immutable target before native gates shell: bash + env: + BOUND_SHA: ${{ steps.bind.outputs.sha }} run: | set -euo pipefail actual="$(git rev-parse HEAD | tr '[:upper:]' '[:lower:]')" - expected="$(printf '%s' "$TARGET_SHA" | tr '[:upper:]' '[:lower:]')" + expected="$(printf '%s' "$BOUND_SHA" | tr '[:upper:]' '[:lower:]')" test "$actual" = "$expected" + git cat-file -e "${expected}^{commit}" test "$(git rev-parse 'HEAD^{tree}')" = "$(git rev-parse "${expected}^{tree}")" + git diff --cached --quiet --exit-code + git diff --quiet --exit-code test -z "$(git status --porcelain=v1 --untracked-files=all)" - name: Run all native gates and retain every exit shell: bash + env: + RECEIPT_DIR: ${{ runner.temp }}/c1b-native-receipt run: | set -u mkdir "$RECEIPT_DIR" @@ -435,10 +1285,26 @@ jobs: run_gate safe-fs-unit cargo test -p opentake-project --lib safe_fs -- --test-threads=1 run_gate archive-security cargo test -p opentake-project --test archive_security -- --test-threads=1 printf '%s\n' "$aggregate" >"$RECEIPT_DIR/final-aggregate.raw-exit" + - name: Re-assert immutable target after native gates + if: always() + shell: bash + env: + BOUND_SHA: ${{ steps.bind.outputs.sha }} + run: | + set -euo pipefail + actual="$(git rev-parse HEAD | tr '[:upper:]' '[:lower:]')" + expected="$(printf '%s' "$BOUND_SHA" | tr '[:upper:]' '[:lower:]')" + test "$actual" = "$expected" + git cat-file -e "${expected}^{commit}" + test "$(git rev-parse 'HEAD^{tree}')" = "$(git rev-parse "${expected}^{tree}")" + git diff --cached --quiet --exit-code + git diff --quiet --exit-code + test -z "$(git status --porcelain=v1 --untracked-files=all)" - name: Build exclusive JSON receipt if: always() shell: pwsh env: + RECEIPT_DIR: ${{ runner.temp }}/c1b-native-receipt RECEIPT_SHA: ${{ steps.bind.outputs.sha }} RECEIPT_ID: ${{ matrix.receipt_id }} RUNNER_LABEL: ${{ matrix.runner }} @@ -479,15 +1345,17 @@ jobs: $receipt | ConvertTo-Json -Depth 8 | Set-Content -Encoding utf8NoBOM (Join-Path $env:RECEIPT_DIR 'receipt.json') - name: Upload immutable native receipt if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 with: name: c1b-native-${{ matrix.receipt_id }}-${{ steps.bind.outputs.sha }} - path: c1b-native-receipt/ + path: ${{ runner.temp }}/c1b-native-receipt/ if-no-files-found: error retention-days: 30 - name: Enforce native aggregate if: always() shell: bash + env: + RECEIPT_DIR: ${{ runner.temp }}/c1b-native-receipt run: | set -euo pipefail test -f "$RECEIPT_DIR/final-aggregate.raw-exit" @@ -513,7 +1381,7 @@ jobs: if ($env:PARENT_SHA -cnotmatch '^[0-9a-f]{40}$') { throw 'red_parent_sha must be lower 40-hex' } if ($env:RED_NONCE -cnotmatch '^[0-9a-f]{16}$') { throw 'red_nonce must be unique lower 16-hex' } - name: Checkout trusted RED dispatcher - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 with: ref: ${{ env.DISPATCHER_SHA }} fetch-depth: 1 @@ -529,7 +1397,7 @@ jobs: if ($actual -cne $env:DISPATCHER_SHA) { throw 'RED dispatcher SHA mismatch' } "sha=$actual" >> $env:GITHUB_OUTPUT - name: Checkout RED target - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 with: ref: ${{ env.TARGET_SHA }} fetch-depth: 2 @@ -551,6 +1419,21 @@ jobs: throw 'RED commit changed paths outside windows.rs' } "sha=$actual" >> $env:GITHUB_OUTPUT + - name: Re-assert immutable RED sources before focused gate + shell: pwsh + run: | + $dispatcher = (git -C c1b-dispatcher rev-parse HEAD).Trim().ToLowerInvariant() + $target = (git -C c1b-target rev-parse HEAD).Trim().ToLowerInvariant() + if ($dispatcher -cne '${{ steps.bind-dispatcher.outputs.sha }}') { throw 'RED dispatcher moved after bind' } + if ($target -cne '${{ steps.bind-red.outputs.sha }}') { throw 'RED target moved after bind' } + if ((git -C c1b-dispatcher rev-parse 'HEAD^{tree}').Trim() -cne (git -C c1b-dispatcher rev-parse "${dispatcher}^{tree}").Trim()) { throw 'RED dispatcher tree mismatch' } + if ((git -C c1b-target rev-parse 'HEAD^{tree}').Trim() -cne (git -C c1b-target rev-parse "${target}^{tree}").Trim()) { throw 'RED target tree mismatch' } + git -C c1b-dispatcher diff --cached --quiet --exit-code + git -C c1b-dispatcher diff --quiet --exit-code + git -C c1b-target diff --cached --quiet --exit-code + git -C c1b-target diff --quiet --exit-code + if (@(git -C c1b-dispatcher status --porcelain=v1 --untracked-files=all).Count -ne 0) { throw 'RED dispatcher worktree is not clean' } + if (@(git -C c1b-target status --porcelain=v1 --untracked-files=all).Count -ne 0) { throw 'RED target worktree is not clean' } - name: Run focused expected-RED contract shell: pwsh run: | @@ -558,9 +1441,25 @@ jobs: ../c1b-dispatcher/scripts/run-c1b-windows-red.ps1 ` -Task $env:RED_TASK -TestSha $env:TARGET_SHA -ParentSha $env:PARENT_SHA ` -Nonce $env:RED_NONCE -EvidenceRoot (Join-Path $env:RUNNER_TEMP 'c1b-red') + - name: Re-assert immutable RED sources after focused gate + if: always() + shell: pwsh + run: | + $dispatcher = (git -C c1b-dispatcher rev-parse HEAD).Trim().ToLowerInvariant() + $target = (git -C c1b-target rev-parse HEAD).Trim().ToLowerInvariant() + if ($dispatcher -cne '${{ steps.bind-dispatcher.outputs.sha }}') { throw 'RED dispatcher moved after gate' } + if ($target -cne '${{ steps.bind-red.outputs.sha }}') { throw 'RED target moved after gate' } + if ((git -C c1b-dispatcher rev-parse 'HEAD^{tree}').Trim() -cne (git -C c1b-dispatcher rev-parse "${dispatcher}^{tree}").Trim()) { throw 'RED dispatcher tree mismatch' } + if ((git -C c1b-target rev-parse 'HEAD^{tree}').Trim() -cne (git -C c1b-target rev-parse "${target}^{tree}").Trim()) { throw 'RED target tree mismatch' } + git -C c1b-dispatcher diff --cached --quiet --exit-code + git -C c1b-dispatcher diff --quiet --exit-code + git -C c1b-target diff --cached --quiet --exit-code + git -C c1b-target diff --quiet --exit-code + if (@(git -C c1b-dispatcher status --porcelain=v1 --untracked-files=all).Count -ne 0) { throw 'RED dispatcher worktree is not clean' } + if (@(git -C c1b-target status --porcelain=v1 --untracked-files=all).Count -ne 0) { throw 'RED target worktree is not clean' } - name: Upload immutable Windows RED receipt if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 with: name: c1b-red-${{ inputs.red_task }}-${{ steps.bind-red.outputs.sha }}-${{ inputs.red_nonce }} path: ${{ runner.temp }}/c1b-red/c1b-task-${{ inputs.red_task }}-${{ steps.bind-red.outputs.sha }}-${{ inputs.red_nonce }}/ diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..630a9d0a --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,943 @@ +name: Release + +on: + push: + tags: ['v*'] + workflow_dispatch: + inputs: + tag: + description: Existing v tag from a failed run; this workflow never creates or moves tags + required: true + type: string + +permissions: + contents: read + +concurrency: + group: release-${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }} + cancel-in-progress: false + +jobs: + validate: + name: Validate immutable release source + runs-on: ubuntu-latest + env: + RELEASE_TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }} + outputs: + source_sha: ${{ steps.bind.outputs.source_sha }} + tag: ${{ steps.bind.outputs.tag }} + version: ${{ steps.bind.outputs.version }} + notes_path: ${{ steps.bind.outputs.notes_path }} + prerelease: ${{ steps.bind.outputs.prerelease }} + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + ref: ${{ env.RELEASE_TAG }} + fetch-depth: 0 + persist-credentials: false + + - name: Validate tag, source SHA, versions, and notes + id: bind + shell: bash + run: | + set -euo pipefail + git show-ref --verify --quiet "refs/tags/${RELEASE_TAG}" + source_sha="$(git rev-parse "${RELEASE_TAG}^{commit}" | tr '[:upper:]' '[:lower:]')" + actual="$(git rev-parse HEAD | tr '[:upper:]' '[:lower:]')" + test "$actual" = "$source_sha" + git cat-file -e "${source_sha}^{commit}" + test -z "$(git status --porcelain=v1 --untracked-files=all)" + + read -r remote_main remote_ref < <(git ls-remote --exit-code origin refs/heads/main) + remote_main="$(printf '%s' "$remote_main" | tr '[:upper:]' '[:lower:]')" + test "$remote_ref" = "refs/heads/main" + if [[ "$source_sha" != "$remote_main" ]]; then + echo "tag commit does not equal current remote main HEAD" >&2 + exit 1 + fi + + printf 'source_sha=%s\n' "$source_sha" >> "$GITHUB_OUTPUT" + RELEASE_TAG="$RELEASE_TAG" python3 - <<'PY' + import json + import os + from pathlib import Path + import re + import tomllib + + numeric = r"(?:0|[1-9][0-9]*)" + identifier = r"(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)" + SEMVER_RE = re.compile( + rf"^v{numeric}\.{numeric}\.{numeric}" + rf"(?:-{identifier}(?:\.{identifier})*)?" + r"(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$" + ) + tag = os.environ["RELEASE_TAG"] + if SEMVER_RE.fullmatch(tag) is None: + raise SystemExit(f"release tag is not v: {tag}") + version = tag[1:] + cargo = tomllib.loads(Path("Cargo.toml").read_text(encoding="utf-8")) + tauri = json.loads(Path("src-tauri/tauri.conf.json").read_text(encoding="utf-8")) + web = json.loads(Path("web/package.json").read_text(encoding="utf-8")) + versions = { + cargo["workspace"]["package"]["version"], + tauri["version"], + web["version"], + } + if versions != {version}: + raise SystemExit(f"tag/version mismatch: {tag} != {sorted(versions)}") + notes = Path("docs/releases") / f"{version}.md" + if not notes.is_file() or not notes.read_text(encoding="utf-8").strip(): + raise SystemExit(f"release notes are missing or empty: {notes}") + prerelease = "-" in version.split("+", 1)[0] + if version == "1.0.0-beta.2" and not prerelease: + raise SystemExit("OpenTake 1.0.0-beta.2 must remain a prerelease") + if not prerelease: + raise SystemExit("this release workflow publishes prereleases only") + + def emit(name: str, value: str) -> None: + with Path(os.environ["GITHUB_OUTPUT"]).open("a", encoding="utf-8") as stream: + stream.write(f"{name}={value}\n") + + emit("tag", tag) + emit("version", version) + emit("notes_path", notes.as_posix()) + emit("prerelease", "true") + PY + + - name: Reassert exact source after validation + env: + EXPECTED_SHA: ${{ steps.bind.outputs.source_sha }} + shell: bash + run: | + set -euo pipefail + actual="$(git rev-parse HEAD | tr '[:upper:]' '[:lower:]')" + expected="$(printf '%s' "$EXPECTED_SHA" | tr '[:upper:]' '[:lower:]')" + test "$actual" = "$expected" + git cat-file -e "${expected}^{commit}" + test "$(git rev-parse "${actual}^{tree}")" = "$(git rev-parse "${expected}^{tree}")" + git diff --quiet --exit-code HEAD -- + git diff --cached --quiet --exit-code HEAD -- + test -z "$(git status --porcelain=v1 --untracked-files=all)" + + quality: + name: Release quality gates + needs: validate + runs-on: ubuntu-latest + timeout-minutes: 120 + env: + TARGET_SHA: ${{ needs.validate.outputs.source_sha }} + CI: true + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + ref: ${{ needs.validate.outputs.source_sha }} + fetch-depth: 0 + persist-credentials: false + + - name: Assert exact checked-out SHA + shell: bash + run: | + set -euo pipefail + actual="$(git rev-parse HEAD | tr '[:upper:]' '[:lower:]')" + expected="$(printf '%s' "$TARGET_SHA" | tr '[:upper:]' '[:lower:]')" + test "$actual" = "$expected" + git cat-file -e "${expected}^{commit}" + test -z "$(git status --porcelain=v1 --untracked-files=all)" + + - name: Free disk space + run: | + sudo rm -rf /usr/share/dotnet /opt/ghc /usr/local/lib/android "$AGENT_TOOLSDIRECTORY" /opt/hostedtoolcache/CodeQL || true + sudo docker image prune --all --force || true + df -h / + + - name: Install Rust toolchain + run: rustup component add rustfmt clippy + + - name: Install system deps (ffmpeg + Tauri/GTK) + run: | + sudo apt-get update + sudo apt-get install -y \ + ffmpeg \ + libwebkit2gtk-4.1-dev \ + libgtk-3-dev \ + libayatana-appindicator3-dev \ + librsvg2-dev \ + libasound2-dev \ + libglib2.0-dev \ + libsoup-3.0-dev \ + patchelf \ + pkg-config \ + fonts-dejavu-core + + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 + with: + version: 10 + + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 22 + cache: pnpm + cache-dependency-path: web/pnpm-lock.yaml + + - uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1 + with: + ruby-version: '3.3' + + - name: Cache Cargo dependencies + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: release-quality-cargo-${{ hashFiles('**/Cargo.toml', 'Cargo.lock') }} + restore-keys: release-quality-cargo- + + - name: Install locked Motion Canvas dependencies + run: npm --prefix plugins/motion-canvas-studio ci --ignore-scripts + + - name: Audit Motion Canvas dependencies and licenses + run: | + npm --prefix plugins/motion-canvas-studio audit --audit-level=moderate + npm --prefix plugins/motion-canvas-studio run licenses + + - name: Test and reproduce Motion Canvas runner + run: | + npm --prefix plugins/motion-canvas-studio test + npm --prefix plugins/motion-canvas-studio run build + git diff --exit-code -- plugins/motion-canvas-studio/bundle/runner.html plugins/motion-canvas-studio/package-lock.json + + - name: Validate Windows and release workflow contracts + run: | + python3 -B scripts/check_windows_product_ci.py + python3 -B -m unittest discover -s scripts -p 'test_check_windows_product_ci.py' + python3 -B scripts/check_release_workflow.py + python3 -B -m unittest discover -s scripts -p 'test_check_release_workflow.py' + + - name: Provisioner unit tests + run: python3 -B -m unittest discover -s scripts/tests -p 'test_*.py' + + - name: Install locked Web dependencies + run: pnpm -C web install --frozen-lockfile + + - name: Rust formatting + run: cargo fmt --all --check + + - name: Rust workspace clippy + run: cargo clippy --workspace --all-targets -- -D warnings + + - name: Rust workspace tests + env: + OPENTAKE_MOTION_TRACE: '1' + run: cargo test --workspace -- --test-threads=1 + + - name: Live playback transport integration + run: | + set -euo pipefail + cargo test -p opentake-tauri \ + --features playback-engine \ + --test playback_transport_integration \ + -- --test-threads=1 + + - name: Minimal-feature Tauri clippy + run: cargo clippy -p opentake-tauri --no-default-features --all-targets -- -D warnings + + - name: Web editor behavior suite + run: pnpm -C web test + + - name: Web production build + run: pnpm -C web build + + - name: Reassert exact source after quality gates + env: + EXPECTED_SHA: ${{ needs.validate.outputs.source_sha }} + shell: bash + run: | + set -euo pipefail + actual="$(git rev-parse HEAD | tr '[:upper:]' '[:lower:]')" + expected="$(printf '%s' "$EXPECTED_SHA" | tr '[:upper:]' '[:lower:]')" + test "$actual" = "$expected" + git cat-file -e "${expected}^{commit}" + test "$(git rev-parse "${actual}^{tree}")" = "$(git rev-parse "${expected}^{tree}")" + git diff --quiet --exit-code HEAD -- + git diff --cached --quiet --exit-code HEAD -- + test -z "$(git status --porcelain=v1 --untracked-files=all)" + + macos_arm64: + name: macOS ARM64 app and DMG + needs: validate + runs-on: macos-14 + timeout-minutes: 120 + env: + TARGET_SHA: ${{ needs.validate.outputs.source_sha }} + APPLE_SIGNING_IDENTITY: '-' + CI: true + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + ref: ${{ needs.validate.outputs.source_sha }} + fetch-depth: 0 + persist-credentials: false + + - name: Assert exact checked-out SHA + shell: bash + run: | + set -euo pipefail + actual="$(git rev-parse HEAD | tr '[:upper:]' '[:lower:]')" + expected="$(printf '%s' "$TARGET_SHA" | tr '[:upper:]' '[:lower:]')" + test "$actual" = "$expected" + git cat-file -e "${expected}^{commit}" + test -z "$(git status --porcelain=v1 --untracked-files=all)" + + - name: Install Rust toolchain + run: rustup component add rustfmt clippy + + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 + with: + version: 10 + + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 22 + cache: pnpm + cache-dependency-path: web/pnpm-lock.yaml + + - uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1 + with: + ruby-version: '3.3' + + - name: Provision checksum-pinned ARM64 FFmpeg sidecars + run: python3 scripts/provision_ffmpeg_sidecars.py --target aarch64-apple-darwin + + - name: Verify pinned sidecar supply + run: ruby scripts/tests/packaged-sidecars-test.rb --name packaged_macos_windows_sidecars_resolve_and_execute + + - name: Install locked Web dependencies + run: pnpm -C web install --frozen-lockfile + + - name: Reassert exact source before macOS build + env: + EXPECTED_SHA: ${{ needs.validate.outputs.source_sha }} + shell: bash + run: | + set -euo pipefail + actual="$(git rev-parse HEAD | tr '[:upper:]' '[:lower:]')" + expected="$(printf '%s' "$EXPECTED_SHA" | tr '[:upper:]' '[:lower:]')" + test "$actual" = "$expected" + git cat-file -e "${expected}^{commit}" + test "$(git rev-parse "${actual}^{tree}")" = "$(git rev-parse "${expected}^{tree}")" + git diff --quiet --exit-code HEAD -- + git diff --cached --quiet --exit-code HEAD -- + test -z "$(git status --porcelain=v1 --untracked-files=all)" + + - name: Build ad-hoc Tauri app and DMG + run: >- + ./web/node_modules/.bin/tauri build --ci + --target aarch64-apple-darwin --bundles app,dmg + --config '{"bundle":{"macOS":{"signingIdentity":"-"}}}' + + - name: Verify complete app, sidecars, and DMG + shell: bash + run: | + set -euo pipefail + bundle_root="target/aarch64-apple-darwin/release/bundle" + test "$(find "$bundle_root/macos" -maxdepth 1 -type d -name '*.app' | wc -l | tr -d ' ')" -eq 1 + test "$(find "$bundle_root/dmg" -maxdepth 1 -type f -name '*.dmg' | wc -l | tr -d ' ')" -eq 1 + app="$(find "$bundle_root/macos" -maxdepth 1 -type d -name '*.app' -print -quit)" + dmg="$(find "$bundle_root/dmg" -maxdepth 1 -type f -name '*.dmg' -print -quit)" + test -f "$app/Contents/MacOS/ffmpeg" + test -f "$app/Contents/MacOS/ffprobe" + codesign --verify --deep --strict --verbose=2 "$app" + codesign --verify --strict --verbose=2 "$app/Contents/MacOS/ffmpeg" + codesign --verify --strict --verbose=2 "$app/Contents/MacOS/ffprobe" + codesign -dv --verbose=4 "$app" 2>&1 | grep -F 'Signature=adhoc' + codesign -dv --verbose=4 "$app/Contents/MacOS/ffmpeg" 2>&1 | grep -F 'Signature=adhoc' + codesign -dv --verbose=4 "$app/Contents/MacOS/ffprobe" 2>&1 | grep -F 'Signature=adhoc' + hdiutil verify "$dmg" + + mountpoint="$RUNNER_TEMP/opentake-release-dmg-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" + mkdir -p "$mountpoint" + cleanup() { hdiutil detach "$mountpoint" -force >/dev/null 2>&1 || true; } + trap cleanup EXIT + hdiutil attach "$dmg" -nobrowse -readonly -mountpoint "$mountpoint" + test "$(find "$mountpoint" -maxdepth 1 -type d -name '*.app' | wc -l | tr -d ' ')" -eq 1 + mounted_app="$(find "$mountpoint" -maxdepth 1 -type d -name '*.app' -print -quit)" + codesign --verify --deep --strict --verbose=2 "$mounted_app" + codesign --verify --strict --verbose=2 "$mounted_app/Contents/MacOS/ffmpeg" + codesign --verify --strict --verbose=2 "$mounted_app/Contents/MacOS/ffprobe" + ruby scripts/tests/packaged-sidecars-test.rb \ + --name packaged_macos_windows_sidecars_resolve_and_execute \ + --package "$mounted_app" + cleanup + trap - EXIT + + - name: Reassert exact source after macOS packaging + env: + EXPECTED_SHA: ${{ needs.validate.outputs.source_sha }} + shell: bash + run: | + set -euo pipefail + actual="$(git rev-parse HEAD | tr '[:upper:]' '[:lower:]')" + expected="$(printf '%s' "$EXPECTED_SHA" | tr '[:upper:]' '[:lower:]')" + test "$actual" = "$expected" + git cat-file -e "${expected}^{commit}" + test "$(git rev-parse "${actual}^{tree}")" = "$(git rev-parse "${expected}^{tree}")" + git diff --quiet --exit-code HEAD -- + git diff --cached --quiet --exit-code HEAD -- + test -z "$(git status --porcelain=v1 --untracked-files=all)" + + - name: Create macOS exact-SHA receipt + env: + RECEIPT_SHA: ${{ needs.validate.outputs.source_sha }} + run: | + python3 - <<'PY' + import hashlib + import json + import os + from pathlib import Path + + def sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + matches = list(Path("target/aarch64-apple-darwin/release/bundle/dmg").glob("*.dmg")) + if len(matches) != 1: + raise SystemExit(f"expected exactly one DMG, found {len(matches)}") + artifact = matches[0] + receipt = { + "schema": "opentake-macos-arm64-receipt-v1", + "repository": os.environ["GITHUB_REPOSITORY"], + "run_id": os.environ["GITHUB_RUN_ID"], + "run_attempt": os.environ["GITHUB_RUN_ATTEMPT"], + "source_sha": os.environ["RECEIPT_SHA"], + "signature_mode": "ad-hoc", + "artifact": { + "name": artifact.name, + "sha256": sha256(artifact), + "bytes": artifact.stat().st_size, + }, + } + Path("macos-arm64-receipt.json").write_text( + json.dumps(receipt, indent=2, sort_keys=True) + "\n", encoding="utf-8" + ) + PY + + - name: Upload exact-SHA macOS package + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: opentake-macos-arm64-${{ needs.validate.outputs.source_sha }} + path: | + target/aarch64-apple-darwin/release/bundle/dmg/*.dmg + macos-arm64-receipt.json + if-no-files-found: error + retention-days: 30 + + windows_x64: + name: Windows x64 MSI and NSIS + needs: validate + runs-on: windows-2022 + timeout-minutes: 120 + env: + TARGET_SHA: ${{ needs.validate.outputs.source_sha }} + CI: true + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + ref: ${{ needs.validate.outputs.source_sha }} + fetch-depth: 0 + persist-credentials: false + + - name: Assert exact checked-out SHA + shell: bash + run: | + set -euo pipefail + actual="$(git rev-parse HEAD | tr '[:upper:]' '[:lower:]')" + expected="$(printf '%s' "$TARGET_SHA" | tr '[:upper:]' '[:lower:]')" + test "$actual" = "$expected" + git cat-file -e "${expected}^{commit}" + test -z "$(git status --porcelain=v1 --untracked-files=all)" + + - name: Install Rust toolchain + run: rustup component add rustfmt clippy + + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 + with: + version: 10 + + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 22 + cache: pnpm + cache-dependency-path: web/pnpm-lock.yaml + + - uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1 + with: + ruby-version: '3.3' + + - name: Provision checksum-pinned Windows FFmpeg sidecars + run: python scripts/provision_ffmpeg_sidecars.py --target x86_64-pc-windows-msvc + + - name: Verify pinned sidecar supply + shell: bash + run: ruby scripts/tests/packaged-sidecars-test.rb --name packaged_macos_windows_sidecars_resolve_and_execute + + - name: Cache Cargo dependencies + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + key: release-windows-cargo-${{ hashFiles('**/Cargo.toml', 'Cargo.lock') }} + restore-keys: release-windows-cargo- + + - name: Install locked Web dependencies + run: pnpm -C web install --frozen-lockfile + + - name: Rust workspace clippy + run: cargo clippy --workspace --all-targets -- -D warnings + + - name: Rust workspace tests + env: + OPENTAKE_MOTION_TRACE: '1' + run: cargo test --workspace -- --test-threads=1 + + - name: Web editor behavior suite + run: pnpm -C web test + + - name: Minimal-feature Tauri clippy + run: cargo clippy -p opentake-tauri --no-default-features --all-targets -- -D warnings + + - name: Web production build + run: pnpm -C web build + + - name: Reassert exact source before Windows build + env: + EXPECTED_SHA: ${{ needs.validate.outputs.source_sha }} + shell: bash + run: | + set -euo pipefail + actual="$(git rev-parse HEAD | tr '[:upper:]' '[:lower:]')" + expected="$(printf '%s' "$EXPECTED_SHA" | tr '[:upper:]' '[:lower:]')" + test "$actual" = "$expected" + git cat-file -e "${expected}^{commit}" + test "$(git rev-parse "${actual}^{tree}")" = "$(git rev-parse "${expected}^{tree}")" + git diff --quiet --exit-code HEAD -- + git diff --cached --quiet --exit-code HEAD -- + test -z "$(git status --porcelain=v1 --untracked-files=all)" + + - name: Build native MSI and NSIS installers + shell: pwsh + run: | + Remove-Item 'target/release/bundle/msi' -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item 'target/release/bundle/nsis' -Recurse -Force -ErrorAction SilentlyContinue + & .\web\node_modules\.bin\tauri.cmd build --ci --bundles msi,nsis + + - name: Install NSIS and smoke installed app and sidecars + shell: pwsh + run: | + $msi = @(Get-ChildItem 'target/release/bundle/msi/*.msi' -File) + $installer = @(Get-ChildItem 'target/release/bundle/nsis/*.exe' -File) + if ($msi.Count -ne 1) { throw 'expected exactly one MSI installer' } + if ($installer.Count -ne 1) { throw 'expected exactly one NSIS installer' } + $install = Start-Process -FilePath $installer[0].FullName -ArgumentList '/S' -Wait -PassThru + if ($install.ExitCode -ne 0) { throw "silent NSIS install failed: $($install.ExitCode)" } + $candidates = @( + (Join-Path $env:LOCALAPPDATA 'OpenTake/opentake.exe'), + (Join-Path $env:LOCALAPPDATA 'Programs/OpenTake/opentake.exe'), + (Join-Path $env:ProgramFiles 'OpenTake/opentake.exe') + ) + $application = $candidates | Where-Object { Test-Path $_ } | Select-Object -First 1 + if (-not $application) { + $uninstall = Get-ItemProperty 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*' ` + -ErrorAction SilentlyContinue | Where-Object { $_.DisplayName -eq 'OpenTake' } | Select-Object -First 1 + if ($uninstall.InstallLocation) { + $application = Get-ChildItem $uninstall.InstallLocation -Filter 'opentake.exe' -File ` + -ErrorAction SilentlyContinue | Select-Object -ExpandProperty FullName -First 1 + } + } + if (-not $application) { throw "installed OpenTake executable not found: $($candidates -join ', ')" } + $installDirectory = Split-Path -Parent $application + ruby scripts/tests/packaged-sidecars-test.rb ` + --name packaged_macos_windows_sidecars_resolve_and_execute ` + --package $installDirectory + $app = Start-Process -FilePath $application -PassThru + Start-Sleep -Seconds 5 + if ($app.HasExited) { throw "installed OpenTake exited during launch smoke test: $($app.ExitCode)" } + Stop-Process -Id $app.Id -Force + Wait-Process -Id $app.Id -ErrorAction SilentlyContinue + + - name: Reassert exact source after Windows packaging + env: + EXPECTED_SHA: ${{ needs.validate.outputs.source_sha }} + shell: bash + run: | + set -euo pipefail + actual="$(git rev-parse HEAD | tr '[:upper:]' '[:lower:]')" + expected="$(printf '%s' "$EXPECTED_SHA" | tr '[:upper:]' '[:lower:]')" + test "$actual" = "$expected" + git cat-file -e "${expected}^{commit}" + test "$(git rev-parse "${actual}^{tree}")" = "$(git rev-parse "${expected}^{tree}")" + git diff --quiet --exit-code HEAD -- + git diff --cached --quiet --exit-code HEAD -- + test -z "$(git status --porcelain=v1 --untracked-files=all)" + + - name: Create Windows exact-SHA receipt + shell: pwsh + env: + RECEIPT_SHA: ${{ needs.validate.outputs.source_sha }} + run: | + $msi = @(Get-ChildItem 'target/release/bundle/msi/*.msi' -File) + $nsis = @(Get-ChildItem 'target/release/bundle/nsis/*.exe' -File) + if ($msi.Count -ne 1) { throw 'expected exactly one MSI installer' } + if ($nsis.Count -ne 1) { throw 'expected exactly one NSIS installer' } + $artifacts = @($msi + $nsis) | ForEach-Object { + [ordered]@{ + name = $_.Name + sha256 = (Get-FileHash $_.FullName -Algorithm SHA256).Hash.ToLowerInvariant() + bytes = $_.Length + } + } + $receipt = [ordered]@{ + schema = 'opentake-windows-release-receipt-v1' + repository = '${{ github.repository }}' + run_id = '${{ github.run_id }}' + run_attempt = '${{ github.run_attempt }}' + runner_os = '${{ runner.os }}' + runner_arch = '${{ runner.arch }}' + source_sha = $env:RECEIPT_SHA + artifacts = @($artifacts) + } + $receipt | ConvertTo-Json -Depth 6 | Set-Content -Encoding utf8NoBOM windows-x64-receipt.json + + - name: Upload exact-SHA Windows packages + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: opentake-windows-x64-${{ needs.validate.outputs.source_sha }} + path: | + target/release/bundle/msi/*.msi + target/release/bundle/nsis/*.exe + windows-x64-receipt.json + if-no-files-found: error + retention-days: 30 + + publish: + name: Publish verified GitHub prerelease + permissions: + contents: write + needs: [validate, quality, macos_arm64, windows_x64] + runs-on: ubuntu-latest + timeout-minutes: 30 + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ needs.validate.outputs.tag }} + RELEASE_SHA: ${{ needs.validate.outputs.source_sha }} + RELEASE_VERSION: ${{ needs.validate.outputs.version }} + NOTES_PATH: ${{ needs.validate.outputs.notes_path }} + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + ref: ${{ needs.validate.outputs.source_sha }} + fetch-depth: 0 + persist-credentials: false + + - name: Assert exact checked-out SHA + shell: bash + run: | + set -euo pipefail + actual="$(git rev-parse HEAD | tr '[:upper:]' '[:lower:]')" + expected="$(printf '%s' "$RELEASE_SHA" | tr '[:upper:]' '[:lower:]')" + test "$actual" = "$expected" + git cat-file -e "${expected}^{commit}" + test -z "$(git status --porcelain=v1 --untracked-files=all)" + + - name: Initialize isolated publish root + shell: bash + run: | + set -euo pipefail + test -n "$RUNNER_TEMP" + publish_root="$RUNNER_TEMP/opentake-release-$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT" + case "$publish_root" in + "$RUNNER_TEMP"/opentake-release-*) ;; + *) exit 1 ;; + esac + printf 'PUBLISH_ROOT=%s\n' "$publish_root" >> "$GITHUB_ENV" + + - name: Download macOS artifact + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: opentake-macos-arm64-${{ needs.validate.outputs.source_sha }} + path: ${{ runner.temp }}/opentake-release-${{ github.run_id }}-${{ github.run_attempt }}/input/macos + + - name: Download Windows artifact + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: opentake-windows-x64-${{ needs.validate.outputs.source_sha }} + path: ${{ runner.temp }}/opentake-release-${{ github.run_id }}-${{ github.run_attempt }}/input/windows + + - name: Stage and verify the exact release payload + shell: bash + run: | + set -euo pipefail + case "$PUBLISH_ROOT" in + "$RUNNER_TEMP"/opentake-release-*) ;; + *) echo "publish root must be under RUNNER_TEMP" >&2; exit 1 ;; + esac + mapfile -d '' dmgs < <(find "$PUBLISH_ROOT/input" -type f -name '*.dmg' -print0) + mapfile -d '' msis < <(find "$PUBLISH_ROOT/input" -type f -name '*.msi' -print0) + mapfile -d '' exes < <(find "$PUBLISH_ROOT/input" -type f -name '*.exe' -print0) + mapfile -d '' mac_receipts < <(find "$PUBLISH_ROOT/input" -type f -name 'macos-arm64-receipt.json' -print0) + mapfile -d '' windows_receipts < <(find "$PUBLISH_ROOT/input" -type f -name 'windows-x64-receipt.json' -print0) + test "${#dmgs[@]}" -eq 1 + test "${#msis[@]}" -eq 1 + test "${#exes[@]}" -eq 1 + test "${#mac_receipts[@]}" -eq 1 + test "${#windows_receipts[@]}" -eq 1 + mkdir -p "$PUBLISH_ROOT/assets" + cp "${dmgs[0]}" "${msis[0]}" "${exes[0]}" "$PUBLISH_ROOT/assets/" + cp "${mac_receipts[0]}" "$PUBLISH_ROOT/assets/macos-arm64-receipt.json" + cp "${windows_receipts[0]}" "$PUBLISH_ROOT/assets/windows-x64-receipt.json" + + python3 - <<'PY' + import hashlib + import json + import os + from pathlib import Path + + def sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + root = Path(os.environ["PUBLISH_ROOT"]) / "assets" + files = [path for path in root.iterdir() if path.is_file()] + expected = {"dmg": 1, "msi": 1, "exe": 1, "json": 2} + actual = { + "dmg": sum(path.suffix.lower() == ".dmg" for path in files), + "msi": sum(path.suffix.lower() == ".msi" for path in files), + "exe": sum(path.suffix.lower() == ".exe" for path in files), + "json": sum(path.suffix.lower() == ".json" for path in files), + } + if actual != expected or len(files) != 5: + raise SystemExit(f"unexpected staged release payload: {actual}") + + source_sha = os.environ["RELEASE_SHA"] + mac = json.loads((root / "macos-arm64-receipt.json").read_text(encoding="utf-8")) + if mac.get("source_sha") != source_sha or mac.get("signature_mode") != "ad-hoc": + raise SystemExit("macOS receipt is not bound to source SHA and ad-hoc signing") + mac_artifact = mac.get("artifact", {}) + dmg = next(path for path in files if path.suffix.lower() == ".dmg") + if mac_artifact.get("name") != dmg.name: + raise SystemExit("macOS receipt names a different DMG") + if mac_artifact.get("sha256") != sha256(dmg): + raise SystemExit("macOS receipt DMG checksum mismatch") + if mac_artifact.get("bytes") != dmg.stat().st_size: + raise SystemExit("macOS receipt DMG byte count mismatch") + + windows = json.loads((root / "windows-x64-receipt.json").read_text(encoding="utf-8")) + if windows.get("source_sha") != source_sha: + raise SystemExit("Windows receipt is not bound to source SHA") + installers = [path for path in files if path.suffix.lower() in {".msi", ".exe"}] + entries = windows.get("artifacts") + if not isinstance(entries, list) or len(entries) != 2: + raise SystemExit("Windows receipt must contain exactly two installer entries") + by_name = {entry.get("name"): entry for entry in entries} + if set(by_name) != {path.name for path in installers}: + raise SystemExit("Windows receipt installer names mismatch") + for installer in installers: + entry = by_name[installer.name] + if entry.get("sha256") != sha256(installer): + raise SystemExit(f"Windows receipt checksum mismatch: {installer.name}") + if entry.get("bytes") != installer.stat().st_size: + raise SystemExit(f"Windows receipt byte count mismatch: {installer.name}") + PY + + - name: Create and verify SHA256SUMS + shell: bash + run: | + set -euo pipefail + cd "$PUBLISH_ROOT/assets" + mapfile -t asset_names < <(find . -maxdepth 1 -type f -printf '%f\n' | LC_ALL=C sort) + payload_names=("${asset_names[@]}") + test "${#payload_names[@]}" -eq 5 + sha256sum "${payload_names[@]}" > SHA256SUMS + test "$(wc -l < SHA256SUMS | tr -d ' ')" -eq 5 + sha256sum --check SHA256SUMS + printf '%s\n' "${payload_names[@]}" SHA256SUMS | LC_ALL=C sort > "$PUBLISH_ROOT/expected-assets.txt" + test "$(wc -l < "$PUBLISH_ROOT/expected-assets.txt" | tr -d ' ')" -eq 6 + + - name: Prepare release notes with provenance + shell: bash + run: | + set -euo pipefail + cp "$NOTES_PATH" "$PUBLISH_ROOT/release-body.md" + cat >> "$PUBLISH_ROOT/release-body.md" < "$PUBLISH_ROOT/remote-tag-before-draft.txt" + python3 scripts/check_release_workflow.py resolve-remote-tag \ + --input "$PUBLISH_ROOT/remote-tag-before-draft.txt" \ + --tag "$RELEASE_TAG" --sha "$RELEASE_SHA" + + - name: Create or refresh draft prerelease + shell: bash + run: | + set -euo pipefail + owner="${GITHUB_REPOSITORY%%/*}" + repository="${GITHUB_REPOSITORY#*/}" + query='query($owner:String!,$name:String!,$tag:String!){repository(owner:$owner,name:$name){release(tagName:$tag){databaseId tagName tagCommit{oid} isDraft isPrerelease releaseAssets(first:100){nodes{id name size} pageInfo{hasNextPage}}}}}' + gh api graphql \ + -f query="$query" -f owner="$owner" -f name="$repository" -f tag="$RELEASE_TAG" \ + > "$PUBLISH_ROOT/existing-release-graphql.json" + python3 scripts/check_release_workflow.py resolve-release-state \ + --input "$PUBLISH_ROOT/existing-release-graphql.json" \ + --tag "$RELEASE_TAG" \ + --sha "$RELEASE_SHA" \ + --output "$PUBLISH_ROOT/release-state.json" + action="$(jq -r '.action' "$PUBLISH_ROOT/release-state.json")" + case "$action" in + create) + gh release create "$RELEASE_TAG" \ + --verify-tag \ + --target "$RELEASE_SHA" \ + --title "OpenTake $RELEASE_VERSION" \ + --notes-file "$PUBLISH_ROOT/release-body.md" \ + --draft --prerelease --latest=false + ;; + refresh) + release_id="$(jq -r '.release_id' "$PUBLISH_ROOT/release-state.json")" + gh api "repos/$GITHUB_REPOSITORY/releases/$release_id" > "$PUBLISH_ROOT/existing-draft-rest.json" + test "$(jq -r '.id' "$PUBLISH_ROOT/existing-draft-rest.json")" = "$release_id" + if test "$(jq -r '.draft' "$PUBLISH_ROOT/existing-draft-rest.json")" != "true"; then + echo "release is already published; refusing to mutate it" >&2 + exit 1 + fi + test "$(jq -r '.prerelease' "$PUBLISH_ROOT/existing-draft-rest.json")" = "true" + test "$(jq -r '.tag_name' "$PUBLISH_ROOT/existing-draft-rest.json")" = "$RELEASE_TAG" + test "$(jq -r '.target_commitish' "$PUBLISH_ROOT/existing-draft-rest.json" | tr '[:upper:]' '[:lower:]')" = "$RELEASE_SHA" + jq -e 'all(.assets[]; (.id | type) == "number")' "$PUBLISH_ROOT/existing-draft-rest.json" >/dev/null + while read -r asset_id; do + gh api --method DELETE "repos/$GITHUB_REPOSITORY/releases/assets/$asset_id" + done < <(jq -r '.assets[].id' "$PUBLISH_ROOT/existing-draft-rest.json") + gh release edit "$RELEASE_TAG" \ + --target "$RELEASE_SHA" \ + --title "OpenTake $RELEASE_VERSION" \ + --notes-file "$PUBLISH_ROOT/release-body.md" \ + --draft --prerelease --latest=false + ;; + *) + exit 1 + ;; + esac + + - name: Upload the exact payload to the draft + run: gh release upload "$RELEASE_TAG" "$PUBLISH_ROOT/assets/"* --clobber + + - name: Verify draft target and exact assets + run: | + set -euo pipefail + owner="${GITHUB_REPOSITORY%%/*}" + repository="${GITHUB_REPOSITORY#*/}" + query='query($owner:String!,$name:String!,$tag:String!){repository(owner:$owner,name:$name){release(tagName:$tag){databaseId tagName tagCommit{oid} isDraft isPrerelease releaseAssets(first:100){nodes{id name size} pageInfo{hasNextPage}}}}}' + gh api graphql \ + -f query="$query" -f owner="$owner" -f name="$repository" -f tag="$RELEASE_TAG" \ + > "$PUBLISH_ROOT/draft-release-graphql.json" + python3 scripts/check_release_workflow.py resolve-release-state \ + --input "$PUBLISH_ROOT/draft-release-graphql.json" \ + --tag "$RELEASE_TAG" \ + --sha "$RELEASE_SHA" \ + --output "$PUBLISH_ROOT/draft-state.json" + test "$(jq -r '.action' "$PUBLISH_ROOT/draft-state.json")" = "refresh" + jq -r '.asset_names[]' "$PUBLISH_ROOT/draft-state.json" | LC_ALL=C sort > "$PUBLISH_ROOT/draft-assets.txt" + cmp "$PUBLISH_ROOT/expected-assets.txt" "$PUBLISH_ROOT/draft-assets.txt" + jq -e '.asset_sizes | length == 6 and all(. > 0)' "$PUBLISH_ROOT/draft-state.json" >/dev/null + + - name: Revalidate remote tag before publication + shell: bash + run: | + set -euo pipefail + git ls-remote --exit-code origin \ + "refs/tags/$RELEASE_TAG" "refs/tags/$RELEASE_TAG^{}" \ + > "$PUBLISH_ROOT/remote-tag-before-publication.txt" + python3 scripts/check_release_workflow.py resolve-remote-tag \ + --input "$PUBLISH_ROOT/remote-tag-before-publication.txt" \ + --tag "$RELEASE_TAG" --sha "$RELEASE_SHA" + + - name: Reassert exact source before publication + env: + EXPECTED_SHA: ${{ needs.validate.outputs.source_sha }} + shell: bash + run: | + set -euo pipefail + actual="$(git rev-parse HEAD | tr '[:upper:]' '[:lower:]')" + expected="$(printf '%s' "$EXPECTED_SHA" | tr '[:upper:]' '[:lower:]')" + test "$actual" = "$expected" + git cat-file -e "${expected}^{commit}" + test "$(git rev-parse "${actual}^{tree}")" = "$(git rev-parse "${expected}^{tree}")" + git diff --quiet --exit-code HEAD -- + git diff --cached --quiet --exit-code HEAD -- + test -z "$(git status --porcelain=v1 --untracked-files=all)" + + - name: Publish verified prerelease + run: gh release edit "$RELEASE_TAG" --draft=false --prerelease --latest=false + + - name: Verify public release through API and checksums + run: | + set -euo pipefail + gh api "repos/$GITHUB_REPOSITORY/releases/tags/$RELEASE_TAG" > "$PUBLISH_ROOT/public-release.json" + python3 - <<'PY' + import json + import os + from pathlib import Path + + root = Path(os.environ["PUBLISH_ROOT"]) + release = json.loads((root / "public-release.json").read_text(encoding="utf-8")) + expected_sha = os.environ["RELEASE_SHA"] + expected_names = set((root / "expected-assets.txt").read_text(encoding="utf-8").splitlines()) + actual_names = {asset["name"] for asset in release.get("assets", [])} + if release.get("draft") is not False or release.get("prerelease") is not True: + raise SystemExit("release was not published as a prerelease") + if release.get("tag_name") != os.environ["RELEASE_TAG"]: + raise SystemExit("public release tag mismatch") + if release.get("target_commitish") != expected_sha: + raise SystemExit("public release target SHA mismatch") + if expected_names != actual_names: + raise SystemExit(f"public release assets mismatch: {sorted(actual_names)}") + PY + mkdir "$PUBLISH_ROOT/verified-release" + gh release download "$RELEASE_TAG" --dir "$PUBLISH_ROOT/verified-release" + find "$PUBLISH_ROOT/verified-release" -maxdepth 1 -type f -printf '%f\n' | LC_ALL=C sort > "$PUBLISH_ROOT/verified-assets.txt" + cmp "$PUBLISH_ROOT/expected-assets.txt" "$PUBLISH_ROOT/verified-assets.txt" + cd "$PUBLISH_ROOT/verified-release" + sha256sum --check SHA256SUMS diff --git a/.gitignore b/.gitignore index 36988ff9..211fe639 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,8 @@ dist/ # Tauri src-tauri/target/ src-tauri/gen/ +src-tauri/binaries/ffmpeg-* +src-tauri/binaries/ffprobe-* # Env / secrets (never commit API keys) .env diff --git a/AGENTS.md b/AGENTS.md index 2d75aa2e..df0a698f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -91,7 +91,9 @@ cd web && pnpm install && pnpm build cargo tauri dev ``` -当前状态:**MVP 编辑闭环已并入 main**——9 个 crate + Tauri 壳 + React 前端均已存在并通过 CI。最新进度与下一步见 `CLAUDE.md`(工作交接状态文档)与 `docs/architecture/PORT-1TO1-GAP.md`。 +当前状态与发布门槛以 `docs/releases/1.0.0-beta.2.md` 为准,验证证据记录在 +`docs/audit/2026-08-02/beta-functional-verification.md`。`CLAUDE.md`、 +`docs/architecture/HANDOFF-2026-07.md` 与 `PORT-1TO1-GAP.md` 均为历史快照或设计来源。 ## 上游参考 diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c3e8cb7..24846069 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,82 @@ 本文件记录 OpenTake 的重要改动。格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/)。 -## [未发布] — 2026-06-23 第三轮(自动 PR 审核:全局素材库 + 文本工具 + 字幕/视频导出 + list_models) +## [1.0.0-beta.2] — 2026-08-03 + +### 新增(Added) + +- ChatGPT 的 AI 登录新增官方 Codex CLI 直接登录;登录、退出和状态完全交由官方 CLI 管理, + OpenTake 不读取、复制或保存 ChatGPT 凭据。 +- 素材放置、复制/移动到新轨和完整片段粘贴改为单一 Rust 事务,支持根/嵌套序列、链接音视频、 + 字幕组、转场、文本与 compound 字段的身份重映射和一次撤销。 +- 播放头多选/链接片段分割和画布 Transform 写入新增原子命令;整批预检后只产生一次版本推进和 + 一次撤销,动画 Transform 在当前帧写入关键帧。 + +### 修复(Fixed) + +- 修复跨工程、另存为、序列切换和外部版本推进期间的迟到媒体/AI 结果、队列串扰与未处理 + Promise;失败保持原状态并显示可见错误。 +- 修复极端帧值、trim、ripple、split、nested sequence 和 script 组装可能触发的整数溢出、 + 部分写入或 ID 消耗;Rust 与浏览器 fallback 现在均先完整预检再原子提交。 +- 保持 Home / Library / Editor mounted state 与滚动位置,补齐弹窗焦点圈、24px 有效命中区、 + 小窗口分栏和 reduced-motion 行为。 +- 修复预览逐帧在片段边界回跳、非零起点音量关键帧坐标、末端关键帧拖动、razor 边界拒绝、 + 片段外动画字段误写,以及 Inspector/字幕控件缺少上下文可访问名称。 + +### 安全与可靠性(Security / Reliability) + +- 官方 Codex 每轮使用独立、工程绑定、带 256-bit Bearer 的临时 loopback MCP;切工程、取消、 + deadline 和进程树清理均 fail closed。未认证的固定端口外部 MCP 在本 Beta 默认关闭。 +- 导入路径使用 retained capability、no-follow/reparse 检查、目录预算、可取消 ffprobe;远程 URL + 拒绝内网/环回/保留地址、逐跳重解析并固定 DNS 结果,禁用代理绕过。 +- Agent 所有模型可见结果改为白名单 DTO;本机路径、签名 URL、provider request ID、prompt、 + hash 与底层诊断不会进入 MCP 返回或工程聊天记录。 + +### Beta 已知边界 + +- macOS 包仍为本地 ad-hoc Beta,不是 Developer ID 签名/公证分发包。 +- Windows Job Object、reparse-point 与安装器运行测试由候选提交的 exact-SHA Windows CI 执行; + macOS 本机结果不能替代真实 Windows 证据。 +- Claude、Cursor 等外部 MCP 客户端连接等待后续带身份验证的显式配对流程;官方 Codex / ChatGPT + 登录使用独立的逐轮认证通道,不受影响。 + +## [1.0.0-beta.1] — 2026-08-01 + +OpenTake 的第一个可安装 Beta。核心闭环为:创建/打开工程 → 导入与管理素材 → +多轨剪辑、字幕、关键帧、特效/蒙版/调色/转场 → 本地 AI 与生成式 AI 审阅工作流 → +预览、保存重开、H.264/H.265/ProRes 与字幕/交换格式导出。 + +### 新增(Added) + +- 可恢复的工程持久化、全局素材库、缩略图/波形/代理媒体、缺失素材重链接。 +- Rust/WGPU 预览与导出共享合成路径,支持文本、调色、绿幕、蒙版、LUT、HSL、 + Lift/Gamma/Gain、通用特效、交叉溶解、嵌套时间线、补帧与防抖。 +- Agent/MCP 编辑、内置聊天、官方 Codex CLI / ChatGPT 登录、BYOK 生成作业、Motion Canvas + 动效与原生 Chromium fallback。Codex 登录态完全由官方 CLI 管理,OpenTake 不读取或保存令牌。 +- 本地口播清理、响度统一、降噪、声部分离、RVM 抠像、智能擦除、参考色彩匹配和 + 可视化运动追踪;字幕翻译、图文成片、数字人与音色克隆提供审阅/同意/成本边界。 +- 完整键盘、焦点、菜单、拖拽、撤销/重做与辅助功能回归门禁。 + +### 安全与可靠性(Security / Reliability) + +- 生产 CSP、最小 asset scope、凭据 keychain 边界、URL/重定向/大小限制、下载校验与 + 项目修订原子提交。 +- macOS/Linux/Windows 安全文件系统契约、打包 FFmpeg sidecar 供应链校验、取消与失败 + 清理、生成/导出恢复与防陈旧结果提交。 + +### Beta 已知边界 + +- 本地 macOS Beta 包未使用 Developer ID 签名或 Apple 公证;首次打开需要用户明确允许。 +- 数字人、音色克隆和通用云生成需要用户自己的 provider key,并可能产生第三方费用; + Agent 可选择 provider key,也可复用官方 Codex CLI 的 ChatGPT 登录。无可用登录或 key 时 + 功能会显式不可用或拒绝,不会静默调用。 +- Windows 安装包由精确 SHA CI 构建和验证;原生 Windows WebView 的最终人工交互烟测仍是 + 平台发布门槛,不影响本次 Apple Silicon macOS 本地 Beta。 +- 任意 Motion Canvas TSX、透明动效、神经语义级任意人声分离等属于后续 Beta 范围。 + +## 历史开发记录 — 2026-06-23 第三轮(已由 Beta 1/2 状态替代) + +> 本节及后续“未完成”表记录当时的开发现场,不代表 Beta 2 的当前阻断项。 本轮为**自动 PR 审核流程**:逐 PR 专家审核 + 对抗验证 + 对照开发文档,审核通过且 CI 双绿的纯新增项合并,其余 @作者 rebase/修改。 @@ -32,7 +107,7 @@ --- -## [未发布] — 2026-06-23 第二轮(剪映式 UI + 时间线剪辑修复 + 导出) +## 历史开发记录 — 2026-06-23 第二轮(已由 Beta 1/2 状态替代) 合并自 PR #102(基于已合并的 #81)。多 Agent 协作:主控修 Bug + 编排 workflow 做功能。 @@ -63,7 +138,7 @@ web `tsc` 干净 + `vitest` 43;Rust `fmt`/`clippy` 干净 + `opentake-project` --- -## 未完成 / 已知问题(已建 Issue 跟踪) +## 当时未完成 / 已知问题(历史 Issue 快照) 每个 Issue 含「现状位置 + 如何完成 + 上游/剪映参照」。 diff --git a/CLAUDE.md b/CLAUDE.md index 94290982..e531f491 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,8 +1,11 @@ -# OpenTake — 工作交接 / 状态文档(给压缩上下文后的自己) +# OpenTake — 历史工作交接 / 状态快照 -> 本文件是 OpenTake 开发的**权威状态 + 操作手册**。每次上下文压缩后先读它,再读 **`docs/architecture/HANDOFF-2026-07.md`(★ 当前权威 TODO / 交接文档:issue 盘点 + 未完成清单 + 每项怎么写)**。旧的 `PORT-1TO1-GAP.md` 已过时,仅作历史参考。 +> **2026-08-03 状态裁决:**本文件保留早期开发过程,不再是当前状态真值。Beta 2 +> 的当前范围与发布门槛见 `docs/releases/1.0.0-beta.2.md`,执行证据见 +> `docs/audit/2026-08-02/beta-functional-verification.md`。`HANDOFF-2026-07.md` 和 +> `PORT-1TO1-GAP.md` 同样仅作历史/设计参考。 -## ✅ 2026-07-04 状态快照 +## 历史:2026-07-04 状态快照 - **播放引擎收官**:#170(流式引擎全链路)已合并;**PR #189(Rust 引擎默认开 + 运行时回退安全网)本次提交**。唯一欠账 = 真机视觉验收(清单 `docs/architecture/PLAYBACK-ENGINE.md`)。 - **#171–#188 已把"引擎建成没接 UI"主缺口清完**:whisper 转写/字幕、SigLIP2 搜索、.opentake 打包、导入白名单、save-as bug、Inspector 关键帧、画布 overlay/zoom、时间线 I/O 范围/nudge、H.265/ProRes 导出、agent MediaBridge。 @@ -60,7 +63,7 @@ - **真机测试循环**:`./web/node_modules/.bin/tauri build` → `cp -R target/release/bundle/macos/OpenTake.app /Applications/` → `open -a OpenTake` → computer-use(已授权 `com.opentake.app`,tier full)。dev 裸二进制识别不到,必须装到 /Applications。 - 同一工作树勿并行两个写同批文件的 workflow;workflow 可能撞 Cloudflare 522 让"写/审"步骤失败、审核被跳过 → 本人接手验证+自审+盯 CI。 -## 4. 🟥 下一步(我认领的"第一个大开发":时间线) +## 历史:当时的下一步(已被 Beta 1/2 收口裁决替代) **先做 #47 + #48(时间线合成预览/播放 + 片段编辑收尾)——这是用户点名的"时间线的工作还没做"。** - **#47 时间线合成预览 + 播放**:src-tauri 新增 `composite_frame(frame)->RGBA/PNG`(RenderPlan.frame + opentake-media 实现 FrameProvider + 已就绪的 Compositor.render_to_rgba)→ 前端 Preview 在 Timeline 标签暂停/seek 时贴 ``(替换 1920×1080 占位,现在时间线预览是黑的不播放);再做播放引擎(连续解码+cpal 音频+A/V 同步)。 - **#48 片段编辑收尾**:验证/修原生里时间线**片段点击选中**(`TimelineContainer` onPointerDown→hitTestClip→selectClips 已接,但实测 Delete 无效,疑选中没生效)→ Delete 删除、Cmd+K/剃刀分割可用;**片段右键菜单**(Copy/Swap Media/Save as Media/AI Edit);Inspector 三段式;Toolbar `[`/`]`/`T` 接线。 @@ -70,20 +73,22 @@ - **#51/#52 合成预览**(PR #59):时间线标签按播放头贴 GPU 合成帧(视频+图)。 - **#61/#62**:多素材拖入、保存/自动保存/退出 flush、预览整数帧、音频探测、播放卡顿缓解。 - **#36 MCP 工具派发层 + Skills**(PR #66/#67):单一能力派发(25 工具接线:18 EditCommand + rename/delete + workflow/Skills)+ 默认"音频先入"内置 Skill(`crates/opentake-agent/src/plugin/builtin/audio-first/`)。 -- **#65 文字光栅化**(PR #68):`CosmicTextRasterizer`(cosmic-text+swash)把文字 clip 框渲染为预乘 RGBA,经既有 affine 1:1 合成置顶(对应上游 CATextLayer);字体/字号/颜色/对齐/背景/投影/边框全覆盖;真机视觉自检中英混排正常。**剩 Lottie 烘焙**。 +- **#65 文字/Lottie 光栅化**:`CosmicTextRasterizer` 处理文字,Velato/Vello 处理 Lottie;preview/playback/export 共用预乘 RGBA 纹理合约,Agent `inspect_media` 及 `inspect_timeline` 也使用同一 Lottie 渲染路径。 - **#36 MCP server 网络面**(PR #69,**issue 已关闭**):rmcp Streamable-HTTP `127.0.0.1:19789/mcp` + 回环 Origin/Host 守卫 + OAuth well-known;src-tauri `mcp.rs` 在 setup spawn(会话共享的 AppCore 克隆 + 内置/用户 workflow registry)。HTTP 集成测试完成 `initialize` 握手 + 远程 Origin 403。`claude mcp add --transport http opentake http://127.0.0.1:19789/mcp` 可连。 ## 5. 🟦 可认领/未完成(供同事,注意文件区避免冲突) -- **🔴 #53 [#47-C] 时间线播放引擎**(连续解码 + cpal 音频 + A/V 同步 + MJPEG 回环传输)。子项 #63(cpal)/#64(MJPEG 传输)/#65(Lottie 烘焙)。最大未完成项,需专门会话 + 真机视觉验证。 +- **#53 [#47-C] 时间线播放引擎**代码竖切已完成:有界解码、cpal 音频、A/V 时钟、seek/pause/resume/cancel 和 Lottie 均已接入;最终发布仍需按完成规划重放打包 GUI 验收。 - **#48 片段编辑收尾**:Delete/切割/片段右键菜单/Inspector 三段式/Toolbar 接线。 -- **剩余 MCP 工具 stub**:媒体读取(inspect_media/get_transcript/search_media)+ import_media 需**拓宽 CoreHandle 接 MediaEngine**(注意:CoreHandle 现仅持 AppCore,MediaEngine 在 MediaState,需架构扩展);`generate_*`/upscale 需异步 GenClient + BYOK;add_captions 需端上 whisper。 +- **动态能力面**:媒体/转写/检索/时间线检查均为真实路径,`inspect_media` 支持图片/视频/音频/Lottie;生成/超分仅在可用授权时发布,Motion add/edit 仅在 Chromium/FFmpeg 生产桥就绪时发布。 - **#49 项目内文件夹导入 + 嵌套文件夹浏览(剪映式)**:文件夹图标/双击进入/面包屑/拖出;DTO 加 folderId+folders;import_folder 镜像目录树。用户很想要。 - **#37 全局可复用素材库 + 收藏**(跨项目/分类/音效库/全库可见):**后端已并入 main** —— 存储层 `crates/opentake-media/src/library.rs`(#37-A/#54,PR #104,copy-on-favorite + SHA-256 内容寻址去重 + JSON manifest 原子写)+ Tauri 命令层 `src-tauri/src/library.rs`(#37-B/#55,PR #106,7 命令 list/favorite/unfavorite/categorize/rename/delete/import_to_project)。**前端 #37-C/#56 已并入 main**(PR #115:独立 `LibraryView` 全屏视图 + `libraryStore`/`libraryApi`,分类树/网格/搜索/排序/跨视图聚合/音效库;Home/TitleBar 入口;前端↔后端 7 命令契约已核实)。**#37 epic 收口**(后端 #104/#106 + 前端 #115)。剩:库→时间线拖拽(现用「导入当前项目」按钮)、媒体面板「星标→library_favorite」接线、收藏从 localStorage 迁后端。follow-up:`library.rs:322` remove() 静默吞 remove_file 错误,建议补 `tracing::warn!`;`library_delete` 与 `library_unfavorite` 现为纯别名,建议语义区分。 - **#39 提取音频星标 · #40 设置多分页+主页 1:1 · #34 motion dispatch · #27–30 进阶 B/C/D/E · #22–25 #12 follow-up · #35 bundle id 改名**。 - 冲突注意:我(#47/#48)动 opentake-render/opentake-media(decode/FrameProvider)/src-tauri(composite_frame、autosave)/web Preview+timeline;#36 动 agent+src-tauri(server 段);#37/#49 动 opentake-media(library/folders)+web media。**src-tauri/lib.rs、opentake-media 是多方交汇点,合并按 issue 顺序、各自小段、勤 rebase。** -## 6. MCP 配置(#36 落地后) -Streamable-HTTP `http://127.0.0.1:19789/mcp`(loopback+Origin 校验)。`claude mcp add --transport http opentake http://127.0.0.1:19789/mcp`;Cursor/Codex/Claude Desktop 同址。40 工具,返回附 context_signal。 +## 6. 历史 MCP 配置(Beta 2 已替代) +旧设计使用未认证的固定 `http://127.0.0.1:19789/mcp`。Beta 2 不启动该产品入口: +官方 Codex / ChatGPT 每轮创建随机端口、256-bit Bearer、工程绑定的临时回环 MCP, +取消、deadline、切工程或轮次结束后关闭。Claude/Cursor 外部连接等待后续带认证的显式配对流程。 -## 7. 压缩后立即执行 +## 7. 历史执行说明(不再作为当前操作手册) 1. 读本文件 + `docs/architecture/PORT-1TO1-GAP.md`。2. `git -C OpenTake pull`(main)。3. 盘点 `gh issue list`,挑最高价值且可完整交付的:**首选 🔴 #53 播放引擎**(大,需专门会话),或 #48 片段编辑收尾、#49/#37 库与文件夹、剩余 MCP 工具 stub。4. 每项走 分支→写→自审→`cargo fmt`+clippy+test→真机/确定性验证→`gh run watch` 双绿→`--admin` 合并。5. 新依赖先读 `~/.cargo/registry/src` 真实源码核实 API(cosmic-text/rmcp 都这么做的),别照猜测写。 diff --git a/Cargo.lock b/Cargo.lock index 2687a82d..32cbf23e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,15 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "addr2line" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" +dependencies = [ + "gimli", +] + [[package]] name = "adler2" version = "2.0.1" @@ -85,9 +94,21 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.102" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" + +[[package]] +name = "anymap2" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d301b3b94cb4b2f23d7917810addbbaff90738e0ca2be692bd027e70d7e0330c" + +[[package]] +name = "anymap3" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +checksum = "fb5dfbc6d8d2675589ccbe4d0fd61df2419075625f8c1a62325e718e2b0049f9" [[package]] name = "arbitrary" @@ -214,6 +235,21 @@ dependencies = [ "tracing", ] +[[package]] +name = "backtrace" +version = "0.3.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-link 0.2.1", +] + [[package]] name = "base64" version = "0.13.1" @@ -238,6 +274,15 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + [[package]] name = "bindgen" version = "0.71.1" @@ -276,15 +321,30 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "bit-set" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" +dependencies = [ + "bit-vec 0.6.3", +] + [[package]] name = "bit-set" version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" dependencies = [ - "bit-vec", + "bit-vec 0.8.0", ] +[[package]] +name = "bit-vec" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" + [[package]] name = "bit-vec" version = "0.8.0" @@ -644,6 +704,18 @@ dependencies = [ "unicode-width", ] +[[package]] +name = "color" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7f99105610438d4b3ee7ae8e453c2990e325c806a14d71e8ea937d584c5289" + +[[package]] +name = "color" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ec7c5eb7a16992b1904d76c517d170ab353b0e0b3d5a0c81a8a0cd1037893cf" + [[package]] name = "color_quant" version = "1.1.0" @@ -1039,6 +1111,16 @@ dependencies = [ "zeroize", ] +[[package]] +name = "debugid" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef552e6f588e446098f6ba40d89ac146c8c7b64aade83c051ee00bb5d2bc18d" +dependencies = [ + "serde", + "uuid", +] + [[package]] name = "der" version = "0.8.0" @@ -1058,6 +1140,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "derive-new" +version = "0.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3418329ca0ad70234b9735dc4ceed10af4df60eff9c8e7b06cb5e520d92c3535" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "derive_arbitrary" version = "1.4.2" @@ -1198,6 +1291,12 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "doc-comment" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "780955b8b195a21ab8e4ac6b60dd1dbdcec1dc6c51c0617964b08c81785e12c9" + [[package]] name = "document-features" version = "0.2.12" @@ -1213,7 +1312,7 @@ version = "0.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521e380c0c8afb8d9a1e83a1822ee03556fc3e3e7dbc1fd30be14e37f9cb3f89" dependencies = [ - "bit-set", + "bit-set 0.8.0", "cssparser", "foldhash 0.2.0", "html5ever", @@ -1222,6 +1321,12 @@ dependencies = [ "tendril", ] +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + [[package]] name = "dpi" version = "0.1.2" @@ -1273,6 +1378,12 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "dyn-hash" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15401da73a9ed8c80e3b2d4dc05fe10e7b72d7243b9f614e516a44fa99986e88" + [[package]] name = "either" version = "1.16.0" @@ -1332,6 +1443,15 @@ version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d817e038c30374a4bcb22f94d0a8a0e216958d4c3dcde369b1439fec4bdda6e6" +[[package]] +name = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] + [[package]] name = "fastrand" version = "2.4.1" @@ -1426,6 +1546,15 @@ dependencies = [ "bytemuck", ] +[[package]] +name = "font-types" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa6a5e5a77b5f3f7f9e32879f484aa5b3632ddfbe568a16266c904a6f32cdaf" +dependencies = [ + "bytemuck", +] + [[package]] name = "fontdb" version = "0.16.2" @@ -1549,6 +1678,17 @@ dependencies = [ "futures-util", ] +[[package]] +name = "futures-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot", +] + [[package]] name = "futures-io" version = "0.3.32" @@ -1753,6 +1893,12 @@ dependencies = [ "weezl", ] +[[package]] +name = "gimli" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" + [[package]] name = "gio" version = "0.18.4" @@ -1900,6 +2046,18 @@ dependencies = [ "bitflags 2.13.0", ] +[[package]] +name = "gpu-allocator" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c151a2a5ef800297b4e79efa4f4bec035c5f51d5ae587287c9b952bdf734cacd" +dependencies = [ + "log", + "presser", + "thiserror 1.0.69", + "windows 0.58.0", +] + [[package]] name = "gpu-descriptor" version = "0.3.2" @@ -1972,6 +2130,16 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "guillotiere" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b62d5865c036cb1393e23c50693df631d3f5d7bcca4c04fe4cc0fd592e74a782" +dependencies = [ + "euclid", + "svg_fmt", +] + [[package]] name = "half" version = "2.7.1" @@ -1980,6 +2148,7 @@ checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" dependencies = [ "cfg-if", "crunchy", + "num-traits", "zerocopy", ] @@ -1989,6 +2158,15 @@ version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", +] + [[package]] name = "hashbrown" version = "0.15.5" @@ -2381,6 +2559,24 @@ version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.13.0" @@ -2526,6 +2722,16 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "keyframe" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60708bf7981518d09095d6f5673ce5cf6a64f1e0d9708b554f670e6d9d2bd9a9" +dependencies = [ + "mint", + "num-traits", +] + [[package]] name = "keyring" version = "3.6.3" @@ -2558,6 +2764,33 @@ version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" +[[package]] +name = "kstring" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b609e7ca5ea38f093c20a4a102335b247221c9643b7a6bc3510f196f99499a9e" +dependencies = [ + "serde", + "static_assertions", +] + +[[package]] +name = "kurbo" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62026ae44756f8a599ba21140f350303d4f08dcdcc71b5ad9c9bb8128c13c62" +dependencies = [ + "arrayvec", + "euclid", + "smallvec 1.15.2", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + [[package]] name = "libappindicator" version = "0.9.0" @@ -2632,12 +2865,75 @@ dependencies = [ "libc", ] +[[package]] +name = "linebender_resource_handle" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4" + [[package]] name = "linux-raw-sys" version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +[[package]] +name = "liquid" +version = "0.26.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e9338405fdbc0bce9b01695b2a2ef6b20eca5363f385d47bce48ddf8323cc25" +dependencies = [ + "doc-comment", + "liquid-core", + "liquid-derive", + "liquid-lib", + "serde", +] + +[[package]] +name = "liquid-core" +version = "0.26.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "feb8fed70857010ed9016ed2ce5a7f34e7cc51d5d7255c9c9dc2e3243e490b42" +dependencies = [ + "anymap2", + "itertools 0.13.0", + "kstring", + "liquid-derive", + "num-traits", + "pest", + "pest_derive", + "regex", + "serde", + "time", +] + +[[package]] +name = "liquid-derive" +version = "0.26.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b51f1d220e3fa869e24cfd75915efe3164bd09bb11b3165db3f37f57bf673e3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "liquid-lib" +version = "0.26.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee1794b5605e9f8864a8a4f41aa97976b42512cc81093f8c885d29fb94c6c556" +dependencies = [ + "itertools 0.13.0", + "liquid-core", + "once_cell", + "percent-encoding", + "regex", + "time", + "unicode-segmentation", +] + [[package]] name = "litemap" version = "0.8.2" @@ -2705,6 +3001,12 @@ dependencies = [ "libc", ] +[[package]] +name = "maplit" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d" + [[package]] name = "markup5ever" version = "0.38.0" @@ -2746,9 +3048,9 @@ checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" [[package]] name = "memmap2" -version = "0.9.10" +version = "0.9.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "714098028fe011992e1c3962653c96b2d578c4b4bce9036e15ff220319b1e0e3" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" dependencies = [ "libc", ] @@ -2809,6 +3111,12 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "mint" +version = "0.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e53debba6bda7a793e5f99b8dacf19e626084f525f7829104ba9898f367d85ff" + [[package]] name = "mio" version = "1.2.1" @@ -2880,7 +3188,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "364f94bc34f61332abebe8cad6f6cd82a5b65cff22c828d05d0968911462ca4f" dependencies = [ "arrayvec", - "bit-set", + "bit-set 0.8.0", "bitflags 2.13.0", "cfg_aliases 0.1.1", "codespan-reporting", @@ -3037,6 +3345,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", ] [[package]] @@ -3266,6 +3575,15 @@ dependencies = [ "objc2-foundation", ] +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + [[package]] name = "oboe" version = "0.6.1" @@ -3362,13 +3680,14 @@ dependencies = [ [[package]] name = "opentake-agent" -version = "1.0.0" +version = "1.0.0-beta.2" dependencies = [ "anyhow", "async-trait", "axum", "base64 0.22.1", "futures", + "getrandom 0.3.4", "http", "keyring", "mime", @@ -3384,9 +3703,11 @@ dependencies = [ "serde", "serde_json", "serde_path_to_error", + "subtle", "tempfile", "thiserror 1.0.69", "tokio", + "tokio-util", "tower", "tower-http", "tracing", @@ -3394,7 +3715,7 @@ dependencies = [ [[package]] name = "opentake-core" -version = "1.0.0" +version = "1.0.0-beta.2" dependencies = [ "opentake-domain", "opentake-ops", @@ -3402,12 +3723,13 @@ dependencies = [ "same-file", "serde", "serde_json", + "tempfile", "thiserror 2.0.18", ] [[package]] name = "opentake-domain" -version = "1.0.0" +version = "1.0.0-beta.2" dependencies = [ "serde", "serde_json", @@ -3415,7 +3737,7 @@ dependencies = [ [[package]] name = "opentake-gen" -version = "1.0.0" +version = "1.0.0-beta.2" dependencies = [ "anyhow", "async-trait", @@ -3433,7 +3755,7 @@ dependencies = [ [[package]] name = "opentake-media" -version = "1.0.0" +version = "1.0.0-beta.2" dependencies = [ "anyhow", "byteorder", @@ -3447,8 +3769,11 @@ dependencies = [ "libc", "ndarray", "opentake-domain", + "opentake-process-tree", "ort", + "ort-tract", "reqwest 0.12.28", + "rustfft", "ryu-js", "same-file", "serde", @@ -3458,6 +3783,7 @@ dependencies = [ "tempfile", "thiserror 2.0.18", "tokenizers", + "tokio", "tracing", "unicode-normalization", "whisper-rs", @@ -3467,30 +3793,41 @@ dependencies = [ [[package]] name = "opentake-motion" -version = "1.0.0" +version = "1.0.0-beta.2" dependencies = [ + "base64 0.22.1", "hex", "image", "opentake-domain", + "opentake-process-tree", "opentake-render", "serde", "serde_json", "sha2", "tempfile", "thiserror 1.0.69", + "tungstenite", ] [[package]] name = "opentake-ops" -version = "1.0.0" +version = "1.0.0-beta.2" dependencies = [ "opentake-domain", "serde_json", ] +[[package]] +name = "opentake-process-tree" +version = "1.0.0-beta.2" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "opentake-project" -version = "1.0.0" +version = "1.0.0-beta.2" dependencies = [ "cap-fs-ext", "cap-std", @@ -3509,20 +3846,24 @@ dependencies = [ [[package]] name = "opentake-render" -version = "1.0.0" +version = "1.0.0-beta.2" dependencies = [ "bytemuck", "cosmic-text", + "half", "image", "opentake-domain", + "opentake-media", + "opentake-ops", "pollster", + "serde_json", "thiserror 2.0.18", "wgpu", ] [[package]] name = "opentake-tauri" -version = "1.0.0" +version = "1.0.0-beta.2" dependencies = [ "axum", "base64 0.22.1", @@ -3530,29 +3871,41 @@ dependencies = [ "cap-fs-ext", "cap-std", "cpal", + "crossbeam-channel", "futures", + "futures-util", + "glob", + "http-range", "image", "libc", + "mime_guess", "objc2-app-kit", + "objc2-foundation", "opentake-agent", "opentake-core", "opentake-domain", "opentake-gen", "opentake-media", + "opentake-motion", "opentake-ops", "opentake-project", "opentake-render", + "percent-encoding", "reqwest 0.12.28", "same-file", + "sentry", "serde", "serde_json", "sha2", "tauri", "tauri-build", "tauri-plugin-dialog", + "tauri-plugin-fs", + "tauri-plugin-persisted-scope", "tempfile", "tokio", "uuid", + "velato", "windows-sys 0.61.2", ] @@ -3587,6 +3940,17 @@ dependencies = [ "ureq", ] +[[package]] +name = "ort-tract" +version = "0.1.0+0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b41450290a215a579f8a723bb255a872666f98609b37fac8f57c9affcadfd78b" +dependencies = [ + "ort-sys", + "parking_lot", + "tract-onnx", +] + [[package]] name = "pango" version = "0.18.3" @@ -3656,12 +4020,78 @@ dependencies = [ "base64ct", ] +[[package]] +name = "peniko" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1f594c54ccdc9bd177a726885f066bf28d20e17169e31a8a1456217b1316b4" +dependencies = [ + "color 0.2.4", + "kurbo", + "peniko 0.4.1", + "smallvec 1.15.2", +] + +[[package]] +name = "peniko" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b44f9ddd2f480176b34278eb653ec1c8062f3b143a4e16eeff5ffac3334e288" +dependencies = [ + "color 0.3.3", + "kurbo", + "linebender_resource_handle", + "smallvec 1.15.2", +] + [[package]] name = "percent-encoding" version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "pest" +version = "2.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df728be843c7070fab6ab7c328c4e9e9d78e23bf749c0669c86ee7ebfa050a2" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e2dd6fc3b26b3462ee188aac870f5a41d398f1cd5e2408d16531bd71c9591fd" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a7a9205cfb6f596a9e8b689c0a15f9ceb7a1aafae7aaf788150ac65b29975b6" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "pest_meta" +version = "2.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85abd351c0de1e8384fc791a0737111a350394937e92b956b743dac12429f57c" +dependencies = [ + "pest", +] + [[package]] name = "phf" version = "0.13.1" @@ -3817,6 +4247,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" +[[package]] +name = "presser" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa" + [[package]] name = "prettyplease" version = "0.2.37" @@ -3827,6 +4263,15 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "primal-check" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc0d895b311e3af9902528fbb8f928688abbd95872819320517cc24ca6b2bd08" +dependencies = [ + "num-integer", +] + [[package]] name = "proc-macro-crate" version = "1.3.1" @@ -3895,6 +4340,29 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" +[[package]] +name = "prost" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b82eaa1d779e9a4bc1c3217db8ffbeabaae1dca241bf70183242128d48681cd" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-derive" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d2d8d10f3c6ded6da8b05b5fb3b8a5082514344d56c9f871412d29b4e075b4" +dependencies = [ + "anyhow", + "itertools 0.10.5", + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "pxfm" version = "0.1.29" @@ -3992,13 +4460,24 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + [[package]] name = "rand" version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" dependencies = [ - "rand_chacha", + "rand_chacha 0.9.0", "rand_core 0.9.5", ] @@ -4013,6 +4492,16 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + [[package]] name = "rand_chacha" version = "0.9.0" @@ -4023,6 +4512,15 @@ dependencies = [ "rand_core 0.9.5", ] +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + [[package]] name = "rand_core" version = "0.9.5" @@ -4038,6 +4536,22 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" +[[package]] +name = "rand_distr" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32cb0b9bc82b0a0876c2dd994a7e7a2683d3e7390ca40e6886785ef0c7e3ee31" +dependencies = [ + "num-traits", + "rand 0.8.7", +] + +[[package]] +name = "range-alloc" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca45419789ae5a7899559e9512e58ca889e41f04f1f2445e9f4b290ceccd1d08" + [[package]] name = "rangemap" version = "1.7.1" @@ -4094,7 +4608,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69aacb76b5c29acfb7f90155d39759a29496aebb49395830e928a9703d2eec2f" dependencies = [ "bytemuck", - "font-types", + "font-types 0.7.3", +] + +[[package]] +name = "read-fonts" +version = "0.25.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f9e8a4f503e5c8750e4cd3b32a4e090035c46374b305a15c70bad833dca05f" +dependencies = [ + "bytemuck", + "font-types 0.8.4", ] [[package]] @@ -4180,6 +4704,7 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ "base64 0.22.1", "bytes", + "futures-channel", "futures-core", "futures-util", "http", @@ -4330,6 +4855,12 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "rustc-demangle" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b74b56ffa8bb2830709a538c2cbcae9aa062db0d2a42563bfb09bdaae44020eb" + [[package]] name = "rustc-hash" version = "1.1.0" @@ -4351,6 +4882,20 @@ dependencies = [ "semver", ] +[[package]] +name = "rustfft" +version = "6.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21db5f9893e91f41798c88680037dba611ca6674703c1a18601b01a72c8adb89" +dependencies = [ + "num-complex", + "num-integer", + "num-traits", + "primal-check", + "strength_reduce", + "transpose", +] + [[package]] name = "rustix" version = "1.1.4" @@ -4453,6 +4998,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "scan_fmt" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b53b0a5db882a8e2fdaae0a43f7b39e7e9082389e978398bdf223a55b581248" +dependencies = [ + "regex", +] + [[package]] name = "schannel" version = "0.1.29" @@ -4604,6 +5158,85 @@ dependencies = [ "serde_core", ] +[[package]] +name = "sentry" +version = "0.49.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91e4790d8c2f43a6645ee2cb12aac2db79221fddd035b94c8166b88ea094408" +dependencies = [ + "cfg_aliases 0.2.1", + "httpdate", + "sentry-backtrace", + "sentry-core", + "sentry-panic", + "sentry-tracing", + "ureq", +] + +[[package]] +name = "sentry-backtrace" +version = "0.49.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "326e106874a7ea90636f1ca42e2f7b912929d29307982cab37f81208c16cf043" +dependencies = [ + "backtrace", + "regex", + "sentry-core", +] + +[[package]] +name = "sentry-core" +version = "0.49.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f9efafefbb78d7e02cb06c10aec08b77e7500428389eabbf6d5a668325265e8" +dependencies = [ + "rand 0.9.4", + "sentry-types", + "serde", + "serde_json", + "url", +] + +[[package]] +name = "sentry-panic" +version = "0.49.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8fc536a3e1fc68d626ae8dd18b628cafd474d9d36fea74e4bbb4d038877f003" +dependencies = [ + "sentry-backtrace", + "sentry-core", +] + +[[package]] +name = "sentry-tracing" +version = "0.49.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96be2253fe14b3fa10c1ba047af2d3e58ce85c8cb297bbd19d6fa81b604e7877" +dependencies = [ + "bitflags 2.13.0", + "sentry-backtrace", + "sentry-core", + "tracing-core", + "tracing-subscriber", +] + +[[package]] +name = "sentry-types" +version = "0.49.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f967748632cd4c5405dbed45426b27b407c0e53ac59b7df1c163e7f5aa57a77c" +dependencies = [ + "debugid", + "hex", + "rand 0.9.4", + "serde", + "serde_json", + "thiserror 2.0.18", + "time", + "url", + "uuid", +] + [[package]] name = "serde" version = "1.0.228" @@ -4829,6 +5462,16 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + [[package]] name = "simd-adler32" version = "0.3.9" @@ -4848,7 +5491,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e1c44ad1f6c5bdd4eefed8326711b7dbda9ea45dfd36068c427d332aa382cbe" dependencies = [ "bytemuck", - "read-fonts", + "read-fonts 0.22.7", +] + +[[package]] +name = "skrifa" +version = "0.26.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cc1aa86c26dbb1b63875a7180aa0819709b33348eb5b1491e4321fae388179d" +dependencies = [ + "bytemuck", + "read-fonts 0.25.3", ] [[package]] @@ -4993,6 +5646,23 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "strength_reduce" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe895eb47f22e2ddd4dabc02bce419d2e643c8e3b585c78158b349195bc24d82" + +[[package]] +name = "string-interner" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07f9fdfdd31a0ff38b59deb401be81b73913d76c9cc5b1aed4e1330a223420b9" +dependencies = [ + "cfg-if", + "hashbrown 0.14.5", + "serde", +] + [[package]] name = "string_cache" version = "0.9.0" @@ -5029,13 +5699,19 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "svg_fmt" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0193cc4331cfd2f3d2011ef287590868599a2f33c3e69bc22c1a3d3acf9e02fb" + [[package]] name = "swash" version = "0.1.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cbd59f3f359ddd2c95af4758c18270eddd9c730dde98598023cdabff472c2ca2" dependencies = [ - "skrifa", + "skrifa 0.22.3", "yazi", "zeno", ] @@ -5058,6 +5734,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" dependencies = [ "proc-macro2", + "quote", "unicode-ident", ] @@ -5354,6 +6031,22 @@ dependencies = [ "url", ] +[[package]] +name = "tauri-plugin-persisted-scope" +version = "2.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b560a5962bf975d38fb4ec98a0e64e52929992ec708acb812add9c1ab8d186d" +dependencies = [ + "aho-corasick", + "bincode", + "log", + "serde", + "serde_json", + "tauri", + "tauri-plugin-fs", + "thiserror 2.0.18", +] + [[package]] name = "tauri-runtime" version = "2.11.3" @@ -5638,6 +6331,7 @@ dependencies = [ "libc", "mio", "pin-project-lite", + "signal-hook-registry", "socket2", "tokio-macros", "windows-sys 0.61.2", @@ -5897,6 +6591,163 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", + "valuable", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "tracing-core", +] + +[[package]] +name = "tract-core" +version = "0.21.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7b5347639690871b124593a8c8903f1f369531498b8abaebd18eb5c58163971" +dependencies = [ + "anyhow", + "anymap3", + "bit-set 0.5.3", + "derive-new", + "downcast-rs", + "dyn-clone", + "lazy_static", + "log", + "maplit", + "ndarray", + "num-complex", + "num-integer", + "num-traits", + "paste", + "rustfft", + "smallvec 1.15.2", + "tract-data", + "tract-linalg", +] + +[[package]] +name = "tract-data" +version = "0.21.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0a3f476a1804e05708e9bc5e2d29dcab82bad531e357d3d14d7da80fbba0b6d" +dependencies = [ + "anyhow", + "downcast-rs", + "dyn-clone", + "dyn-hash", + "half", + "itertools 0.12.1", + "lazy_static", + "maplit", + "ndarray", + "nom", + "num-integer", + "num-traits", + "parking_lot", + "scan_fmt", + "smallvec 1.15.2", + "string-interner", +] + +[[package]] +name = "tract-hir" +version = "0.21.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dca047ba1151fe3446fb0194d4b6ddb9ae8f361337c47a267870c53605fbafb" +dependencies = [ + "derive-new", + "log", + "tract-core", +] + +[[package]] +name = "tract-linalg" +version = "0.21.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb8e0703eb53ef1bbf77050ff261675818dd5f0d6c27044c6e48ede9b845f9e0" +dependencies = [ + "byteorder", + "cc", + "derive-new", + "downcast-rs", + "dyn-clone", + "dyn-hash", + "half", + "lazy_static", + "liquid", + "liquid-core", + "liquid-derive", + "log", + "num-traits", + "paste", + "rayon", + "scan_fmt", + "smallvec 1.15.2", + "time", + "tract-data", + "unicode-normalization", + "walkdir", +] + +[[package]] +name = "tract-nnef" +version = "0.21.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72cb88a4367ec2c695610223cf886f01fc1deb5c9a82c7a74b1a5d32dc0b1466" +dependencies = [ + "byteorder", + "flate2", + "log", + "nom", + "tar", + "tract-core", + "walkdir", +] + +[[package]] +name = "tract-onnx" +version = "0.21.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5830aa672b2aa4dc98a97a36e5988eaf77b3ecee65e2601619588d2ca557008" +dependencies = [ + "bytes", + "derive-new", + "log", + "memmap2", + "num-integer", + "prost", + "smallvec 1.15.2", + "tract-hir", + "tract-nnef", + "tract-onnx-opl", +] + +[[package]] +name = "tract-onnx-opl" +version = "0.21.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121d3d224c806ba3d941f4bb50943ad33b59d1da5ae704d0e4e76d2808221f96" +dependencies = [ + "getrandom 0.2.17", + "log", + "rand 0.8.7", + "rand_distr", + "rustfft", + "tract-nnef", +] + +[[package]] +name = "transpose" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad61aed86bc3faea4300c7aee358b4c6d0c8d6ccc36524c96e4c92ccf26e77e" +dependencies = [ + "num-integer", + "strength_reduce", ] [[package]] @@ -5967,6 +6818,12 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + [[package]] name = "unic-char-property" version = "0.9.0" @@ -6189,12 +7046,77 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + [[package]] name = "vcpkg" version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" +[[package]] +name = "velato" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e40789f32ccca73bf2cef43426ecb616bfddcd623029d4d0b37bf00ab276580" +dependencies = [ + "keyframe", + "once_cell", + "serde", + "serde_json", + "serde_repr", + "thiserror 2.0.18", + "vello", +] + +[[package]] +name = "vello" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d5b0bafa35e0c2e4132104576d6bcec4bf7cd0044f1760e92ecae0d4d9bc0e7" +dependencies = [ + "bytemuck", + "futures-intrusive", + "log", + "peniko 0.3.2", + "png 0.17.16", + "skrifa 0.26.6", + "static_assertions", + "thiserror 2.0.18", + "vello_encoding", + "vello_shaders", + "wgpu", +] + +[[package]] +name = "vello_encoding" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbbdec68dea2b39ece9f82ab15ec4cf2c4f8600ce6926df0638290702d95b3f7" +dependencies = [ + "bytemuck", + "guillotiere", + "peniko 0.3.2", + "skrifa 0.26.6", + "smallvec 1.15.2", +] + +[[package]] +name = "vello_shaders" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e0179d74cf9131dfd7882323751d2544f3aefdfda9d16c39bbe2729799410d2" +dependencies = [ + "bytemuck", + "naga", + "thiserror 2.0.18", + "vello_encoding", +] + [[package]] name = "version-compare" version = "0.2.1" @@ -6489,6 +7411,7 @@ dependencies = [ "document-features", "js-sys", "log", + "naga", "parking_lot", "profiling", "raw-window-handle", @@ -6509,7 +7432,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d63c3c478de8e7e01786479919c8769f62a22eec16788d8c2ac77ce2c132778a" dependencies = [ "arrayvec", - "bit-vec", + "bit-vec 0.8.0", "bitflags 2.13.0", "cfg_aliases 0.1.1", "document-features", @@ -6536,6 +7459,7 @@ dependencies = [ "android_system_properties", "arrayvec", "ash", + "bit-set 0.8.0", "bitflags 2.13.0", "block", "bytemuck", @@ -6544,6 +7468,7 @@ dependencies = [ "glow", "glutin_wgl_sys", "gpu-alloc", + "gpu-allocator", "gpu-descriptor", "js-sys", "khronos-egl", @@ -6557,6 +7482,7 @@ dependencies = [ "once_cell", "parking_lot", "profiling", + "range-alloc", "raw-window-handle", "renderdoc-sys", "rustc-hash 1.1.0", @@ -6566,6 +7492,7 @@ dependencies = [ "web-sys", "wgpu-types", "windows 0.58.0", + "windows-core 0.58.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 40890120..aafc48fc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,7 @@ resolver = "2" members = [ "crates/opentake-domain", + "crates/opentake-process-tree", "crates/opentake-ops", "crates/opentake-project", "crates/opentake-render", @@ -14,7 +15,7 @@ members = [ ] [workspace.package] -version = "1.0.0" +version = "1.0.0-beta.2" edition = "2021" license = "GPL-3.0-or-later" repository = "https://github.com/appergb/OpenTake" @@ -24,7 +25,10 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" serde_ignored = "0.1" uuid = { version = "1", features = ["v4"] } +velato = { version = "=0.5.0", features = ["wgpu"] } +sentry = { version = "=0.49.0", default-features = false, features = ["backtrace", "panic", "ureq"] } opentake-domain = { path = "crates/opentake-domain" } +opentake-process-tree = { path = "crates/opentake-process-tree" } opentake-ops = { path = "crates/opentake-ops" } opentake-project = { path = "crates/opentake-project" } opentake-render = { path = "crates/opentake-render" } diff --git a/NOTICE b/NOTICE index e25822ea..5f110371 100644 --- a/NOTICE +++ b/NOTICE @@ -28,3 +28,16 @@ Nature of the fork / summary of changes replacing AVFoundation's declarative composition. - Self-hosted / BYOK generative-AI backend. The upstream generative-AI processing is closed-source and is NOT part of this fork; OpenTake provides its own. + +------------------------------------------------------------------------------- +Optional on-device model +------------------------------------------------------------------------------- + +The AI portrait-matting feature can download the official Robust Video Matting +(RVM) MobileNetV3 FP32 ONNX model, version 1.0.0, from +https://github.com/PeterL1n/RobustVideoMatting. RVM was developed at ByteDance +Inc. by Shanchuan Lin, Linjie Yang, Imran Saleemi, and Soumyadip Sengupta and is +distributed under the GNU General Public License version 3. The model is not +embedded in the OpenTake application; it is installed on demand after the user +selects the model-install action and is verified against a pinned byte size and +SHA-256 digest before use. diff --git a/README.ja.md b/README.ja.md index 1e795af2..a42529ef 100644 --- a/README.ja.md +++ b/README.ja.md @@ -82,17 +82,22 @@ OpenTake は CapCut / DaVinci Resolve / Final Cut Pro の代替品ではあり 📖 [Context Signal 設計](docs/modules/opentake-agent/AGENT-CONTEXT-SIGNAL.md) -### 🔌 MCP Server — 31ツール +### 🔌 Agent ツールサーフェス -`127.0.0.1:19789` で動作する完全なMCPサーバー。Agentがタイムラインを直接制御: +OpenTake は45個の互換 Agent ツールを提供し、メディア・生成・provider の実行可能性に +応じて動的に公開します。利用できない機能は fail closed になります。 -| グループ | 数 | 主要ツール | -|:--|:--:|:--| -| 読取 / 内省 | 7 | `get_timeline`, `get_media`, `inspect_media`, `search_media` | -| タイムライン編集 | 11 | `add_clips`, `split_clip`, `set_clip_properties`, `set_keyframes`, `ripple_delete_ranges` | -| 生成 / インポート | 5 | `generate_video`, `generate_image`, `generate_audio`, `import_media` | -| ライブラリ | 7 | `create_folder`, `move_to_folder`, `rename_media` | -| リソース | 2 | `models/video`, `models/image` | +| グループ | 主要ツール | +|:--|:--| +| 読取 / 内省 | `get_timeline`, `get_media`, `inspect_media`, `search_media` | +| タイムライン編集 | `add_clips`, `split_clip`, `set_clip_properties`, `set_keyframes`, `ripple_delete_ranges` | +| 生成 / インポート | `generate_video`, `generate_image`, `generate_audio`, `import_media` | +| ライブラリ | `create_folder`, `move_to_folder`, `rename_media` | +| リソース | `models/video`, `models/image` | + +公式 Codex / ChatGPT は、ターンごとにランダムな loopback ポート、256-bit Bearer、 +現在のプロジェクト ID を持つ一時 MCP を使用します。Beta 2 では旧来の未認証固定 +`127.0.0.1:19789` エンドポイントを無効化しています。 ### 🎬 クロスプラットフォームメディアエンジン @@ -159,7 +164,7 @@ crates/ │ opentake-domain / ops / project / render / media │ │ opentake-agent / gen / core │ │ ▲ │ │ -│ MCP Server (:19789) 呼出 ▼ │ +│ ターン単位の認証 MCP 呼出 ▼ │ │ In-app Agent Chat FFmpeg + wgpu + cpal + whisper │ └──────────────────────────────────────────────────────┘ ``` @@ -233,7 +238,9 @@ cd web && pnpm install && pnpm build cd .. && cargo tauri dev ``` -> ⚠️ **現在の状態**: 初期設計段階。アーキテクチャ、ロードマップ、モジュール移植マップは完了。コード実装中。 +> **現在の状態**: `1.0.0-beta.2` 候補版。ローカル編集、プレビュー、保存、 +> 書き出し、Agent、Motion Canvas、レビュー可能な AI ワークフローを実装済みです。 +> 検証範囲と制限は [Beta リリースノート](docs/releases/1.0.0-beta.2.md) を参照してください。 --- @@ -242,6 +249,8 @@ cd .. && cargo tauri dev | バージョン | 日付 | マイルストーン | |:--|:--|:--| | `0.1.0-dev` | 2026-06 | Phase 0+1: Cargo workspace + Domain models + Edit ops | +| `1.0.0-beta.1` | 2026-08-01 | 初回インストール可能 Beta:ローカル編集、Agent、Motion、レビュー可能な AI ワークフロー | +| `1.0.0-beta.2` | 2026-08-03 | 公式 Codex ログイン、原子的タイムライン操作、認証 MCP、操作性の強化 | | *(planned)* `1.0.0` | TBD | Phase 10: フルリリース | 📖 [完全なロードマップ](docs/architecture/ROADMAP.md) diff --git a/README.md b/README.md index 0cedf295..7b956fb3 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@ OpenTake is not a replacement for CapCut / DaVinci Resolve / Final Cut Pro — i |:--|:--|:--| | Agent doesn't know how to edit | Agent reads skill docs on its own | Software pushes Context Signal — "this track is A-roll, cut with talking-head rhythm" | | Cross-platform needs 3 codebases | macOS: Swift/AVFoundation, Windows: C++/DirectShow | Single Rust codebase, FFmpeg + wgpu, identical experience on all 3 platforms | -| I want to use my own AI keys | Locked into vendor cloud services | BYOK — direct to fal.ai / Replicate / OpenAI, zero backend, zero ops cost | +| I want to use AI directly | Locked into vendor cloud services | Official Codex / ChatGPT sign-in for Agent, plus BYOK for fal.ai / Replicate / OpenAI | | Agent can chat but can't act | CLI agent reads text output | MCP Server with 31 tools — Agent directly runs add_clips / split_clip / set_keyframes | | Rewriting prompts for every video type | "You are editing a product review..." every time | Workflow Plugin System: review/tutorial/gaming/wedding, each pre-packaged with methodology | | Steep learning curve for new tools | Complex UI, long onboarding | Agent operates for you — just say "edit this interview into a 3-minute highlight" | @@ -98,19 +98,27 @@ Knowledge source: [ClipSkills](https://github.com/appergb/ClipSkills) — 12-vol 📖 [Context Signal Design](docs/modules/opentake-agent/AGENT-CONTEXT-SIGNAL.md) -### 🔌 MCP Server — 31 Tools +### 🔌 Agent Tool Surface -Full MCP server at `127.0.0.1:19789`. Agents control the timeline directly: +OpenTake exposes 44 compatible Agent tools, filtered at runtime so unavailable media, +generation, or provider capabilities fail closed instead of being advertised: -| Group | Count | Key Tools | -|:--|:--:|:--| -| Read / Introspect | 7 | `get_timeline`, `get_media`, `inspect_media`, `search_media` | -| Timeline Edit | 11 | `add_clips`, `split_clip`, `set_clip_properties`, `set_keyframes`, `ripple_delete_ranges` | -| Generate / Import | 5 | `generate_video`, `generate_image`, `generate_audio`, `import_media` | -| Library | 7 | `create_folder`, `move_to_folder`, `rename_media` | -| Resources | 2 | `models/video`, `models/image` | +| Group | Key Tools | +|:--|:--| +| Read / Introspect | `get_timeline`, `get_media`, `inspect_media`, `search_media` | +| Timeline Edit | `add_clips`, `split_clip`, `set_clip_properties`, `set_keyframes`, `ripple_delete_ranges` | +| Generate / Import | `generate_video`, `generate_image`, `generate_audio`, `import_media` | +| Library | `create_folder`, `move_to_folder`, `rename_media` | +| Resources | `models/video`, `models/image` | + +Official Codex / ChatGPT turns use a fresh loopback endpoint with a random port, +256-bit Bearer token, and current-project identity for that turn only. The legacy +fixed unauthenticated `127.0.0.1:19789` endpoint is disabled in Beta 2; external +Claude/Cursor pairing will return only with an authenticated opt-in flow. -Built-in Agent chat panel shares tool definitions and system prompt with MCP. +Built-in Agent chat panel shares tool definitions and system prompt with MCP. It can use direct +OpenAI/Anthropic BYOK or the user-installed official Codex CLI's ChatGPT sign-in; OpenTake never +reads or stores the Codex credential. ### 🎬 Cross-Platform Media Engine @@ -209,7 +217,7 @@ plugins/ │ opentake-core Session / DI / Events │ │ │ │ ▲ │ │ -│ MCP Server (:19789) invokes ▼ │ +│ Authenticated per-turn MCP invokes ▼ │ │ In-app Agent Chat FFmpeg + wgpu + cpal + whisper │ └──────────────────────────────────────────────────────┘ ``` @@ -295,7 +303,10 @@ cd .. cargo tauri dev ``` -> ⚠️ **Current Status**: Early design phase. Architecture, roadmap, and module port maps are complete; code implementation in progress. +> **Current Status**: `1.0.0-beta.2` candidate. The local editing, preview, +> persistence, export, Agent, Motion Canvas, and reviewed AI workflow verticals +> are implemented. See the [Beta release notes](docs/releases/1.0.0-beta.2.md) +> for validation scope and platform/provider limits. The sibling directory `palmier-pro-upstream/` contains upstream Swift sources for reference during porting. @@ -306,9 +317,8 @@ The sibling directory `palmier-pro-upstream/` contains upstream Swift sources fo | Version | Date | Milestone | |:--|:--|:--| | `0.1.0-dev` | 2026-06 | Phase 0+1: Cargo workspace + Domain models + Edit ops + Tauri scaffold | -| *(planned)* `0.2.0` | TBD | Phase 2: Persistence + Media import + Thumbnails + Waveform | -| *(planned)* `0.3.0` | TBD | Phase 3: Timeline UI + Preview + MCP Server | -| *(planned)* `0.4.0` | TBD | Phase 4: GPU Compositor (wgpu) + Text rasterization | +| `1.0.0-beta.1` | 2026-08-01 | First installable Beta: end-to-end local editor, Agent, Motion and reviewed AI workflows | +| `1.0.0-beta.2` | 2026-08-03 | Hardened Beta: official Codex login, atomic timeline gestures, secure MCP and interaction polish | | *(planned)* `1.0.0` | TBD | Phase 10: Full release — CapCut parity + deep Agent integration | 📖 [Full Roadmap](docs/architecture/ROADMAP.md) diff --git a/README.zh-CN.md b/README.zh-CN.md index c15ed08b..324e2196 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -82,17 +82,22 @@ Agent 操作时间线时,每次工具返回附带 `context_signal`: 📖 [Context Signal 设计文档](docs/modules/opentake-agent/AGENT-CONTEXT-SIGNAL.md) -### 🔌 MCP Server — 31 个工具 +### 🔌 Agent 工具面 -完整的 MCP server (`127.0.0.1:19789`),Agent 可直接操控时间线: +OpenTake 提供 45 个兼容 Agent 工具,并按当前媒体、生成能力和 provider 授权动态发布; +未就绪能力会 fail closed,不会被虚假宣传为可执行: -| Group 分组 | Count | 代表工具 | -|:--|:--:|:--| -| Read / Introspect 读 / 内省 | 7 | `get_timeline`, `get_media`, `inspect_media`, `search_media` | -| Timeline Edit 时间线编辑 | 11 | `add_clips`, `split_clip`, `set_clip_properties`, `set_keyframes`, `ripple_delete_ranges` | -| Generate / Import 生成 / 导入 | 5 | `generate_video`, `generate_image`, `generate_audio`, `import_media` | -| Library 库组织 | 7 | `create_folder`, `move_to_folder`, `rename_media` | -| Resources | 2 | `models/video`, `models/image` | +| 分组 | 代表工具 | +|:--|:--| +| 读 / 内省 | `get_timeline`, `get_media`, `inspect_media`, `search_media` | +| 时间线编辑 | `add_clips`, `split_clip`, `set_clip_properties`, `set_keyframes`, `ripple_delete_ranges` | +| 生成 / 导入 | `generate_video`, `generate_image`, `generate_audio`, `import_media` | +| 素材库组织 | `create_folder`, `move_to_folder`, `rename_media` | +| 资源 | `models/video`, `models/image` | + +官方 Codex / ChatGPT 每轮使用独立的随机回环端口、256-bit Bearer 和当前工程身份, +轮次结束即销毁。Beta 2 已关闭旧的未认证固定 `127.0.0.1:19789` 入口;Claude、 +Cursor 等外部客户端将在后续带认证、显式配对流程完成后重新开放。 内置 Agent chat panel,与 MCP 共享工具定义和系统提示词。 @@ -176,7 +181,7 @@ plugins/ │ opentake-core Session / DI / Events │ │ │ │ ▲ │ │ -│ MCP Server (:19789) 调用 ▼ │ +│ 逐轮认证 MCP 调用 ▼ │ │ In-app Agent Chat FFmpeg + wgpu + cpal + whisper │ └──────────────────────────────────────────────────────┘ ``` @@ -258,7 +263,9 @@ cd web && pnpm install && pnpm build cd .. && cargo tauri dev ``` -> ⚠️ **当前状态**: 早期设计阶段。架构设计、路线图、模块移植地图已完成,代码正在落地中。 +> **当前状态**:`1.0.0-beta.2` 候选版。本地剪辑、预览、持久化、导出、Agent、 +> Motion Canvas 与可审阅 AI 工作流竖切均已实现。验证范围及平台/provider 限制见 +> [Beta 发布说明](docs/releases/1.0.0-beta.2.md)。 --- @@ -267,9 +274,8 @@ cd .. && cargo tauri dev | 版本 | 日期 | 里程碑 | |:--|:--|:--| | `0.1.0-dev` | 2026-06 | Phase 0+1: Cargo workspace + Domain models + Edit ops + Tauri scaffold | -| *(planned)* `0.2.0` | TBD | Phase 2: Persistence + Media import + Thumbnails + Waveform | -| *(planned)* `0.3.0` | TBD | Phase 3: Timeline UI + Preview + MCP Server | -| *(planned)* `0.4.0` | TBD | Phase 4: GPU Compositor (wgpu) + Text rasterization | +| `1.0.0-beta.1` | 2026-08-01 | 首个可安装 Beta:本地编辑闭环、Agent、Motion 与可审阅 AI 工作流 | +| `1.0.0-beta.2` | 2026-08-03 | 官方 Codex 登录、原子时间线手势、安全 MCP 与交互加固 | | *(planned)* `1.0.0` | TBD | Phase 10: 全功能发布 — 对标剪映 + Agent 深度集成 | 📖 [完整路线图](docs/architecture/ROADMAP.md) diff --git a/crates/opentake-agent/Cargo.toml b/crates/opentake-agent/Cargo.toml index ad357610..924580c6 100644 --- a/crates/opentake-agent/Cargo.toml +++ b/crates/opentake-agent/Cargo.toml @@ -25,6 +25,7 @@ futures = "0.3" # Base64-encode composited `inspect_timeline` frame bytes into MCP image content. base64 = "0.22" tokio = { version = "1", features = ["rt", "rt-multi-thread", "macros", "sync", "net", "time"] } +tokio-util = "0.7" # MCP server transport (Streamable HTTP over axum/hyper) + in-app chat HTTP. rmcp = { version = "2.2.0", features = [ @@ -39,6 +40,8 @@ http = "1" mime = "0.3" reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "rustls-tls"] } keyring = "3" +getrandom = "0.3" +subtle = "2" [dev-dependencies] tokio = { version = "1", features = ["rt", "rt-multi-thread", "macros", "sync", "net", "time", "test-util"] } diff --git a/crates/opentake-agent/src/chat/loop.rs b/crates/opentake-agent/src/chat/loop.rs index 3566d107..e444da05 100644 --- a/crates/opentake-agent/src/chat/loop.rs +++ b/crates/opentake-agent/src/chat/loop.rs @@ -29,7 +29,6 @@ use crate::plugin::registry::PluginRegistry; use crate::prompt::assemble::assemble_system_prompt; use crate::signal::engine::build_signal; use crate::tools::descriptions::{description, input_schema}; -use crate::tools::names::ToolName; use crate::tools::panic_boundary::with_redacted_dispatch_panic; use crate::tools::result::ToolResult; @@ -207,6 +206,45 @@ pub trait ChatTurnGate: Send + Sync { name: &str, args: serde_json::Value, ) -> Option; + + /// Dispatch with the transport request's cancellation token. Turn-bound + /// hosts normally cancel their own token from [`Self::request_cancel`]; + /// long-lived MCP authorities can instead forward this request-local token + /// without coupling unrelated sessions. + fn dispatch_cancellable( + &self, + dispatcher: &Dispatcher, + name: &str, + args: serde_json::Value, + _request_cancel: &opentake_media::MediaCancelToken, + ) -> Option { + self.dispatch(dispatcher, name, args) + } + + /// Dispatch under a transport-supplied undo owner. Long-lived MCP gates use + /// this to isolate rmcp sessions; project-turn gates may ignore it and retain + /// their stable OpenTake ChatSession owner. + fn dispatch_cancellable_scoped( + &self, + dispatcher: &Dispatcher, + name: &str, + args: serde_json::Value, + _undo_scope: &str, + request_cancel: &opentake_media::MediaCancelToken, + ) -> Option { + self.dispatch_cancellable(dispatcher, name, args, request_cancel) + } + + /// Request cancellation of the whole turn. Standalone callers have no + /// project-bound cancellation state, so their default remains a no-op. + fn request_cancel(&self) {} + + /// Stop in-flight dispatcher work while cleaning up an internally failed + /// provider turn. Project hosts may keep this distinct from a user-requested + /// whole-turn cancellation so the terminal failure can still be persisted. + fn request_dispatch_cancel(&self) { + self.request_cancel(); + } } struct DirectChatTurnGate; @@ -251,18 +289,15 @@ impl ChatLoop { } /// The tool catalog in the OpenAI function-calling shape. Built fresh per - /// turn (cheap; ~44 tools) so the model always sees the current schema. + /// turn (cheap; currently 38 base live tools) so the model always sees the + /// current fail-closed catalog. /// /// When the dispatcher lacks a media bridge, hide the bridge-dependent /// tools instead of advertising tools that would only fail at runtime. fn tool_catalog(&self) -> Vec { - ToolName::ALL - .iter() - .copied() - .filter(|tool| { - self.dispatcher.has_media_bridge() - || !matches!(tool, ToolName::InspectTimeline | ToolName::ImportMedia) - }) + self.dispatcher + .advertised_tools() + .into_iter() .map(|tool| ToolSchema { name: tool.as_str().to_string(), description: description(tool).to_string(), @@ -281,7 +316,10 @@ impl ChatLoop { if let Ok(json) = serde_json::to_value(&signal) { s.push_str("\n\n# Current timeline context signal\n"); s.push_str(&serde_json::to_string_pretty(&json).unwrap_or_default()); - s.push_str("\n\nUse this signal to pick the right tool without re-reading the timeline first. For example, if the user asks to tighten silences on a talking-head timeline, call `tighten_silences` then `ripple_delete_ranges` with the returned ranges."); + s.push_str("\n\nUse this signal to pick the right tool without re-reading the timeline first. For example, if the user asks to tighten silences on a talking-head timeline, call `tighten_silences` then `ripple_delete_ranges` with the accepted returned ranges."); + if self.dispatcher.has_media_bridge() { + s.push_str(" If the user asks to remove filler words, call `remove_filler_words`, let them review the word-aligned cuts, then apply only the accepted ranges with `ripple_delete_ranges`."); + } } s } @@ -603,6 +641,10 @@ mod tests { let loop_ = build_loop(talking_head_timeline(), Arc::new(MemoryKeyStore::new())); let tools = loop_.tool_catalog(); assert!(tools.iter().any(|t| t.name == "tighten_silences")); + assert!(!tools.iter().any(|t| t.name == "remove_filler_words")); + assert!(!tools.iter().any(|t| t.name == "get_transcript")); + assert!(!tools.iter().any(|t| t.name == "search_media")); + assert!(!tools.iter().any(|t| t.name == "inspect_media")); assert!(!tools.iter().any(|t| t.name == "inspect_timeline")); assert!(!tools.iter().any(|t| t.name == "import_media")); } diff --git a/crates/opentake-agent/src/mcp/advanced.rs b/crates/opentake-agent/src/mcp/advanced.rs new file mode 100644 index 00000000..c55e1c3c --- /dev/null +++ b/crates/opentake-agent/src/mcp/advanced.rs @@ -0,0 +1,531 @@ +//! Host boundary for capability-gated advanced editing workflows. +//! +//! The agent owns stable, strict tool contracts. The desktop host owns model +//! availability, provider authorization, rendering, imports, and atomic edits. +//! A tool is discoverable only when the injected host bridge explicitly lists +//! it as supported; this prevents schema-only placeholders from reaching users. + +use serde_json::Value; + +use crate::tools::args::{ + CloneVoiceArgs, GenerateAvatarArgs, GenerateMatteArgs, MatchColorArgs, RemoveObjectArgs, + ScriptToVideoArgs, SeparateStemsArgs, TrackMotionArgs, TranslateCaptionsArgs, +}; +use crate::tools::names::ToolName; + +#[derive(Debug, Clone, PartialEq)] +pub enum AdvancedWorkflowRequest { + TrackMotion(TrackMotionArgs), + GenerateMatte(GenerateMatteArgs), + RemoveObject(RemoveObjectArgs), + MatchColor(MatchColorArgs), + SeparateStems(SeparateStemsArgs), + TranslateCaptions(TranslateCaptionsArgs), + ScriptToVideo(ScriptToVideoArgs), + GenerateAvatar(GenerateAvatarArgs), + CloneVoice(CloneVoiceArgs), +} + +impl AdvancedWorkflowRequest { + pub fn tool(&self) -> ToolName { + match self { + Self::TrackMotion(_) => ToolName::TrackMotion, + Self::GenerateMatte(_) => ToolName::GenerateMatte, + Self::RemoveObject(_) => ToolName::RemoveObject, + Self::MatchColor(_) => ToolName::MatchColor, + Self::SeparateStems(_) => ToolName::SeparateStems, + Self::TranslateCaptions(_) => ToolName::TranslateCaptions, + Self::ScriptToVideo(_) => ToolName::ScriptToVideo, + Self::GenerateAvatar(_) => ToolName::GenerateAvatar, + Self::CloneVoice(_) => ToolName::CloneVoice, + } + } +} + +#[derive(Debug, Clone, PartialEq)] +pub struct AdvancedWorkflowCommit { + /// Structured result returned to the agent after a successful operation. + pub result: Value, + /// Present only when the host committed an undoable project mutation. + pub action_name: Option, +} + +/// Rebuild a host-owned advanced-workflow payload as a model-facing typed +/// allowlist. The desktop host may carry paths, provider request ids, hashes, +/// or raw provider diagnostics in its internal result; none are copied unless a +/// field is explicitly reconstructed below with its expected primitive shape. +pub(crate) fn model_safe_result(tool: ToolName, result: &Value) -> Value { + let source = result.as_object(); + let mut out = serde_json::Map::new(); + out.insert("tool".into(), Value::String(tool.as_str().to_string())); + + if let Some(status) = source + .and_then(|object| object.get("status")) + .and_then(Value::as_str) + .filter(|status| matches!(*status, "completed" | "completed-without-edit")) + { + out.insert("status".into(), Value::String(status.to_string())); + } + + match tool { + ToolName::TrackMotion => { + copy_id(source, &mut out, "clipId"); + copy_bool(source, &mut out, "applied"); + copy_number(source, &mut out, "minimumConfidence"); + copy_region(source, &mut out); + copy_tracking_keyframes(source, &mut out); + } + ToolName::GenerateMatte | ToolName::RemoveObject => { + for key in ["clipId", "sourceMediaRef", "assetId"] { + copy_id(source, &mut out, key); + } + copy_bool(source, &mut out, "applied"); + for key in ["frameCount", "width", "height", "startFrame", "endFrame"] { + copy_integer(source, &mut out, key); + } + copy_number(source, &mut out, "fps"); + } + ToolName::MatchColor => { + for key in ["clipId", "referenceMediaRef"] { + copy_id(source, &mut out, key); + } + for key in ["referenceFrame", "targetFrame"] { + copy_integer(source, &mut out, key); + } + for key in [ + "targetMeanLinear", + "referenceMeanLinear", + "matchedMeanLinear", + "deltaEBefore", + "deltaEAfter", + "targetLumaBefore", + "targetLumaAfter", + ] { + copy_number(source, &mut out, key); + } + copy_bool(source, &mut out, "applied"); + } + ToolName::SeparateStems => { + for key in ["sourceMediaRef", "vocalsAssetId", "accompanimentAssetId"] { + copy_id(source, &mut out, key); + } + copy_id_array(source, &mut out, "clipIds", 64); + copy_bool(source, &mut out, "importedToTracks"); + copy_number(source, &mut out, "vocalSdrImprovementDb"); + } + ToolName::TranslateCaptions => { + for key in ["projectEpoch", "version", "captionCount", "translatedCount"] { + copy_integer(source, &mut out, key); + } + for key in ["sourceLocale", "targetLocale"] { + copy_token(source, &mut out, key, 32); + } + copy_bool(source, &mut out, "applied"); + copy_translation_review(source, &mut out); + let failure_count = source + .and_then(|object| object.get("errors")) + .and_then(Value::as_array) + .map_or(0, Vec::len); + out.insert("failureCount".into(), Value::from(failure_count)); + } + ToolName::ScriptToVideo => { + for key in ["projectEpoch", "version", "startFrame", "endFrame"] { + copy_integer(source, &mut out, key); + } + copy_id(source, &mut out, "planId"); + copy_bool(source, &mut out, "applied"); + copy_script_segments(source, &mut out); + } + ToolName::GenerateAvatar => { + for key in ["assetId", "portraitMediaRef", "audioMediaRef"] { + copy_id(source, &mut out, key); + } + copy_id_array(source, &mut out, "clipIds", 64); + copy_integer(source, &mut out, "durationFrames"); + copy_bool(source, &mut out, "imported"); + } + ToolName::CloneVoice => { + copy_enum( + source, + &mut out, + "action", + &["enroll", "generate", "revoke"], + ); + for key in ["voiceId", "assetId", "sourceAudioMediaRef"] { + copy_id(source, &mut out, key); + } + copy_id_array(source, &mut out, "clipIds", 64); + copy_text(source, &mut out, "voiceName", 256); + copy_integer(source, &mut out, "durationFrames"); + copy_bool(source, &mut out, "imported"); + copy_bool(source, &mut out, "revoked"); + } + _ => {} + } + Value::Object(out) +} + +fn safe_token(value: &str, max_chars: usize) -> bool { + !value.is_empty() + && value.chars().count() <= max_chars + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b':')) +} + +fn copy_id( + source: Option<&serde_json::Map>, + out: &mut serde_json::Map, + key: &str, +) { + copy_token(source, out, key, 128); +} + +fn copy_token( + source: Option<&serde_json::Map>, + out: &mut serde_json::Map, + key: &str, + max_chars: usize, +) { + if let Some(value) = source + .and_then(|object| object.get(key)) + .and_then(Value::as_str) + .filter(|value| safe_token(value, max_chars)) + { + out.insert(key.into(), Value::String(value.to_string())); + } +} + +fn copy_text( + source: Option<&serde_json::Map>, + out: &mut serde_json::Map, + key: &str, + max_chars: usize, +) { + if let Some(value) = source + .and_then(|object| object.get(key)) + .and_then(Value::as_str) + .filter(|value| value.chars().count() <= max_chars) + { + out.insert(key.into(), Value::String(value.to_string())); + } +} + +fn copy_enum( + source: Option<&serde_json::Map>, + out: &mut serde_json::Map, + key: &str, + allowed: &[&str], +) { + if let Some(value) = source + .and_then(|object| object.get(key)) + .and_then(Value::as_str) + .filter(|value| allowed.contains(value)) + { + out.insert(key.into(), Value::String(value.to_string())); + } +} + +fn copy_bool( + source: Option<&serde_json::Map>, + out: &mut serde_json::Map, + key: &str, +) { + if let Some(value) = source + .and_then(|object| object.get(key)) + .and_then(Value::as_bool) + { + out.insert(key.into(), Value::Bool(value)); + } +} + +fn copy_integer( + source: Option<&serde_json::Map>, + out: &mut serde_json::Map, + key: &str, +) { + if let Some(value) = source.and_then(|object| object.get(key)).and_then(|value| { + value + .as_i64() + .or_else(|| value.as_u64().and_then(|v| i64::try_from(v).ok())) + }) { + out.insert(key.into(), Value::from(value)); + } +} + +fn copy_number( + source: Option<&serde_json::Map>, + out: &mut serde_json::Map, + key: &str, +) { + if let Some(value) = source + .and_then(|object| object.get(key)) + .and_then(Value::as_f64) + .filter(|value| value.is_finite()) + .and_then(serde_json::Number::from_f64) + { + out.insert(key.into(), Value::Number(value)); + } +} + +fn copy_id_array( + source: Option<&serde_json::Map>, + out: &mut serde_json::Map, + key: &str, + max_items: usize, +) { + let Some(values) = source + .and_then(|object| object.get(key)) + .and_then(Value::as_array) + else { + return; + }; + let values = values + .iter() + .take(max_items) + .filter_map(Value::as_str) + .filter(|value| safe_token(value, 128)) + .map(|value| Value::String(value.to_string())) + .collect(); + out.insert(key.into(), Value::Array(values)); +} + +fn copy_region( + source: Option<&serde_json::Map>, + out: &mut serde_json::Map, +) { + let Some(region) = source + .and_then(|object| object.get("region")) + .and_then(Value::as_object) + else { + return; + }; + let mut safe = serde_json::Map::new(); + for key in ["x", "y", "width", "height"] { + copy_number(Some(region), &mut safe, key); + } + if safe.len() == 4 { + out.insert("region".into(), Value::Object(safe)); + } +} + +fn copy_tracking_keyframes( + source: Option<&serde_json::Map>, + out: &mut serde_json::Map, +) { + let Some(values) = source + .and_then(|object| object.get("keyframes")) + .and_then(Value::as_array) + else { + return; + }; + let rows = values + .iter() + .take(10_000) + .filter_map(Value::as_object) + .filter_map(|row| { + let frame = row.get("frame")?.as_i64()?; + let position = row.get("position")?.as_object()?; + let x = position + .get("x")? + .as_f64() + .filter(|value| value.is_finite())?; + let y = position + .get("y")? + .as_f64() + .filter(|value| value.is_finite())?; + Some(serde_json::json!({ + "frame": frame, + "position": {"x": x, "y": y}, + "interpolation": "linear" + })) + }) + .collect(); + out.insert("keyframes".into(), Value::Array(rows)); +} + +fn copy_translation_review( + source: Option<&serde_json::Map>, + out: &mut serde_json::Map, +) { + let Some(values) = source + .and_then(|object| object.get("review")) + .and_then(Value::as_array) + else { + return; + }; + let rows = values + .iter() + .take(500) + .filter_map(Value::as_object) + .filter_map(|row| { + let id = row + .get("id")? + .as_str() + .filter(|value| safe_token(value, 128))?; + let source_text = row + .get("sourceText")? + .as_str() + .filter(|value| value.chars().count() <= 20_000)?; + let translated_text = row + .get("translatedText")? + .as_str() + .filter(|value| value.chars().count() <= 20_000)?; + Some(serde_json::json!({ + "id": id, + "sourceText": source_text, + "translatedText": translated_text, + })) + }) + .collect(); + out.insert("review".into(), Value::Array(rows)); +} + +fn copy_script_segments( + source: Option<&serde_json::Map>, + out: &mut serde_json::Map, +) { + let Some(values) = source + .and_then(|object| object.get("segments")) + .and_then(Value::as_array) + else { + return; + }; + let rows = values + .iter() + .take(100) + .filter_map(Value::as_object) + .filter_map(|row| { + let script = row + .get("script")? + .as_str() + .filter(|value| value.chars().count() <= 20_000)?; + let media_ref = row + .get("mediaRef")? + .as_str() + .filter(|value| safe_token(value, 128))?; + let start_frame = row.get("startFrame")?.as_i64()?; + let duration_frames = row.get("durationFrames")?.as_i64()?; + let mut safe = serde_json::json!({ + "script": script, + "mediaRef": media_ref, + "startFrame": start_frame, + "durationFrames": duration_frames, + }); + if let Some(narration) = row + .get("narrationMediaRef") + .and_then(Value::as_str) + .filter(|value| safe_token(value, 128)) + { + safe["narrationMediaRef"] = Value::String(narration.to_string()); + } + Some(safe) + }) + .collect(); + out.insert("segments".into(), Value::Array(rows)); +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AdvancedWorkflowErrorKind { + InvalidArguments, + ResourceNotFound, + CapabilityUnavailable, + ConsentRequired, + CostAuthorizationRequired, + AnalysisLowConfidence, + Cancelled, + ExecutionFailed, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AdvancedWorkflowError { + pub kind: AdvancedWorkflowErrorKind, + pub message: String, +} + +impl AdvancedWorkflowError { + pub fn new(kind: AdvancedWorkflowErrorKind, message: impl Into) -> Self { + Self { + kind, + message: message.into(), + } + } +} + +pub trait AdvancedWorkflowBridge: Send + Sync { + /// Exact advanced tools backed by a production implementation right now. + /// The dispatcher ignores names outside [`ToolName::ADVANCED_AI`]. + fn supported_tools(&self) -> Vec; + + fn execute( + &self, + request: AdvancedWorkflowRequest, + cancel: &opentake_media::MediaCancelToken, + ) -> Result; +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn model_safe_results_drop_host_paths_urls_provider_ids_and_diagnostics() { + const PRIVATE_PATH: &str = "/Users/private/render/avatar.mov"; + const SIGNED_URL: &str = "https://provider.invalid/output.mov?token=SIGNED_ADVANCED_SECRET"; + const PROVIDER_ID: &str = "provider-request-PRIVATE-123"; + const RAW_ERROR: &str = "ffmpeg failed opening /Users/private/source.mov"; + const API_KEY: &str = "sk-private-advanced-key"; + const PRIVATE_PROMPT: &str = "PRIVATE_AVATAR_PROMPT"; + + let result = serde_json::json!({ + "status": "completed", + "clipId": "clip-safe", + "sourceMediaRef": "source-safe", + "assetId": "asset-safe", + "portraitMediaRef": "portrait-safe", + "audioMediaRef": "audio-safe", + "clipIds": ["clip-safe"], + "applied": true, + "imported": true, + "previewPath": PRIVATE_PATH, + "signedUrl": SIGNED_URL, + "providerRequestId": PROVIDER_ID, + "prompt": PRIVATE_PROMPT, + "errors": [{"message": RAW_ERROR, "apiKey": API_KEY}], + "provider": {"apiKey": API_KEY, "url": SIGNED_URL}, + "unknown": {"path": PRIVATE_PATH} + }); + + for tool in ToolName::ADVANCED_AI { + let safe = model_safe_result(tool, &result); + let encoded = safe.to_string(); + assert_eq!(safe["tool"], tool.as_str()); + assert_eq!(safe["status"], "completed"); + for private in [ + PRIVATE_PATH, + SIGNED_URL, + PROVIDER_ID, + RAW_ERROR, + API_KEY, + PRIVATE_PROMPT, + ] { + assert!( + !encoded.contains(private), + "{} leaked {private}: {encoded}", + tool.as_str() + ); + } + for forbidden_key in [ + "previewPath", + "signedUrl", + "providerRequestId", + "prompt", + "errors", + "provider", + "unknown", + ] { + assert!(safe.get(forbidden_key).is_none(), "{tool:?}: {safe}"); + } + } + } +} diff --git a/crates/opentake-agent/src/mcp/convert.rs b/crates/opentake-agent/src/mcp/convert.rs index 900a3289..a9d01895 100644 --- a/crates/opentake-agent/src/mcp/convert.rs +++ b/crates/opentake-agent/src/mcp/convert.rs @@ -136,6 +136,22 @@ fn safe_public_detail(kind: PublicErrorKind, private_detail: &str) -> Option { Some(safe_invalid_argument_detail(tool, private_detail)) } + PublicErrorKind::ResourceNotFound(tool) => Some(format!( + "{} could not resolve the referenced project resource.", + tool.as_str() + )), + PublicErrorKind::CapabilityUnavailable(tool) => Some(format!( + "{} cannot inspect this source in the current build or source state.", + tool.as_str() + )), + PublicErrorKind::PathAuthorityRequired(tool) => Some(format!( + "{} cannot use a model-supplied local path without user-granted file access.", + tool.as_str() + )), + PublicErrorKind::AnalysisLowConfidence(tool) => Some(format!( + "{} could not identify the selected subject reliably.", + tool.as_str() + )), } } @@ -314,6 +330,41 @@ mod tests { assert!(!wire.contains("expected i32")); } + #[test] + fn typed_unavailable_error_exposes_only_fixed_recovery_contract() { + let private = "inspect_media: /Users/alice/private.mov is offline"; + let result = ToolResult::public_error( + PublicErrorKind::CapabilityUnavailable(ToolName::InspectMedia), + private, + ); + let value = safe_tool_result_for_llm(&result); + assert_eq!(value["code"], "MCP_CAPABILITY_UNAVAILABLE"); + assert_eq!( + value["message"], + "This capability is unavailable for the referenced media." + ); + assert_eq!( + value["details"], + "inspect_media cannot inspect this source in the current build or source state." + ); + assert!(!value.to_string().contains("/Users/alice")); + } + + #[test] + fn low_confidence_error_has_a_fixed_safe_retry_contract() { + let result = ToolResult::public_error( + PublicErrorKind::AnalysisLowConfidence(ToolName::TrackMotion), + "confidence=0.02 path=/private/source.mp4", + ); + let value = safe_tool_result_for_llm(&result); + assert_eq!(value["code"], "MCP_ANALYSIS_LOW_CONFIDENCE"); + assert_eq!( + value["details"], + "track_motion could not identify the selected subject reliably." + ); + assert!(!value.to_string().contains("/private/source.mp4")); + } + #[test] fn explicitly_public_marker_cannot_bypass_detail_guard() { let private = "/Users/alice/private.mp4"; diff --git a/crates/opentake-agent/src/mcp/core_handle.rs b/crates/opentake-agent/src/mcp/core_handle.rs index b12766fa..15ff5f20 100644 --- a/crates/opentake-agent/src/mcp/core_handle.rs +++ b/crates/opentake-agent/src/mcp/core_handle.rs @@ -10,11 +10,26 @@ use std::path::PathBuf; -use opentake_core::AppCore; +use opentake_core::{AppCore, OwnedUndoResult, ProjectRevision}; use opentake_domain::{MediaManifest, MediaResolver, Timeline}; use opentake_media::{extract_pcm, PcmBuffer, PcmSpec}; use opentake_ops::command::{EditCommand, EditResult}; +/// Project/document identity captured at a dispatcher commit boundary. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CoreRevision { + pub project_epoch: u64, + pub project_dir: Option, + pub timeline_version: u64, +} + +/// Exact history transaction currently eligible for Undo. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CoreUndoHead { + pub action_name: String, + pub transaction_version: u64, +} + /// The narrow document surface the dispatch shell needs. `Send + Sync` so a /// `Dispatcher` holding `Arc` stays shareable across threads /// (matching [`AppCore`]'s cross-client design). @@ -31,6 +46,45 @@ pub trait CoreHandle: Send + Sync { /// shell can turn any failure into a single `ToolResult::error`. fn apply(&self, cmd: EditCommand) -> anyhow::Result; + /// One-lock project/document identity when the host exposes it. Lightweight + /// test handles may return `None`; production must return an exact revision. + fn current_revision(&self) -> Option { + None + } + + /// Apply only if the captured project identity and version remain current. + fn apply_at_revision( + &self, + expected: &CoreRevision, + cmd: EditCommand, + ) -> anyhow::Result { + if self.current_revision().as_ref() != Some(expected) { + anyhow::bail!("stale project revision"); + } + self.apply(cmd) + } + + /// Exact top-level undo transaction, or `None` when history is empty or this + /// handle cannot expose ownership metadata. + fn undo_head(&self) -> Option { + None + } + + /// One-lock revision + undo-head snapshot when supported. The default is + /// suitable only for deterministic test handles; production overrides it. + fn revision_and_undo_head(&self) -> Option<(CoreRevision, CoreUndoHead)> { + Some((self.current_revision()?, self.undo_head()?)) + } + + /// Atomically compare the complete owner marker and Undo it. + fn undo_if_owned( + &self, + _expected: &CoreRevision, + _expected_head: &CoreUndoHead, + ) -> anyhow::Result { + Ok(OwnedUndoResult::NoHistory) + } + /// The open project's bundle directory, or `None` for an unsaved project. fn project_dir(&self) -> Option; @@ -83,6 +137,69 @@ impl CoreHandle for AppCoreHandle { self.0.apply(cmd).map_err(|e| anyhow::anyhow!("{e}")) } + fn current_revision(&self) -> Option { + let snapshot = self.0.runtime_snapshot(); + Some(CoreRevision { + project_epoch: snapshot.project_epoch, + project_dir: snapshot.project_dir, + timeline_version: snapshot.version, + }) + } + + fn apply_at_revision( + &self, + expected: &CoreRevision, + cmd: EditCommand, + ) -> anyhow::Result { + self.0 + .apply_at_project_revision( + ProjectRevision { + project_epoch: expected.project_epoch, + version: expected.timeline_version, + }, + expected.project_dir.as_deref(), + cmd, + ) + .map_err(|error| anyhow::anyhow!("{error}")) + } + + fn undo_head(&self) -> Option { + self.revision_and_undo_head().map(|(_, head)| head) + } + + fn revision_and_undo_head(&self) -> Option<(CoreRevision, CoreUndoHead)> { + let snapshot = self.0.project_undo_snapshot()?; + Some(( + CoreRevision { + project_epoch: snapshot.revision.project_epoch, + project_dir: snapshot.project_path, + timeline_version: snapshot.revision.version, + }, + CoreUndoHead { + action_name: snapshot.action_name, + transaction_version: snapshot.transaction_version, + }, + )) + } + + fn undo_if_owned( + &self, + expected: &CoreRevision, + expected_head: &CoreUndoHead, + ) -> anyhow::Result { + self.0 + .undo_if_owned( + ProjectRevision { + project_epoch: expected.project_epoch, + version: expected.timeline_version, + }, + expected.project_dir.as_deref(), + &expected_head.action_name, + expected_head.transaction_version, + ) + .map_err(|error| anyhow::anyhow!("{error}")) + } + fn project_dir(&self) -> Option { self.0.project_dir() } @@ -152,6 +269,8 @@ mod tests { source_height: None, source_fps: None, has_audio: None, + color: None, + proxy: None, folder_id: None, cached_remote_url: None, cached_remote_url_expires_at: None, diff --git a/crates/opentake-agent/src/mcp/dispatch.rs b/crates/opentake-agent/src/mcp/dispatch.rs index 89a8a3b1..5889f552 100644 --- a/crates/opentake-agent/src/mcp/dispatch.rs +++ b/crates/opentake-agent/src/mcp/dispatch.rs @@ -12,34 +12,49 @@ //! 7. shorten outbound ids in the result, //! 8. return the [`ToolResult`]. //! -//! Sync throughout: every wired (EXISTS-mapped) tool is synchronous. The async -//! generation / media tools are stubs in this phase and return an honest -//! "not yet implemented" so the tool table is complete. +//! Sync throughout: every advertised (EXISTS-mapped) tool has a synchronous +//! dispatch path. Future async generation and Motion tools retain known wire +//! names for compatibility but stay out of discovery until their backends are +//! production-ready. -use std::collections::BTreeMap; +use std::cell::RefCell; +use std::collections::{BTreeMap, BTreeSet, HashMap}; use std::sync::{Arc, Mutex, RwLock}; +use opentake_core::OwnedUndoResult; use opentake_domain::{AnimPair, Crop, Interpolation, Keyframe, KeyframeTrack}; use opentake_domain::{ - ChromaKey, ColorGrade, Effect, LiftGammaGain, Mask, MaskShape, MediaManifest, Point2, Rgb, - Rgba, TextStyle, Timeline, Transform, VideoType, + ChromaKey, ColorGrade, Effect, GenerationJobStatus, LiftGammaGain, Mask, MaskShape, + MediaManifest, Point2, Rgb, Rgba, TextStyle, Timeline, Transform, VideoType, }; use opentake_media::analysis::{ detect_beats, detect_silences, BeatDetectionConfig, SilenceDetectionConfig, }; use opentake_media::{PcmFormat, PcmSpec}; use opentake_ops::{ - ClipEntry, ClipMove, ClipProperties, EditCommand, FrameRange, KeyframePayload, - KeyframeProperty, RenameEntry, TextEntry, + ClipEntry, ClipMove, ClipProperties, ClipPropertyAssignment, EditCommand, FrameRange, + KeyframePayload, KeyframeProperty, RenameEntry, TextEntry, }; use serde_json::Value; -use crate::mcp::core_handle::CoreHandle; +use crate::mcp::advanced::{ + model_safe_result, AdvancedWorkflowBridge, AdvancedWorkflowError, AdvancedWorkflowErrorKind, + AdvancedWorkflowRequest, +}; +use crate::mcp::core_handle::{CoreHandle, CoreRevision, CoreUndoHead}; use crate::mcp::gen_catalog; +use crate::mcp::generation::{GenerationBridge, GenerationRequest}; use crate::mcp::media_bridge::{ - frame_to_block, ImportSource, InspectResult, MediaBridge, SearchCandidate, TranscriptSource, + frame_to_block, media_frame_to_block, BridgeErrorKind, ImportSource, InspectMediaRequest, + InspectMediaResult, InspectResult, MediaBridge, SearchCandidate, TranscriptSource, IMPORT_BYTES_BASE64_MAX, }; +use crate::mcp::media_catalog::ModelMediaCatalog; +use crate::mcp::motion::{ + model_safe_commit, AddMotionRequest, EditMotionRequest, MotionBridge, MotionBridgeError, + MotionBridgeErrorKind, MotionSourceRequest, +}; +use crate::mcp::vision::VisionBridge; use crate::plugin::registry::PluginRegistry; use crate::signal::engine; use crate::signal::rules::OpContext; @@ -56,6 +71,117 @@ use crate::tools::short_id; const INSPECT_TIMELINE_DEFAULT_FRAMES: i32 = 6; const INSPECT_TIMELINE_MAX_FRAMES: i32 = 12; const INSPECT_TIMELINE_MAX_DIMENSION: u32 = 512; +const INSPECT_MEDIA_DEFAULT_FRAMES: usize = 6; +const INSPECT_MEDIA_MAX_FRAMES: usize = 12; +const INSPECT_MEDIA_MAX_SEGMENTS: usize = 400; +const INSPECT_MEDIA_MAX_WORDS: usize = 10_000; +const DIRECT_UNDO_SCOPE: &str = "opentake:direct"; + +thread_local! { + static ACTIVE_UNDO_SCOPES: RefCell> = const { RefCell::new(Vec::new()) }; +} + +struct ActiveUndoScope; + +impl ActiveUndoScope { + fn enter(scope: &str) -> Self { + ACTIVE_UNDO_SCOPES.with(|scopes| scopes.borrow_mut().push(scope.to_string())); + Self + } + + fn current() -> String { + ACTIVE_UNDO_SCOPES + .with(|scopes| scopes.borrow().last().cloned()) + .unwrap_or_else(|| DIRECT_UNDO_SCOPE.to_string()) + } +} + +impl Drop for ActiveUndoScope { + fn drop(&mut self) { + ACTIVE_UNDO_SCOPES.with(|scopes| { + scopes.borrow_mut().pop(); + }); + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct AgentUndoMarker { + revision: CoreRevision, + head: CoreUndoHead, +} + +/// Resource class used by the HTTP MCP host before it starts blocking work. +/// Unknown or malformed calls are conservatively treated as mutations. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum DispatchAdmissionClass { + ReadOnly, + Mutation, +} + +pub(crate) fn dispatch_admission_class(name: &str, args: &Value) -> DispatchAdmissionClass { + let Ok(tool) = name.parse::() else { + return DispatchAdmissionClass::Mutation; + }; + match tool { + ToolName::GetTimeline + | ToolName::GetMedia + | ToolName::InspectMedia + | ToolName::GetTranscript + | ToolName::InspectTimeline + | ToolName::SearchMedia + | ToolName::ListModels + | ToolName::ListFolders + | ToolName::ListWorkflows + | ToolName::DetectBeats + | ToolName::SmartReframe + | ToolName::TightenSilences + | ToolName::RemoveFillerWords => DispatchAdmissionClass::ReadOnly, + ToolName::AutoCutToBeats => match args.get("write") { + None | Some(Value::Bool(false)) => DispatchAdmissionClass::ReadOnly, + Some(_) => DispatchAdmissionClass::Mutation, + }, + ToolName::AddClips + | ToolName::InsertClips + | ToolName::RemoveClips + | ToolName::RemoveTracks + | ToolName::MoveClips + | ToolName::SetClipProperties + | ToolName::SetKeyframes + | ToolName::SplitClip + | ToolName::RippleDeleteRanges + | ToolName::Undo + | ToolName::AddTexts + | ToolName::AddCaptions + | ToolName::GenerateVideo + | ToolName::GenerateImage + | ToolName::GenerateAudio + | ToolName::UpscaleMedia + | ToolName::ImportMedia + | ToolName::CreateFolder + | ToolName::MoveToFolder + | ToolName::RenameMedia + | ToolName::RenameFolder + | ToolName::DeleteMedia + | ToolName::DeleteFolder + | ToolName::ActivateWorkflow + | ToolName::DeactivateWorkflow + | ToolName::SetColorGrade + | ToolName::ChromaKey + | ToolName::SetMask + | ToolName::ApplyEffect + | ToolName::AddMotionGraphic + | ToolName::EditMotionGraphic + | ToolName::TrackMotion + | ToolName::GenerateMatte + | ToolName::RemoveObject + | ToolName::MatchColor + | ToolName::SeparateStems + | ToolName::TranslateCaptions + | ToolName::ScriptToVideo + | ToolName::GenerateAvatar + | ToolName::CloneVoice => DispatchAdmissionClass::Mutation, + } +} /// The in-process tool dispatcher. Holds the [`CoreHandle`] boundary, the plugin /// registry (read-locked for the active plugin), and a per-dispatcher agent-undo @@ -68,9 +194,21 @@ pub struct Dispatcher { /// [`CoreHandle`] because those two capabilities reach into crates the agent /// layer does not link (`opentake-render`, the src-tauri import path). bridge: Option>, - /// Action names of agent edits applied through this dispatcher, newest last. - /// Guards `undo`: we only revert when this session has pushed an edit. - agent_undo: Mutex>, + /// Paid generation/upscale side-door. The desktop host injects this only + /// when it can persist jobs and run configured providers. + generation_bridge: Option>, + /// Deterministic render + atomic import/place host capability. Motion tools + /// are discoverable only while this bridge reports production readiness. + motion_bridge: Option>, + /// Capability-gated advanced workflows. Each tool is discovered only when + /// this bridge explicitly reports a production implementation for it. + advanced_bridge: Option>, + /// Capability-gated vision analysis. `smart_reframe` is discovered only + /// while this bridge reports a usable backend. + vision_bridge: Option>, + /// Exact undo ownership markers keyed by the explicit OpenTake chat or MCP + /// transport session. The dispatcher is shared, so a global stack is unsafe. + agent_undo: Mutex>>, } impl Dispatcher { @@ -88,21 +226,130 @@ impl Dispatcher { handle: Arc, registry: Arc>, bridge: Option>, + ) -> Self { + Self::with_bridges(handle, registry, bridge, None) + } + + /// New dispatcher with independent media and generation host capabilities. + pub fn with_bridges( + handle: Arc, + registry: Arc>, + bridge: Option>, + generation_bridge: Option>, + ) -> Self { + Self::with_capability_bridges(handle, registry, bridge, generation_bridge, None) + } + + /// New dispatcher with every optional host capability injected + /// independently. The narrower constructors remain source-compatible for + /// non-desktop hosts and tests. + pub fn with_capability_bridges( + handle: Arc, + registry: Arc>, + bridge: Option>, + generation_bridge: Option>, + motion_bridge: Option>, + ) -> Self { + Self::with_all_capability_bridges( + handle, + registry, + bridge, + generation_bridge, + motion_bridge, + None, + ) + } + + pub fn with_all_capability_bridges( + handle: Arc, + registry: Arc>, + bridge: Option>, + generation_bridge: Option>, + motion_bridge: Option>, + advanced_bridge: Option>, ) -> Self { Dispatcher { handle, registry, bridge, - agent_undo: Mutex::new(Vec::new()), + generation_bridge, + motion_bridge, + advanced_bridge, + vision_bridge: None, + agent_undo: Mutex::new(HashMap::new()), } } + /// Attach a vision-analysis capability (subject-aware reframing). The + /// `smart_reframe` tool is discovered only while the injected host reports + /// a usable backend; hosts and tests without one stay fail-closed. The + /// setter keeps every existing bridge constructor source-compatible. + pub fn with_vision_bridge(mut self, vision_bridge: Option>) -> Self { + self.vision_bridge = vision_bridge; + self + } + + pub fn can_do_vision_analysis(&self) -> bool { + self.vision_bridge + .as_ref() + .is_some_and(|bridge| bridge.can_reframe()) + } + /// Whether this dispatcher can satisfy render/import tools that require the /// injected [`MediaBridge`]. pub fn has_media_bridge(&self) -> bool { self.bridge.is_some() } + pub fn can_generate(&self) -> bool { + self.generation_bridge + .as_ref() + .is_some_and(|bridge| bridge.can_generate()) + } + + /// Recover the session-owned undo registry after an unrelated unwinding + /// panic. `HashMap`/`Vec` retain their memory-safety invariants across + /// unwinding, so preserving the completed markers is safer than turning one + /// poisoned guard into a process-lifetime denial of all later edit/undo + /// calls. + fn agent_undo_stacks( + &self, + ) -> std::sync::MutexGuard<'_, HashMap>> { + self.agent_undo + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } + + pub fn can_render_motion(&self) -> bool { + self.motion_bridge + .as_ref() + .is_some_and(|bridge| bridge.can_render_motion()) + } + + pub fn advertised_tools(&self) -> Vec { + let mut tools = ToolName::ALL.to_vec(); + if !self.has_media_bridge() { + tools.retain(|tool| !tool.requires_media_bridge()); + } + if self.can_generate() { + tools.extend(ToolName::GENERATION); + } + if self.can_render_motion() { + tools.extend(ToolName::MOTION); + } + if let Some(bridge) = &self.advanced_bridge { + for tool in bridge.supported_tools() { + if ToolName::ADVANCED_AI.contains(&tool) && !tools.contains(&tool) { + tools.push(tool); + } + } + } + if self.can_do_vision_analysis() { + tools.extend(ToolName::VISION); + } + tools + } + /// Snapshot the current timeline from the bound core handle. pub fn timeline(&self) -> Timeline { self.handle.timeline() @@ -110,7 +357,12 @@ impl Dispatcher { /// Run one tool through the full pipeline and return its neutral result. pub fn dispatch(&self, name: &str, args: Value) -> ToolResult { - self.dispatch_cancellable(name, args, &opentake_media::MediaCancelToken::new()) + self.dispatch_cancellable_scoped( + DIRECT_UNDO_SCOPE, + name, + args, + &opentake_media::MediaCancelToken::new(), + ) } /// Run one tool with cooperative media cancellation. The MCP transport uses @@ -122,6 +374,23 @@ impl Dispatcher { args: Value, cancel: &opentake_media::MediaCancelToken, ) -> ToolResult { + self.dispatch_cancellable_scoped(DIRECT_UNDO_SCOPE, name, args, cancel) + } + + /// Dispatch under an explicit assistant-undo ownership scope. The scope is + /// supplied by the OpenTake ChatSession or MCP transport session and remains + /// active for every editing helper reached by this synchronous invocation. + pub fn dispatch_cancellable_scoped( + &self, + undo_scope: &str, + name: &str, + args: Value, + cancel: &opentake_media::MediaCancelToken, + ) -> ToolResult { + let _undo_scope = ActiveUndoScope::enter(undo_scope); + if cancel.is_cancelled() { + return ToolResult::error("Cancelled"); + } // 1. Resolve the tool name. let Ok(tool) = name.parse::() else { return ToolResult::public_error( @@ -130,16 +399,22 @@ impl Dispatcher { ); }; - // Validate the complete wire shape before snapshots, side effects, or a - // not-yet-implemented stub can run. `run_body` still decodes the typed - // value it consumes after short-id expansion; this preflight is the - // fail-closed contract shared by every one of ToolName::ALL. + // Validate the complete wire shape before snapshots or side effects. + // Known-but-hidden compatibility names keep their strict schema + // contract, but a valid invocation is rejected below as unavailable. if let Err(error) = validate_tool_args(tool, &args) { return ToolResult::public_error( PublicErrorKind::InvalidArguments(tool), error.message, ); } + if !self.advertised_tools().contains(&tool) { + let message = match tool.hidden_capability_reason() { + Some(reason) => format!("Tool is not advertised: {} ({reason})", tool.as_str()), + None => format!("Tool is not advertised: {}", tool.as_str()), + }; + return ToolResult::public_error(PublicErrorKind::UnknownTool, message); + } // 2. Snapshot the pre-run state. let before = self.handle.timeline(); @@ -197,14 +472,12 @@ impl Dispatcher { ToolName::GetTimeline => { let a: GetTimelineArgs = decode_tool_args(args, "")?; let tl = self.handle.timeline(); - // canGenerate is gated by the (not-yet-wired) generation backend; - // false until that lands so the model never proposes generation. - let json = encode_timeline(&tl, a.start_frame, a.end_frame, false); + let json = encode_timeline(&tl, a.start_frame, a.end_frame, self.can_generate()); Ok(ToolResult::ok(json.to_string())) } ToolName::GetMedia => { let manifest = self.handle.media(); - let json = serde_json::to_value(&manifest) + let json = serde_json::to_value(ModelMediaCatalog::from(&manifest)) .map(round_floats_3dp) .map_err(|e| ToolError::new(format!("get_media: {e}")))?; Ok(ToolResult::ok(json.to_string())) @@ -216,6 +489,7 @@ impl Dispatcher { Ok(ToolResult::ok(json.to_string())) } ToolName::ListModels => self.list_models_catalog(args), + ToolName::InspectMedia => self.inspect_media(args, before, manifest), // --- Editing (wired to EditCommand) --- ToolName::AddClips => self.add_clips(args, manifest, op), @@ -248,37 +522,196 @@ impl Dispatcher { // --- Analysis-driven edit surface --- ToolName::DetectBeats => self.detect_beats(args, before), - ToolName::AutoCutToBeats => self.auto_cut_to_beats(args, before), + ToolName::AutoCutToBeats => self.auto_cut_to_beats(args, before, op, cancel), ToolName::SmartReframe => self.smart_reframe(args), ToolName::TightenSilences => self.tighten_silences(args, before), + ToolName::RemoveFillerWords => self.remove_filler_words(args, before, manifest), // --- Render + import + transcript + search (wired to the injected MediaBridge) --- ToolName::InspectTimeline => self.inspect_timeline(args, before), ToolName::ImportMedia => self.import_media(args, manifest, cancel), ToolName::GetTranscript => self.get_transcript(args, before, manifest), - ToolName::AddCaptions => self.add_captions(args, before, manifest), + ToolName::AddCaptions => self.add_captions(args, before, manifest, cancel), ToolName::SearchMedia => self.search_media(args, manifest), - // --- Not yet implementable in this phase (honest stubs) --- - // inspect_media still needs the analysis backend; generation/upscale - // need the async GenClient + BYOK auth. Motion graphics (#34) now - // routes through the planned Motion Canvas plugin: render mp4 -> - // import media -> place clip. - ToolName::InspectMedia - | ToolName::GenerateVideo + // --- Known but deliberately absent from discovery --- + // Generation/upscale need the async GenClient + BYOK auth. Motion + // graphics (#34) need the planned deterministic Motion Canvas path: + // render mp4 -> import media -> place clip. + ToolName::GenerateVideo | ToolName::GenerateImage | ToolName::GenerateAudio - | ToolName::UpscaleMedia - | ToolName::AddMotionGraphic - | ToolName::EditMotionGraphic => Ok(ToolResult::error(format!( - "{}: not yet implemented", - tool.as_str() - ))), + | ToolName::UpscaleMedia => self.submit_generation(tool, args, cancel), + ToolName::AddMotionGraphic => self.add_motion_graphic(args, cancel), + ToolName::EditMotionGraphic => self.edit_motion_graphic(args, cancel), + ToolName::TrackMotion + | ToolName::GenerateMatte + | ToolName::RemoveObject + | ToolName::MatchColor + | ToolName::SeparateStems + | ToolName::TranslateCaptions + | ToolName::ScriptToVideo + | ToolName::GenerateAvatar + | ToolName::CloneVoice => self.run_advanced_workflow(tool, args, cancel), } } + fn run_advanced_workflow( + &self, + tool: ToolName, + args: &Value, + cancel: &opentake_media::MediaCancelToken, + ) -> Result { + let request = match tool { + ToolName::TrackMotion => { + AdvancedWorkflowRequest::TrackMotion(decode_tool_args(args, "")?) + } + ToolName::GenerateMatte => { + AdvancedWorkflowRequest::GenerateMatte(decode_tool_args(args, "")?) + } + ToolName::RemoveObject => { + AdvancedWorkflowRequest::RemoveObject(decode_tool_args(args, "")?) + } + ToolName::MatchColor => { + AdvancedWorkflowRequest::MatchColor(decode_tool_args(args, "")?) + } + ToolName::SeparateStems => { + AdvancedWorkflowRequest::SeparateStems(decode_tool_args(args, "")?) + } + ToolName::TranslateCaptions => { + AdvancedWorkflowRequest::TranslateCaptions(decode_tool_args(args, "")?) + } + ToolName::ScriptToVideo => { + AdvancedWorkflowRequest::ScriptToVideo(decode_tool_args(args, "")?) + } + ToolName::GenerateAvatar => { + AdvancedWorkflowRequest::GenerateAvatar(decode_tool_args(args, "")?) + } + ToolName::CloneVoice => { + AdvancedWorkflowRequest::CloneVoice(decode_tool_args(args, "")?) + } + _ => return Err(ToolError::new("not an advanced workflow tool")), + }; + let bridge = self + .advanced_bridge + .as_ref() + .ok_or_else(|| ToolError::new("advanced workflow host capability is not available"))?; + let revision_before = self.handle.current_revision(); + match bridge.execute(request, cancel) { + Ok(commit) => { + if let Some(action_name) = commit.action_name { + self.record_external_edit(action_name, revision_before); + } + Ok(ToolResult::ok( + model_safe_result(tool, &commit.result).to_string(), + )) + } + Err(error) => Ok(advanced_workflow_error(tool, error)), + } + } + + fn add_motion_graphic( + &self, + args: &Value, + cancel: &opentake_media::MediaCancelToken, + ) -> Result { + let decoded: AddMotionGraphicArgs = decode_tool_args(args, "")?; + let source: MotionSourceArg = decode_tool_args(&decoded.source, "source")?; + let source = match (source.code, source.template_id) { + (Some(code), None) => MotionSourceRequest::Code(code), + (None, Some(template_id)) => MotionSourceRequest::Template { + template_id, + params: source.params.unwrap_or_default(), + }, + _ => return Err(ToolError::new("source: exactly one source is required")), + }; + let bridge = self.motion_bridge.as_ref().ok_or_else(|| { + ToolError::new("add_motion_graphic: motion renderer is not available") + })?; + let revision_before = self.handle.current_revision(); + let commit = match bridge.add( + AddMotionRequest { + source, + start_frame: decoded.start_frame, + duration_frames: decoded.duration_frames, + transparent: decoded.transparent.unwrap_or(false), + track_index: decoded.track_index, + }, + cancel, + ) { + Ok(commit) => commit, + Err(error) => return Ok(motion_bridge_error(ToolName::AddMotionGraphic, error)), + }; + self.record_external_edit(commit.action_name.clone(), revision_before); + Ok(ToolResult::ok(model_safe_commit(&commit).to_string())) + } + + fn edit_motion_graphic( + &self, + args: &Value, + cancel: &opentake_media::MediaCancelToken, + ) -> Result { + let decoded: EditMotionGraphicArgs = decode_tool_args(args, "")?; + let bridge = self.motion_bridge.as_ref().ok_or_else(|| { + ToolError::new("edit_motion_graphic: motion renderer is not available") + })?; + let revision_before = self.handle.current_revision(); + let commit = match bridge.edit( + EditMotionRequest { + clip_id: decoded.clip_id, + code: decoded.code, + params: decoded.params, + }, + cancel, + ) { + Ok(commit) => commit, + Err(error) => return Ok(motion_bridge_error(ToolName::EditMotionGraphic, error)), + }; + self.record_external_edit(commit.action_name.clone(), revision_before); + Ok(ToolResult::ok(model_safe_commit(&commit).to_string())) + } + // MARK: - Generative read bodies + fn submit_generation( + &self, + tool: ToolName, + args: &Value, + cancel: &opentake_media::MediaCancelToken, + ) -> Result { + let request = match tool { + ToolName::GenerateVideo => GenerationRequest::Video(decode_tool_args(args, "")?), + ToolName::GenerateImage => GenerationRequest::Image(decode_tool_args(args, "")?), + ToolName::GenerateAudio => GenerationRequest::Audio(decode_tool_args(args, "")?), + ToolName::UpscaleMedia => GenerationRequest::Upscale(decode_tool_args(args, "")?), + _ => return Err(ToolError::new("not a generation tool")), + }; + if !request.cost_authorized() { + return Ok(ToolResult::public_error( + PublicErrorKind::InvalidArguments(tool), + "costAuthorized must be true after explicit user approval", + )); + } + let Some(bridge) = self.generation_bridge.as_ref() else { + return Ok(ToolResult::public_error( + PublicErrorKind::CapabilityUnavailable(tool), + "Generation is not available in this build.", + )); + }; + if !bridge.can_generate() { + return Ok(ToolResult::public_error( + PublicErrorKind::CapabilityUnavailable(tool), + "Configure a compatible generation provider before submitting.", + )); + } + let submission = bridge + .submit(request, cancel) + .map_err(|_| ToolError::new("generation submission failed"))?; + let payload = serde_json::to_string(&submission) + .map_err(|_| ToolError::new("generation submission response failed"))?; + Ok(ToolResult::ok(payload)) + } + /// `list_models`: project the built-in static catalog from `opentake-gen` /// into the `{ models, loaded }` payload, optionally filtered by `?type=`. /// Fully local — no network, no BYOK key — so it runs synchronously here and @@ -293,6 +726,93 @@ impl Dispatcher { // MARK: - Render + import tool bodies (backed by the MediaBridge) + /// Inspect one raw source asset with real decoded frames and optional local + /// transcription. Manifest/clip/range validation stays in the dispatcher; + /// retained source resolution and IO stay behind [`MediaBridge`]. + fn inspect_media( + &self, + args: &Value, + timeline: &Timeline, + manifest: &MediaManifest, + ) -> Result { + let a: InspectMediaArgs = decode_tool_args(args, "")?; + let Some(entry) = manifest + .entries + .iter() + .find(|entry| entry.id == a.media_ref) + else { + return Ok(ToolResult::public_error( + PublicErrorKind::ResourceNotFound(ToolName::InspectMedia), + format!("Media not found: {}", a.media_ref), + )); + }; + ensure_generation_output_ready(entry, "inspect_media")?; + if entry.kind == opentake_domain::ClipType::Text { + return Ok(ToolResult::public_error( + PublicErrorKind::CapabilityUnavailable(ToolName::InspectMedia), + "Text clips are not stored as media assets.", + )); + } + + let mapping = if let Some(clip_id) = a.clip_id.as_deref() { + let clip = find_clip(timeline, clip_id) + .ok_or_else(|| ToolError::new(format!("Clip not found: {clip_id}")))?; + if clip.media_ref != entry.id { + return Err(ToolError::new(format!( + "Clip {clip_id} does not reference mediaRef {} (it references {})", + entry.id, clip.media_ref + ))); + } + Some(clip) + } else { + None + }; + + let duration = entry.duration.max(0.0); + let range = inspect_media_range(a.start_seconds, a.end_seconds, duration)?; + let max_frames = a + .max_frames + .unwrap_or(INSPECT_MEDIA_DEFAULT_FRAMES as i32) + .clamp(1, INSPECT_MEDIA_MAX_FRAMES as i32) as usize; + let Some(bridge) = self.bridge.as_ref() else { + return Ok(ToolResult::public_error( + PublicErrorKind::CapabilityUnavailable(ToolName::InspectMedia), + "inspect_media: source inspection is not available in this build", + )); + }; + let request = InspectMediaRequest { + media_ref: entry.id.clone(), + kind: entry.kind, + start_seconds: range.map(|value| value.0), + end_seconds: range.map(|value| value.1), + max_frames, + overview: a.overview.unwrap_or(false), + }; + let inspected = match bridge.inspect_media(&request) { + Ok(inspected) => inspected, + Err(error) => { + let kind = match error.kind { + BridgeErrorKind::Private => return Err(ToolError::new(error.message)), + BridgeErrorKind::NotFound => { + PublicErrorKind::ResourceNotFound(ToolName::InspectMedia) + } + BridgeErrorKind::Unavailable => { + PublicErrorKind::CapabilityUnavailable(ToolName::InspectMedia) + } + }; + return Ok(ToolResult::public_error(kind, error.message)); + } + }; + inspect_media_result( + entry, + timeline.fps, + mapping, + &request, + inspected, + a.word_timestamps, + ) + } + /// `inspect_timeline`: composite one project frame, or `maxFrames` frames /// evenly sampled across `[startFrame, endFrame)`, downscaled for tokens. /// 1:1 port of upstream `ToolExecutor+InspectTimeline.inspectTimeline` @@ -394,7 +914,10 @@ impl Dispatcher { let source = match args.get("source") { Some(raw) => decode_tool_args::(raw, "source")?, None => { - return Ok(ToolResult::error("Missing required 'source' object")); + return Ok(ToolResult::public_error( + PublicErrorKind::InvalidArguments(ToolName::ImportMedia), + "source: missing required field 'source'", + )); } }; @@ -404,33 +927,49 @@ impl Dispatcher { .filter(|v| v.is_some()) .count(); if set_count != 1 { - return Ok(ToolResult::error(format!( - "source must set exactly one of 'url', 'path', or 'bytes' (got {set_count})" - ))); + return Ok(ToolResult::public_error( + PublicErrorKind::InvalidArguments(ToolName::ImportMedia), + format!( + "source: must set exactly one of 'url', 'path', or 'bytes' (got {set_count})" + ), + )); + } + + // LLM/MCP input is never file-system authority. Until the desktop host + // injects a picker-issued, persistent scope capability, a path supplied + // in a prompt must fail before the media bridge can inspect metadata. + if source.path.is_some() { + return Ok(ToolResult::public_error( + PublicErrorKind::PathAuthorityRequired(ToolName::ImportMedia), + "source.path: explicit user-granted file authority is required", + )); } // folderId, when provided, must name an existing folder (upstream // `resolveFolderId`). There is no reference fallback for a tool call. if let Some(folder_id) = a.folder_id.as_deref() { if !manifest.folders.iter().any(|f| f.id == folder_id) { - return Ok(ToolResult::error(format!( - "folderId not found: {folder_id}" - ))); + return Ok(ToolResult::public_error( + PublicErrorKind::ResourceNotFound(ToolName::ImportMedia), + format!("folderId not found: {folder_id}"), + )); } } - let import_source = if let Some(path) = source.path.clone() { - ImportSource::Path(path) - } else if let Some(base64) = source.bytes.clone() { + let import_source = if let Some(base64) = source.bytes.clone() { let base64_len = base64.trim().len(); if base64_len > IMPORT_BYTES_BASE64_MAX { - return Ok(ToolResult::error(format!( - "source.bytes is too large: {base64_len} base64 bytes, max {IMPORT_BYTES_BASE64_MAX}; use source.path for larger files" - ))); + return Ok(ToolResult::public_error( + PublicErrorKind::InvalidArguments(ToolName::ImportMedia), + format!( + "source.bytes: value is too large ({base64_len} base64 bytes, max {IMPORT_BYTES_BASE64_MAX})" + ), + )); } let Some(mime_type) = source.mime_type.clone() else { - return Ok(ToolResult::error( - "source.mimeType is required when source.bytes is set", + return Ok(ToolResult::public_error( + PublicErrorKind::InvalidArguments(ToolName::ImportMedia), + "source.mimeType: missing required field when source.bytes is set", )); }; ImportSource::Bytes { base64, mime_type } @@ -441,19 +980,48 @@ impl Dispatcher { } } else { // Unreachable: set_count == 1 guaranteed one branch above. - return Ok(ToolResult::error("import_media: no source set")); + return Ok(ToolResult::public_error( + PublicErrorKind::InvalidArguments(ToolName::ImportMedia), + "source: missing required field", + )); }; let Some(bridge) = self.bridge.as_ref() else { - return Ok(ToolResult::error( + return Ok(ToolResult::public_error( + PublicErrorKind::CapabilityUnavailable(ToolName::ImportMedia), "import_media: importing is not available in this build", )); }; - let outcome = bridge - .import_media_cancellable(import_source, a.name.clone(), a.folder_id.clone(), cancel) - .map_err(|e| ToolError::new(e.message))?; - Ok(ToolResult::ok(outcome.message)) + let outcome = match bridge.import_media_cancellable( + import_source, + a.name.clone(), + a.folder_id.clone(), + cancel, + ) { + Ok(outcome) => outcome, + Err(error) => { + let kind = match error.kind { + BridgeErrorKind::Private => return Err(ToolError::new(error.message)), + BridgeErrorKind::NotFound => { + PublicErrorKind::ResourceNotFound(ToolName::ImportMedia) + } + BridgeErrorKind::Unavailable => { + PublicErrorKind::CapabilityUnavailable(ToolName::ImportMedia) + } + }; + return Ok(ToolResult::public_error(kind, error.message)); + } + }; + let recovery = if outcome.recovery_required { + " The asset is committed, but project recovery is required; save and reopen the project before editing it." + } else { + "" + }; + Ok(ToolResult::ok(format!( + "Imported {} media asset(s) and created {} folder(s). Refresh with get_media or list_folders before referencing the new assets.{recovery}", + outcome.asset_count, outcome.folder_count + ))) } /// `search_media`: content search over the library — visual (SigLIP2 @@ -657,7 +1225,17 @@ impl Dispatcher { .find(|e| e.id == r.media_ref) .map(|e| e.name.clone()) .unwrap_or_else(|| r.media_ref.clone()); - skipped.push(serde_json::json!({ "file": file, "reason": reason })); + tracing::warn!( + target: "opentake::agent::private", + media_ref = %r.media_ref, + detail = %reason, + "transcript source was skipped" + ); + skipped.push(serde_json::json!({ + "file": file, + "code": "TRANSCRIPTION_SOURCE_UNAVAILABLE", + "reason": "Source unavailable for transcription. Relink or replace the media, then retry." + })); } } @@ -744,7 +1322,10 @@ impl Dispatcher { args: &Value, before: &Timeline, manifest: &MediaManifest, + cancel: &opentake_media::MediaCancelToken, ) -> Result { + ensure_not_cancelled(cancel)?; + let revision = self.handle.current_revision(); let a: AddCaptionsArgs = decode_tool_args(args, "")?; // Style from args (defaults: Helvetica-Bold @ AppTheme.Caption.defaultFontSize=48, @@ -826,6 +1407,7 @@ impl Dispatcher { let source_results = bridge .transcribe_sources(&sources) .map_err(|e| ToolError::new(e.message))?; + ensure_not_cancelled(cancel)?; let mut transcripts: BTreeMap = BTreeMap::new(); for r in source_results { @@ -908,7 +1490,7 @@ impl Dispatcher { .collect(); let count = entries.len(); - let res = self.apply(EditCommand::AddCaptions { entries })?; + let res = self.apply_deferred(EditCommand::AddCaptions { entries }, revision, cancel)?; if !res.changed { return Ok(ToolResult::error("No speech detected to caption.")); } @@ -933,6 +1515,13 @@ impl Dispatcher { let mut explicit_count = 0usize; for (i, raw) in a.entries.iter().enumerate() { let e: AddClipEntry = decode_tool_args(raw, &format!("entries[{i}]"))?; + if let Some(entry) = manifest + .entries + .iter() + .find(|entry| entry.id == e.media_ref) + { + ensure_generation_output_ready(entry, &format!("entries[{i}]"))?; + } let (media_type, has_audio) = resolve_media_kind(manifest, &e.media_ref); if e.track_index.is_some() { explicit_count += 1; @@ -986,6 +1575,13 @@ impl Dispatcher { let mut entries = Vec::with_capacity(a.entries.len()); for (i, raw) in a.entries.iter().enumerate() { let e: InsertClipEntry = decode_tool_args(raw, &format!("entries[{i}]"))?; + if let Some(entry) = manifest + .entries + .iter() + .find(|entry| entry.id == e.media_ref) + { + ensure_generation_output_ready(entry, &format!("entries[{i}]"))?; + } let (media_type, has_audio) = resolve_media_kind(manifest, &e.media_ref); let duration_frames = match e.duration_frames { Some(d) => d, @@ -1133,8 +1729,22 @@ impl Dispatcher { Ok(ToolResult::ok(round_floats_3dp(payload).to_string())) } - fn auto_cut_to_beats(&self, args: &Value, before: &Timeline) -> Result { + fn auto_cut_to_beats( + &self, + args: &Value, + before: &Timeline, + op: &mut OpContext, + cancel: &opentake_media::MediaCancelToken, + ) -> Result { + ensure_not_cancelled(cancel)?; + let revision = self.handle.current_revision(); let a: AutoCutToBeatsArgs = decode_tool_args(args, "")?; + let write = a.write.unwrap_or(false); + if write && a.align_cuts == Some(false) { + return Err(ToolError::new( + "auto_cut_to_beats: write=true conflicts with alignCuts=false", + )); + } let beats = self.detect_beat_hints( before, BeatAnalysisRequest { @@ -1146,6 +1756,7 @@ impl Dispatcher { tool_name: "auto_cut_to_beats", }, )?; + ensure_not_cancelled(cancel)?; let min_gap = a.min_clip_frames.unwrap_or(1).max(1); let max_gap = a.max_clip_frames.unwrap_or(i32::MAX).max(min_gap); let mut cut_frames = Vec::new(); @@ -1166,10 +1777,9 @@ impl Dispatcher { cut_frames.sort_unstable(); cut_frames.dedup(); - let placements = a - .clip_ids - .unwrap_or_default() - .into_iter() + let requested_clip_ids = a.clip_ids.unwrap_or_default(); + let placements = requested_clip_ids + .iter() .zip(cut_frames.iter().copied()) .map(|(clip_id, to_frame)| { serde_json::json!({ @@ -1179,24 +1789,43 @@ impl Dispatcher { }) .collect::>(); + let (applied, summary, placements) = if write { + let (moves, applied_placements) = + plan_beat_alignment_moves(before, &requested_clip_ids, &cut_frames)?; + op.clip_ids = moves + .iter() + .map(|movement| movement.clip_id.clone()) + .collect(); + op.track_index = moves.first().map(|movement| movement.to_track); + let result = self.apply_deferred(EditCommand::MoveClips { moves }, revision, cancel)?; + (result.changed, Some(result.summary), applied_placements) + } else { + (false, None, placements) + }; + let payload = serde_json::json!({ - "applied": false, - "alignCuts": a.align_cuts.unwrap_or(false), + "applied": applied, + "alignCuts": a.align_cuts.unwrap_or(write), "beats": beats.iter().map(|beat| serde_json::json!({ "frame": beat.frame, "strength": beat.strength, })).collect::>(), "cutFrames": cut_frames, "placements": placements, - "note": "Preview only. Apply returned frames through split_clip/move_clips/ripple_delete_ranges as needed.", + "summary": summary, + "note": if write { + "Applied selected clip placements and linked A/V partners through one atomic move_clips command." + } else { + "Preview only. Set write=true to apply placements atomically, or use returned frames with existing edit tools." + }, }); Ok(ToolResult::ok(round_floats_3dp(payload).to_string())) } fn smart_reframe(&self, args: &Value) -> Result { let _: SmartReframeArgs = decode_tool_args(args, "")?; - Ok(ToolResult::error( - "smart_reframe: needs vision analysis backend; CoreHandle does not expose sampled frames or saliency/subject analysis yet", + Err(ToolError::new( + "smart_reframe: vision analysis backend is not available; CoreHandle does not expose sampled frames or saliency/subject analysis yet", )) } @@ -1226,7 +1855,17 @@ impl Dispatcher { ) { Ok(pcm) => pcm, Err(e) => { - warnings.push(format!("{}: {e}", target.clip.id)); + tracing::warn!( + target: "opentake::agent::private", + clip_id = %target.clip.id, + detail = %e, + "silence analysis source was unavailable" + ); + warnings.push(serde_json::json!({ + "clipId": target.clip.id, + "code": "ANALYSIS_SOURCE_UNAVAILABLE", + "message": "Source audio is unavailable for analysis. Relink or replace the media, then retry." + })); continue; } }; @@ -1293,6 +1932,205 @@ impl Dispatcher { Ok(ToolResult::ok(round_floats_3dp(payload).to_string())) } + fn remove_filler_words( + &self, + args: &Value, + before: &Timeline, + manifest: &MediaManifest, + ) -> Result { + let a: RemoveFillerWordsArgs = decode_tool_args(args, "")?; + if a.clip_ids.is_some() && a.track_index.is_some() { + return Err(ToolError::new( + "remove_filler_words: pass clipIds or trackIndex, not both", + )); + } + if let Some(ids) = a.clip_ids.as_ref() { + if ids.is_empty() { + return Err(ToolError::new("remove_filler_words: clipIds is empty")); + } + for id in ids { + if find_clip(before, id).is_none() { + return Err(ToolError::new(format!( + "remove_filler_words: clip not found: {id}" + ))); + } + } + } + if let Some(track_index) = a.track_index { + if before.tracks.get(track_index).is_none() { + return Err(ToolError::new(format!( + "remove_filler_words: track not found: {track_index}" + ))); + } + } + + let lexicon = a.filler_words.unwrap_or_else(|| { + ["um", "uh", "er", "erm", "ah", "like", "you know"] + .into_iter() + .map(str::to_string) + .collect() + }); + let mut phrases = lexicon + .into_iter() + .filter_map(|phrase| { + let tokens = phrase + .split_whitespace() + .map(normalize_spoken_token) + .filter(|token| !token.is_empty()) + .collect::>(); + (!tokens.is_empty()).then_some(tokens) + }) + .collect::>(); + phrases.sort(); + phrases.dedup(); + phrases.sort_by_key(|tokens| std::cmp::Reverse(tokens.len())); + if phrases.is_empty() { + return Err(ToolError::new( + "remove_filler_words: fillerWords has no usable phrases", + )); + } + + let transcript = self.get_transcript(&serde_json::json!({}), before, manifest)?; + if transcript.is_error { + return Ok(transcript); + } + let transcript_json: Value = serde_json::from_str(&transcript.text_joined()) + .map_err(|_| ToolError::new("remove_filler_words: transcript response is invalid"))?; + let clips = transcript_json["clips"] + .as_array() + .ok_or_else(|| ToolError::new("remove_filler_words: transcript clips are missing"))?; + let requested_ids = a + .clip_ids + .as_ref() + .map(|ids| ids.iter().map(String::as_str).collect::>()) + .or_else(|| { + a.track_index.map(|track_index| { + before.tracks[track_index] + .clips + .iter() + .map(|clip| clip.id.as_str()) + .collect::>() + }) + }); + let selected_ids = requested_ids.map(|requested| { + let mut expanded = requested + .iter() + .map(|id| (*id).to_string()) + .collect::>(); + let link_groups = requested + .iter() + .filter_map(|id| find_clip(before, id)) + .filter_map(|clip| clip.link_group_id.as_deref()) + .collect::>(); + for clip in before.tracks.iter().flat_map(|track| &track.clips) { + if clip + .link_group_id + .as_deref() + .is_some_and(|group| link_groups.contains(group)) + { + expanded.insert(clip.id.clone()); + } + } + expanded + }); + let padding = a.padding_frames.unwrap_or(1).max(0) as i64; + let mut cuts = Vec::new(); + let mut ranges_by_track: BTreeMap> = BTreeMap::new(); + + for clip in clips { + let Some(clip_id) = clip["clipId"].as_str() else { + continue; + }; + let Some(track_index) = clip["trackIndex"].as_u64() else { + continue; + }; + if selected_ids + .as_ref() + .is_some_and(|ids| !ids.contains(clip_id)) + { + continue; + } + let clip_start = clip["startFrame"].as_i64().unwrap_or(0); + let clip_end = clip["endFrame"].as_i64().unwrap_or(clip_start); + let Some(rows) = clip["words"].as_array() else { + continue; + }; + let normalized = rows + .iter() + .map(|row| normalize_spoken_token(row[0].as_str().unwrap_or_default())) + .collect::>(); + let mut word_index = 0; + while word_index < rows.len() { + let Some(phrase) = phrases.iter().find(|phrase| { + word_index + phrase.len() <= normalized.len() + && normalized[word_index..word_index + phrase.len()] == phrase[..] + }) else { + word_index += 1; + continue; + }; + let last_index = word_index + phrase.len() - 1; + let start = (rows[word_index][1].as_i64().unwrap_or(clip_start) + padding) + .clamp(clip_start, clip_end); + let end = (rows[last_index][2].as_i64().unwrap_or(start) - padding) + .clamp(clip_start, clip_end); + if end > start { + let text = rows[word_index..=last_index] + .iter() + .filter_map(|row| row[0].as_str()) + .collect::>() + .join(" "); + let cut_id = format!("filler-{clip_id}-{word_index}"); + cuts.push(serde_json::json!({ + "id": cut_id, + "clipId": clip_id, + "trackIndex": track_index, + "text": text, + "range": [start, end], + "accepted": true, + })); + ranges_by_track + .entry(track_index) + .or_default() + .push([start, end]); + } + word_index += phrase.len(); + } + } + + for ranges in ranges_by_track.values_mut() { + ranges.sort_unstable(); + ranges.dedup(); + } + cuts.sort_by_key(|cut| { + ( + cut["trackIndex"].as_u64().unwrap_or(0), + cut["range"][0].as_i64().unwrap_or(0), + ) + }); + let commands = ranges_by_track + .into_iter() + .map(|(track_index, ranges)| { + serde_json::json!({ + "tool": "ripple_delete_ranges", + "args": { + "trackIndex": track_index, + "units": "frames", + "ranges": ranges, + } + }) + }) + .collect::>(); + Ok(ToolResult::ok( + serde_json::json!({ + "applied": false, + "cuts": cuts, + "commands": commands, + "note": "Review cuts and remove rejected ranges before calling each returned ripple_delete_ranges command. Each command applies as one undoable edit.", + }) + .to_string(), + )) + } + fn detect_beat_hints( &self, timeline: &Timeline, @@ -1527,7 +2365,7 @@ impl Dispatcher { return Ok(ToolResult::ok(res.summary)); }; - let mut per_clip = Vec::new(); + let mut assignments = Vec::with_capacity(clip_ids.len()); for clip_id in &clip_ids { let clip = find_clip(before, clip_id).ok_or_else(|| { ToolError::new(format!("set_clip_properties: clip not found: {clip_id}")) @@ -1540,18 +2378,14 @@ impl Dispatcher { transform_patch.clone(), aspect, )); - per_clip.push((clip_id.clone(), clip_properties)); + assignments.push(ClipPropertyAssignment { + clip_id: clip_id.clone(), + properties: clip_properties, + }); } - let mut summaries = Vec::new(); - for (clip_id, clip_properties) in per_clip { - let res = self.apply(EditCommand::SetClipProperties { - clip_ids: vec![clip_id], - properties: Box::new(clip_properties), - })?; - summaries.push(res.summary); - } - Ok(ToolResult::ok(summaries.join("; "))) + let result = self.apply(EditCommand::SetClipPropertiesPerClip { assignments })?; + Ok(ToolResult::ok(result.summary)) } fn set_color_grade(&self, args: &Value) -> Result { @@ -1759,14 +2593,62 @@ impl Dispatcher { } fn undo(&self) -> Result { - // Only revert when this dispatch session has actually pushed an edit. - let mut stack = self.agent_undo.lock().expect("agent-undo mutex"); - if stack.pop().is_none() { - return Ok(ToolResult::error("undo: no agent edits to revert")); + let scope = ActiveUndoScope::current(); + let marker = self + .agent_undo_stacks() + .get(&scope) + .and_then(|stack| stack.last()) + .cloned(); + let Some(marker) = marker else { + return Ok(ToolResult::error( + "No assistant edit to undo this session. The user's own edits are theirs to undo.", + )); + }; + + let outcome = match self.handle.undo_if_owned(&marker.revision, &marker.head) { + Ok(outcome) => outcome, + Err(_) => { + return Ok(ToolResult::error( + "The project or timeline changed after the assistant edit — not undoing it.", + )); + } + }; + match outcome { + OwnedUndoResult::NoHistory => { + self.agent_undo_stacks().remove(&scope); + Ok(ToolResult::error("Nothing to undo.")) + } + OwnedUndoResult::Conflict { + actual_action_name, .. + } => Ok(ToolResult::error(format!( + "The most recent change ('{}') wasn't made by the assistant — not undoing it.", + actual_action_name.as_deref().unwrap_or("unknown") + ))), + OwnedUndoResult::Undone(_result) => { + let mut stacks = self.agent_undo_stacks(); + let mut remove_scope = false; + if let Some(stack) = stacks.get_mut(&scope) { + if let Some(index) = stack.iter().rposition(|candidate| candidate == &marker) { + stack.remove(index); + } + if let (Some((revision, head)), Some(previous)) = + (self.handle.revision_and_undo_head(), stack.last_mut()) + { + if previous.head == head { + previous.revision = revision; + } + } + remove_scope = stack.is_empty(); + } + if remove_scope { + stacks.remove(&scope); + } + Ok(ToolResult::ok(format!( + "Undid: {}. The timeline is restored to its state before that edit; re-read with get_timeline or get_transcript before editing again.", + marker.head.action_name + ))) + } } - drop(stack); - let res = self.apply_raw(EditCommand::Undo)?; - Ok(ToolResult::ok(res.summary)) } // MARK: - Apply helpers @@ -1777,14 +2659,66 @@ impl Dispatcher { fn apply(&self, cmd: EditCommand) -> Result { let res = self.apply_raw(cmd)?; if res.changed { - self.agent_undo - .lock() - .expect("agent-undo mutex") - .push(res.action_name.clone()); + self.record_current_edit(&res); } Ok(res) } + fn apply_deferred( + &self, + cmd: EditCommand, + expected: Option, + cancel: &opentake_media::MediaCancelToken, + ) -> Result { + ensure_not_cancelled(cancel)?; + let result = match expected { + Some(expected) => self + .handle + .apply_at_revision(&expected, cmd) + .map_err(|error| ToolError::new(error.to_string()))?, + None => self.apply_raw(cmd)?, + }; + if result.changed { + self.record_current_edit(&result); + } + Ok(result) + } + + fn record_current_edit(&self, result: &opentake_ops::command::EditResult) { + let Some((revision, head)) = self.handle.revision_and_undo_head() else { + return; + }; + if revision.timeline_version != result.timeline_version + || head.action_name != result.action_name + || head.transaction_version != result.timeline_version + { + return; + } + self.agent_undo_stacks() + .entry(ActiveUndoScope::current()) + .or_default() + .push(AgentUndoMarker { revision, head }); + } + + fn record_external_edit(&self, action_name: String, before: Option) { + let (Some(before), Some((revision, head))) = (before, self.handle.revision_and_undo_head()) + else { + return; + }; + if before.project_epoch != revision.project_epoch + || before.project_dir != revision.project_dir + || revision.timeline_version != before.timeline_version.saturating_add(1) + || head.action_name != action_name + || head.transaction_version != revision.timeline_version + { + return; + } + self.agent_undo_stacks() + .entry(ActiveUndoScope::current()) + .or_default() + .push(AgentUndoMarker { revision, head }); + } + /// Apply without touching the agent-undo stack (used by `undo` itself). fn apply_raw(&self, cmd: EditCommand) -> Result { self.handle @@ -1793,6 +2727,14 @@ impl Dispatcher { } } +fn ensure_not_cancelled(cancel: &opentake_media::MediaCancelToken) -> Result<(), ToolError> { + if cancel.is_cancelled() { + Err(ToolError::new("Cancelled")) + } else { + Ok(()) + } +} + #[derive(serde::Deserialize)] struct EmptyArgs {} @@ -1862,6 +2804,7 @@ fn validate_tool_args(tool: ToolName, args: &Value) -> Result<(), ToolError> { ToolName::AutoCutToBeats => decode!(AutoCutToBeatsArgs), ToolName::SmartReframe => decode!(SmartReframeArgs), ToolName::TightenSilences => decode!(TightenSilencesArgs), + ToolName::RemoveFillerWords => decode!(RemoveFillerWordsArgs), ToolName::GenerateVideo => decode!(GenerateVideoArgs), ToolName::GenerateImage => decode!(GenerateImageArgs), ToolName::GenerateAudio => decode!(GenerateAudioArgs), @@ -1958,6 +2901,21 @@ fn validate_tool_args(tool: ToolName, args: &Value) -> Result<(), ToolError> { validate_motion_params(params, "params")?; } } + ToolName::TrackMotion => { + decode!(TrackMotionArgs); + validate_required_object::(args, "region", "region")?; + } + ToolName::GenerateMatte => decode!(GenerateMatteArgs), + ToolName::RemoveObject => decode!(RemoveObjectArgs), + ToolName::MatchColor => decode!(MatchColorArgs), + ToolName::SeparateStems => decode!(SeparateStemsArgs), + ToolName::TranslateCaptions => decode!(TranslateCaptionsArgs), + ToolName::ScriptToVideo => { + decode!(ScriptToVideoArgs); + validate_array::(args, "segments")?; + } + ToolName::GenerateAvatar => decode!(GenerateAvatarArgs), + ToolName::CloneVoice => decode!(CloneVoiceArgs), } Ok(()) } @@ -1973,7 +2931,46 @@ fn validate_motion_params( ))); } } - Ok(()) + Ok(()) +} + +fn motion_bridge_error(tool: ToolName, error: MotionBridgeError) -> ToolResult { + match error.kind { + MotionBridgeErrorKind::InvalidArguments => { + ToolResult::public_error(PublicErrorKind::InvalidArguments(tool), error.message) + } + MotionBridgeErrorKind::ResourceNotFound => { + ToolResult::public_error(PublicErrorKind::ResourceNotFound(tool), error.message) + } + MotionBridgeErrorKind::CapabilityUnavailable => { + ToolResult::public_error(PublicErrorKind::CapabilityUnavailable(tool), error.message) + } + MotionBridgeErrorKind::Cancelled => ToolResult::error("motion render cancelled"), + MotionBridgeErrorKind::RenderFailed => ToolResult::error("motion render failed"), + } +} + +fn advanced_workflow_error(tool: ToolName, error: AdvancedWorkflowError) -> ToolResult { + match error.kind { + AdvancedWorkflowErrorKind::InvalidArguments => { + ToolResult::public_error(PublicErrorKind::InvalidArguments(tool), error.message) + } + AdvancedWorkflowErrorKind::ResourceNotFound => { + ToolResult::public_error(PublicErrorKind::ResourceNotFound(tool), error.message) + } + AdvancedWorkflowErrorKind::CapabilityUnavailable => { + ToolResult::public_error(PublicErrorKind::CapabilityUnavailable(tool), error.message) + } + AdvancedWorkflowErrorKind::AnalysisLowConfidence => { + ToolResult::public_error(PublicErrorKind::AnalysisLowConfidence(tool), error.message) + } + AdvancedWorkflowErrorKind::ConsentRequired + | AdvancedWorkflowErrorKind::CostAuthorizationRequired + | AdvancedWorkflowErrorKind::ExecutionFailed => { + ToolResult::error("advanced workflow failed") + } + AdvancedWorkflowErrorKind::Cancelled => ToolResult::error("advanced workflow cancelled"), + } } fn validate_array(args: &Value, field: &str) -> Result<(), ToolError> { @@ -2178,6 +3175,34 @@ fn resolve_media_kind( .unwrap_or((opentake_domain::ClipType::Video, false)) } +fn generation_status_label(status: Option) -> &'static str { + match status { + Some(GenerationJobStatus::Queued | GenerationJobStatus::Generating) => "generating", + Some(GenerationJobStatus::Downloading | GenerationJobStatus::Finalizing) => "downloading", + Some(GenerationJobStatus::Failed) => "failed", + Some(GenerationJobStatus::Cancelled) => "cancelled", + Some(GenerationJobStatus::Ready) | None => "none", + } +} + +fn ensure_generation_output_ready( + entry: &opentake_domain::MediaManifestEntry, + path: &str, +) -> Result<(), ToolError> { + let status = entry + .generation_input + .as_ref() + .and_then(|input| input.status); + if matches!(status, Some(GenerationJobStatus::Ready) | None) { + return Ok(()); + } + Err(ToolError::new(format!( + "{path}: generated media '{}' is not ready (status {})", + entry.id, + generation_status_label(status) + ))) +} + /// Current `(track_index, start_frame)` of a clip on the timeline, or `(None, /// None)` if absent. Used to fill optional `move_clips` fields. fn clip_location(timeline: &Timeline, clip_id: &str) -> (Option, Option) { @@ -2189,6 +3214,107 @@ fn clip_location(timeline: &Timeline, clip_id: &str) -> (Option, Option Result<(Vec, Vec), ToolError> { + if clip_ids.is_empty() { + return Err(ToolError::new( + "auto_cut_to_beats: write=true requires a non-empty clipIds array", + )); + } + + let mut roots = Vec::new(); + let mut seen_roots = BTreeSet::new(); + for clip_id in clip_ids { + let clip = find_clip(timeline, clip_id).ok_or_else(|| { + ToolError::new(format!("auto_cut_to_beats: clip not found: {clip_id}")) + })?; + if !clip.media_type.is_visual() { + return Err(ToolError::new(format!( + "auto_cut_to_beats: clip is not visual: {clip_id}" + ))); + } + let root_key = clip + .link_group_id + .as_ref() + .map(|group| format!("link:{group}")) + .unwrap_or_else(|| format!("clip:{clip_id}")); + if seen_roots.insert(root_key) { + roots.push(( + clip.id.clone(), + clip.start_frame, + clip.link_group_id.clone(), + )); + } + } + if beat_frames.len() < roots.len() { + return Err(ToolError::new(format!( + "auto_cut_to_beats: need at least {} beat frame(s) for write, got {}", + roots.len(), + beat_frames.len() + ))); + } + + let mut moves = Vec::new(); + let mut placements = Vec::new(); + let mut moved_ids = BTreeSet::new(); + for ((root_id, root_start, link_group), beat_frame) in + roots.into_iter().zip(beat_frames.iter().copied()) + { + let delta = beat_frame + .checked_sub(root_start) + .ok_or_else(|| ToolError::new("auto_cut_to_beats: placement frame delta overflow"))?; + let mut linked_clip_ids = Vec::new(); + for (track_index, clip) in + timeline + .tracks + .iter() + .enumerate() + .flat_map(|(track_index, track)| { + track.clips.iter().map(move |clip| (track_index, clip)) + }) + { + let belongs = match link_group.as_deref() { + Some(group) => clip.link_group_id.as_deref() == Some(group), + None => clip.id == root_id, + }; + if !belongs || !moved_ids.insert(clip.id.clone()) { + continue; + } + let to_frame = clip.start_frame.checked_add(delta).ok_or_else(|| { + ToolError::new(format!( + "auto_cut_to_beats: linked placement frame overflow: {}", + clip.id + )) + })?; + if to_frame < 0 { + return Err(ToolError::new(format!( + "auto_cut_to_beats: linked placement would start before frame zero: {}", + clip.id + ))); + } + linked_clip_ids.push(clip.id.clone()); + moves.push(ClipMove { + clip_id: clip.id.clone(), + to_track: track_index, + to_frame, + }); + } + placements.push(serde_json::json!({ + "clipId": root_id, + "fromFrame": root_start, + "toFrame": beat_frame, + "linkedClipIds": linked_clip_ids, + })); + } + Ok((moves, placements)) +} + #[derive(Clone, Debug)] struct BeatHint { frame: i32, @@ -2424,6 +3550,14 @@ fn normalized_speed(clip: &opentake_domain::Clip) -> f64 { } } +fn normalize_spoken_token(value: &str) -> String { + value + .chars() + .flat_map(char::to_lowercase) + .filter(|character| character.is_alphanumeric() || *character == '\'') + .collect() +} + fn source_seconds_to_timeline_frame_clamped( clip: &opentake_domain::Clip, source_seconds: f64, @@ -2469,28 +3603,100 @@ fn build_ripple_ranges( ))); } let (mut start, mut end) = match units { - RangeUnits::Frames => (row[0] as i32, row[1] as i32), + RangeUnits::Frames => ( + checked_frame_number(row[0], &format!("ranges[{i}][0]"))?, + checked_frame_number(row[1], &format!("ranges[{i}][1]"))?, + ), RangeUnits::Seconds => { if let Some(clip) = clip { ( - source_seconds_to_timeline_frame_clamped(clip, row[0], timeline.fps), - source_seconds_to_timeline_frame_clamped(clip, row[1], timeline.fps), + checked_source_seconds_to_timeline_frame_clamped( + clip, + row[0], + timeline.fps, + &format!("ranges[{i}][0]"), + )?, + checked_source_seconds_to_timeline_frame_clamped( + clip, + row[1], + timeline.fps, + &format!("ranges[{i}][1]"), + )?, ) } else { let fps = timeline.fps.max(1) as f64; - ((row[0] * fps).round() as i32, (row[1] * fps).round() as i32) + ( + checked_rounded_frame(row[0] * fps, &format!("ranges[{i}][0]"))?, + checked_rounded_frame(row[1] * fps, &format!("ranges[{i}][1]"))?, + ) } } }; if let Some(clip) = clip { - start = start.clamp(clip.start_frame, clip.end_frame()); - end = end.clamp(clip.start_frame, clip.end_frame()); + let clip_end = clip + .start_frame + .checked_add(clip.duration_frames) + .ok_or_else(|| ToolError::new("ripple_delete_ranges: clip endFrame overflows"))?; + start = start.clamp(clip.start_frame, clip_end); + end = end.clamp(clip.start_frame, clip_end); + } + if start < 0 || end <= start || end.checked_sub(start).is_none() { + return Err(ToolError::new(format!( + "ranges[{i}]: expected 0 <= start < end without overflow" + ))); } ranges.push(FrameRange::new(start, end)); } Ok(ranges) } +fn checked_frame_number(value: f64, label: &str) -> Result { + if !value.is_finite() || value.fract() != 0.0 { + return Err(ToolError::new(format!( + "{label}: frame value must be a finite integer" + ))); + } + checked_rounded_frame(value, label) +} + +fn checked_rounded_frame(value: f64, label: &str) -> Result { + let rounded = value.round(); + if !rounded.is_finite() || !(i32::MIN as f64..=i32::MAX as f64).contains(&rounded) { + return Err(ToolError::new(format!( + "{label}: frame value is outside the supported range" + ))); + } + Ok(rounded as i32) +} + +fn checked_source_seconds_to_timeline_frame_clamped( + clip: &opentake_domain::Clip, + source_seconds: f64, + timeline_fps: i32, + label: &str, +) -> Result { + if !source_seconds.is_finite() { + return Err(ToolError::new(format!( + "{label}: seconds value must be finite" + ))); + } + let clip_end = clip + .start_frame + .checked_add(clip.duration_frames) + .ok_or_else(|| ToolError::new(format!("{label}: clip endFrame overflows")))?; + let fps = timeline_fps.max(1) as f64; + let source_frame = source_seconds * fps; + let speed = normalized_speed(clip); + let mapped = clip.start_frame as f64 + (source_frame - clip.trim_start_frame as f64) / speed; + if !mapped.is_finite() { + return Err(ToolError::new(format!( + "{label}: seconds value maps outside the supported frame range" + ))); + } + let clamped = mapped.clamp(clip.start_frame as f64, clip_end as f64); + checked_rounded_frame(clamped, label) +} + /// `add_texts` wraps at 90% of canvas width before auto-fitting the box to the /// measured text, same ratio as [`CAPTION_MAX_TEXT_WIDTH_RATIO`] but named /// separately since the two tools' constants are independent (upstream @@ -2696,6 +3902,7 @@ fn color_grade_from_args(a: &SetColorGradeArgs) -> ColorGrade { }, contrast: a.contrast.unwrap_or(base.contrast), saturation: a.saturation.unwrap_or(base.saturation), + hsl_secondary: None, } } @@ -2762,6 +3969,7 @@ fn mask_from_arg(m: &MaskArg, path: &str) -> Result { shape, feather: m.feather.unwrap_or(0.0), invert: m.invert.unwrap_or(false), + ..Mask::default() }) } @@ -2885,6 +4093,296 @@ fn parse_interpolation(s: &str) -> Option { } } +fn inspect_media_range( + start: Option, + end: Option, + duration: f64, +) -> Result, ToolError> { + if start.is_none() && end.is_none() { + return Ok(None); + } + let start = start.unwrap_or(0.0).max(0.0); + let end = end.unwrap_or(duration).min(duration); + if start >= end { + return Err(ToolError::new(format!( + "Invalid time range [{start}, {end}] for media of duration {duration}s" + ))); + } + Ok(Some((start, end))) +} + +fn inspect_media_result( + entry: &opentake_domain::MediaManifestEntry, + timeline_fps: i32, + mapping: Option<&opentake_domain::Clip>, + request: &InspectMediaRequest, + inspected: InspectMediaResult, + include_words: Option, +) -> Result { + if entry.kind.is_visual() && inspected.frames.is_empty() { + return Err(ToolError::new(format!( + "Failed to extract frames from {}", + entry.name + ))); + } + + let mut blocks: Vec = inspected.frames.iter().map(media_frame_to_block).collect(); + let mut meta = serde_json::Map::new(); + meta.insert("id".into(), Value::String(entry.id.clone())); + meta.insert("name".into(), Value::String(entry.name.clone())); + meta.insert( + "type".into(), + serde_json::to_value(entry.kind).unwrap_or(Value::Null), + ); + meta.insert( + "duration".into(), + json_number(inspected.duration_seconds, 3), + ); + meta.insert( + "generationStatus".into(), + Value::String( + generation_status_label( + entry + .generation_input + .as_ref() + .and_then(|input| input.status), + ) + .into(), + ), + ); + if let Some(progress) = entry + .generation_input + .as_ref() + .and_then(|input| input.progress) + { + meta.insert("generationProgress".into(), json_number(progress, 3)); + } + meta.insert("byteSize".into(), Value::from(inspected.byte_size)); + if let Some(file_name) = manifest_file_name(entry) { + meta.insert("fileName".into(), Value::String(file_name)); + } + if let Some(width) = inspected.width { + meta.insert("sourceWidth".into(), Value::from(width)); + } + if let Some(height) = inspected.height { + meta.insert("sourceHeight".into(), Value::from(height)); + } + if let Some(fps) = inspected.fps { + meta.insert("sourceFPS".into(), json_number(fps, 3)); + } + if let (Some(start), Some(end)) = (request.start_seconds, request.end_seconds) { + meta.insert( + "timeRange".into(), + Value::Array(vec![json_number(start, 3), json_number(end, 3)]), + ); + } + + let frame_timestamps: Vec = inspected + .frames + .iter() + .map(|frame| json_number(frame.timestamp_seconds, 3)) + .collect(); + if request.overview { + let timestamps = inspected + .overview_timestamps + .iter() + .map(|timestamp| json_number(*timestamp, 3)) + .collect::>(); + meta.insert( + "overview".into(), + serde_json::json!({"tileTimestamps": timestamps}), + ); + } else if !frame_timestamps.is_empty() { + meta.insert("frameTimestamps".into(), Value::Array(frame_timestamps)); + } + + if entry.kind == opentake_domain::ClipType::Image { + if let Some(frame) = inspected.frames.first() { + meta.insert("mimeType".into(), Value::String(frame.media_type.clone())); + meta.insert("encodedByteSize".into(), Value::from(frame.bytes.len())); + } + if let (Some(width), Some(height)) = (inspected.width, inspected.height) { + meta.insert( + "imageProperties".into(), + serde_json::json!({"pixelWidth": width, "pixelHeight": height}), + ); + } + } + if entry.kind == opentake_domain::ClipType::Video { + meta.insert("hasAudio".into(), Value::Bool(inspected.has_audio)); + } + + if let Some(transcript) = inspected.transcript.as_ref() { + let transcript = transcription_meta( + transcript, + mapping, + timeline_fps, + include_words.unwrap_or(false), + ); + if entry.kind == opentake_domain::ClipType::Audio { + meta.extend(transcript); + } else { + meta.insert("transcription".into(), Value::Object(transcript)); + } + } else if inspected.transcription_unavailable { + meta.insert( + "transcriptionError".into(), + Value::String("On-device transcription is unavailable.".into()), + ); + } + if let Some(clip) = mapping { + meta.insert( + "timelineMapping".into(), + serde_json::json!({ + "clipId": clip.id, + "clipStartFrame": clip.start_frame, + "clipEndFrame": clip.end_frame(), + "fps": timeline_fps, + "note": "transcription segments/words are project frames for this clip; out-of-range entries are dropped." + }), + ); + } + + blocks.push(Block::text( + round_floats_3dp(Value::Object(meta)).to_string(), + )); + Ok(ToolResult::blocks(blocks)) +} + +fn transcription_meta( + transcript: &opentake_media::TranscriptionResult, + mapping: Option<&opentake_domain::Clip>, + timeline_fps: i32, + include_words: bool, +) -> serde_json::Map { + let mut out = serde_json::Map::new(); + out.insert( + "timing".into(), + Value::String(if mapping.is_some() { + "projectFrames".into() + } else { + "sourceSeconds".into() + }), + ); + if let Some(language) = &transcript.language { + out.insert("language".into(), Value::String(language.clone())); + } + + let segment_rows: Vec<(Value, f64)> = transcript + .segments + .iter() + .filter_map(|segment| { + let row = if let Some(clip) = mapping { + let (start, end) = opentake_media::transcribe::timeline::span_frames( + segment.start, + segment.end, + clip, + timeline_fps, + )?; + serde_json::json!([segment.text, start, end]) + } else { + serde_json::json!([ + segment.text, + json_number(segment.start, 2), + json_number(segment.end, 2) + ]) + }; + Some((row, segment.end)) + }) + .collect(); + out.insert( + "segments".into(), + Value::Array( + segment_rows + .iter() + .take(INSPECT_MEDIA_MAX_SEGMENTS) + .map(|(row, _)| row.clone()) + .collect(), + ), + ); + if segment_rows.len() > INSPECT_MEDIA_MAX_SEGMENTS { + out.insert("totalSegments".into(), Value::from(segment_rows.len())); + if let Some((_, end)) = segment_rows.get(INSPECT_MEDIA_MAX_SEGMENTS - 1) { + out.insert("nextStartSeconds".into(), json_number(*end, 2)); + } + out.insert( + "segmentsNote".into(), + Value::String(format!( + "First {} of {} segments. Continue with startSeconds = nextStartSeconds.", + INSPECT_MEDIA_MAX_SEGMENTS, + segment_rows.len() + )), + ); + } + + if include_words { + let words: Vec = transcript + .words + .iter() + .filter_map(|word| { + let (Some(start), Some(end)) = (word.start, word.end) else { + return None; + }; + if let Some(clip) = mapping { + let (start, end) = opentake_media::transcribe::timeline::span_frames( + start, + end, + clip, + timeline_fps, + )?; + Some(serde_json::json!([word.text, start, end])) + } else { + Some(serde_json::json!([ + word.text, + json_number(start, 2), + json_number(end, 2) + ])) + } + }) + .collect(); + out.insert( + "words".into(), + Value::Array( + words + .iter() + .take(INSPECT_MEDIA_MAX_WORDS) + .cloned() + .collect(), + ), + ); + if words.len() > INSPECT_MEDIA_MAX_WORDS { + out.insert("totalWords".into(), Value::from(words.len())); + out.insert( + "wordsNote".into(), + Value::String(format!( + "First {} of {} words. Narrow with startSeconds/endSeconds.", + INSPECT_MEDIA_MAX_WORDS, + words.len() + )), + ); + } + } + out +} + +fn manifest_file_name(entry: &opentake_domain::MediaManifestEntry) -> Option { + let path = match &entry.source { + opentake_domain::MediaSource::External { absolute_path } => absolute_path, + opentake_domain::MediaSource::Project { relative_path } => relative_path, + }; + std::path::Path::new(path) + .file_name() + .and_then(|name| name.to_str()) + .map(str::to_string) +} + +fn json_number(value: f64, places: i32) -> Value { + let factor = 10_f64.powi(places); + serde_json::Number::from_f64((value * factor).round() / factor) + .map(Value::Number) + .unwrap_or(Value::Null) +} + /// Round every float in a JSON tree to 3 decimal places (mirrors the encoder's /// `round3`), so `get_media` floats match the rest of the agent surface. fn round_floats_3dp(value: Value) -> Value { @@ -2963,6 +4461,45 @@ mod tests { fn apply(&self, cmd: EditCommand) -> anyhow::Result { self.core.apply(cmd).map_err(|e| anyhow::anyhow!("{e}")) } + fn current_revision(&self) -> Option { + let snapshot = self.core.runtime_snapshot(); + Some(CoreRevision { + project_epoch: snapshot.project_epoch, + project_dir: snapshot.project_dir, + timeline_version: snapshot.version, + }) + } + fn revision_and_undo_head(&self) -> Option<(CoreRevision, CoreUndoHead)> { + let snapshot = self.core.project_undo_snapshot()?; + Some(( + CoreRevision { + project_epoch: snapshot.revision.project_epoch, + project_dir: snapshot.project_path, + timeline_version: snapshot.revision.version, + }, + CoreUndoHead { + action_name: snapshot.action_name, + transaction_version: snapshot.transaction_version, + }, + )) + } + fn undo_if_owned( + &self, + expected: &CoreRevision, + head: &CoreUndoHead, + ) -> anyhow::Result { + self.core + .undo_if_owned( + opentake_core::ProjectRevision { + project_epoch: expected.project_epoch, + version: expected.timeline_version, + }, + expected.project_dir.as_deref(), + &head.action_name, + head.transaction_version, + ) + .map_err(|error| anyhow::anyhow!("{error}")) + } fn project_dir(&self) -> Option { self.core.project_dir() } @@ -3077,23 +4614,31 @@ mod tests { let r = d.dispatch("undo", serde_json::json!({})); assert!(r.is_error); assert!( - r.text_joined().contains("no agent edits to revert"), + r.text_joined() + .contains("No assistant edit to undo this session"), "{}", r.text_joined() ); } - #[test] - fn stub_tool_reports_not_implemented() { + fn assert_hidden_tool_is_rejected_as_unadvertised() { let d = dispatcher_with(Arc::new(TestHandle::new())); let r = d.dispatch("generate_video", serde_json::json!({"prompt": "x"})); assert!(r.is_error); - assert!( - r.text_joined() - .contains("generate_video: not yet implemented"), - "{}", - r.text_joined() - ); + assert_eq!(r.public_error_kind(), Some(PublicErrorKind::UnknownTool)); + assert!(r.text_joined().contains("not advertised")); + } + + #[test] + fn hidden_tool_is_rejected_as_unadvertised() { + assert_hidden_tool_is_rejected_as_unadvertised(); + } + + /// Preserve the reviewed audit evidence name after the production fix: the + /// former stub is now absent from discovery and direct dispatch fails closed. + #[test] + fn stub_tool_reports_not_implemented() { + assert_hidden_tool_is_rejected_as_unadvertised(); } #[test] @@ -3170,6 +4715,14 @@ mod tests { timeline: Timeline, manifest: MediaManifest, pcm: opentake_media::PcmBuffer, + extract_error: Option, + } + + struct WritableAnalysisHandle { + state: Mutex, + pcm: opentake_media::PcmBuffer, + commands: Mutex>, + cancel_after_extract: Mutex>, } impl CoreHandle for AnalysisHandle { @@ -3191,6 +4744,9 @@ mod tests { _spec: opentake_media::PcmSpec, _range: Option<(f64, f64)>, ) -> anyhow::Result { + if let Some(error) = self.extract_error.as_deref() { + anyhow::bail!(error.to_string()); + } // Mirror the real `CoreHandle::media_path` default: an empty // `media_ref` (what text clips carry — see `command.rs::add_texts`) // never resolves to a path, matching production's real failure mode @@ -3202,6 +4758,63 @@ mod tests { } } + impl CoreHandle for WritableAnalysisHandle { + fn timeline(&self) -> Timeline { + self.state.lock().unwrap().timeline.clone() + } + fn media(&self) -> MediaManifest { + self.state.lock().unwrap().manifest.clone() + } + fn apply(&self, cmd: EditCommand) -> anyhow::Result { + self.commands.lock().unwrap().push(cmd.clone()); + let ids = SeqIdGen::new("beat-"); + ops_apply(&mut self.state.lock().unwrap(), cmd, &ids) + .map_err(|e| anyhow::anyhow!("{e}")) + } + fn current_revision(&self) -> Option { + Some(CoreRevision { + project_epoch: 0, + project_dir: None, + timeline_version: self.state.lock().unwrap().version(), + }) + } + fn revision_and_undo_head(&self) -> Option<(CoreRevision, CoreUndoHead)> { + let state = self.state.lock().unwrap(); + Some(( + CoreRevision { + project_epoch: 0, + project_dir: None, + timeline_version: state.version(), + }, + CoreUndoHead { + action_name: state.undo_action_name()?.to_string(), + transaction_version: state.undo_transaction_version()?, + }, + )) + } + fn undo_if_owned( + &self, + expected: &CoreRevision, + head: &CoreUndoHead, + ) -> anyhow::Result { + undo_test_state_if_owned(&self.state, expected, head, "beat-") + } + fn project_dir(&self) -> Option { + None + } + fn extract_analysis_pcm( + &self, + _media_ref: &str, + _spec: opentake_media::PcmSpec, + _range: Option<(f64, f64)>, + ) -> anyhow::Result { + if let Some(cancel) = self.cancel_after_extract.lock().unwrap().take() { + cancel.cancel(); + } + Ok(self.pcm.clone()) + } + } + fn pcm(samples: Vec, sample_rate: u32) -> opentake_media::PcmBuffer { opentake_media::PcmBuffer { spec: opentake_media::PcmSpec { @@ -3233,11 +4846,71 @@ mod tests { let mut st = self.state.lock().unwrap(); ops_apply(&mut st, cmd, &ids).map_err(|e| anyhow::anyhow!("{e}")) } + fn current_revision(&self) -> Option { + Some(CoreRevision { + project_epoch: 0, + project_dir: None, + timeline_version: self.state.lock().unwrap().version(), + }) + } + fn revision_and_undo_head(&self) -> Option<(CoreRevision, CoreUndoHead)> { + let state = self.state.lock().unwrap(); + Some(( + CoreRevision { + project_epoch: 0, + project_dir: None, + timeline_version: state.version(), + }, + CoreUndoHead { + action_name: state.undo_action_name()?.to_string(), + transaction_version: state.undo_transaction_version()?, + }, + )) + } + fn undo_if_owned( + &self, + expected: &CoreRevision, + head: &CoreUndoHead, + ) -> anyhow::Result { + undo_test_state_if_owned(&self.state, expected, head, "t-") + } fn project_dir(&self) -> Option { None } } + fn undo_test_state_if_owned( + state: &Mutex, + expected: &CoreRevision, + head: &CoreUndoHead, + id_prefix: &str, + ) -> anyhow::Result { + let mut state = state.lock().unwrap(); + if expected.project_epoch != 0 + || expected.project_dir.is_some() + || expected.timeline_version != state.version() + { + anyhow::bail!("stale project revision"); + } + let actual_action_name = state.undo_action_name().map(str::to_owned); + let actual_transaction_version = state.undo_transaction_version(); + if actual_action_name.is_none() { + return Ok(OwnedUndoResult::NoHistory); + } + if actual_action_name.as_deref() != Some(&head.action_name) + || actual_transaction_version != Some(head.transaction_version) + { + return Ok(OwnedUndoResult::Conflict { + actual_action_name, + actual_transaction_version, + }); + } + let ids = SeqIdGen::new(id_prefix); + ops_apply(&mut state, EditCommand::Undo, &ids) + .map(OwnedUndoResult::Undone) + .map_err(|error| anyhow::anyhow!("{error}")) + } + fn entry(id: &str, name: &str) -> MediaManifestEntry { MediaManifestEntry { id: id.into(), @@ -3252,6 +4925,8 @@ mod tests { source_height: None, source_fps: None, has_audio: Some(false), + color: None, + proxy: None, folder_id: None, cached_remote_url: None, cached_remote_url_expires_at: None, @@ -3268,6 +4943,33 @@ mod tests { e } + fn linked_beat_handle() -> Arc { + let mut timeline = Timeline::new(); + timeline.fps = 10; + let mut video_track = Track::new("video-track", ClipType::Video); + let mut video = Clip::new("video-a", "video-source", 20, 5); + video.link_group_id = Some("linked-av".into()); + video_track.clips.push(video); + let mut audio_track = Track::new("audio-track", ClipType::Audio); + let mut audio = Clip::new("audio-a", "video-source", 20, 5); + audio.media_type = ClipType::Audio; + audio.source_clip_type = ClipType::Audio; + audio.link_group_id = Some("linked-av".into()); + audio_track.clips.push(audio); + timeline.tracks = vec![video_track, audio_track]; + + let mut manifest = MediaManifest::new(); + manifest.entries.push(audio_entry("music", "Music")); + let mut samples = vec![0.0; 1_000]; + samples[500..530].fill(1.0); + Arc::new(WritableAnalysisHandle { + state: Mutex::new(EditorState::new(timeline, manifest)), + pcm: pcm(samples, 1_000), + commands: Mutex::new(Vec::new()), + cancel_after_extract: Mutex::new(None), + }) + } + /// A video asset whose source carries an audio track (`hasAudio: true`) — /// the case `add_clips`/`insert_clips` should auto-create a linked audio /// partner for. @@ -3328,6 +5030,169 @@ mod tests { Arc::new(StateHandle::new(Timeline::new(), m)) } + fn scoped_dispatch( + dispatcher: &Dispatcher, + scope: &str, + tool: &str, + args: Value, + ) -> ToolResult { + dispatcher.dispatch_cancellable_scoped( + scope, + tool, + args, + &opentake_media::MediaCancelToken::new(), + ) + } + + #[test] + fn assistant_session_can_undo_two_consecutive_owned_edits() { + let handle = seeded_handle(); + let dispatcher = dispatcher_with(handle.clone()); + for to_frame in [10, 20] { + let moved = scoped_dispatch( + &dispatcher, + "chat-session-x", + "move_clips", + serde_json::json!({"moves":[{"clipId":"clip-1","toFrame":to_frame}]}), + ); + assert!(!moved.is_error, "{}", moved.text_joined()); + } + + let first = scoped_dispatch(&dispatcher, "chat-session-x", "undo", serde_json::json!({})); + assert!(!first.is_error, "{}", first.text_joined()); + assert_eq!(handle.timeline().tracks[0].clips[0].start_frame, 10); + + let second = scoped_dispatch(&dispatcher, "chat-session-x", "undo", serde_json::json!({})); + assert!(!second.is_error, "{}", second.text_joined()); + assert_eq!(handle.timeline().tracks[0].clips[0].start_frame, 0); + } + + #[test] + fn poisoned_agent_undo_mutex_does_not_break_later_edit_or_undo() { + let handle = seeded_handle(); + let dispatcher = dispatcher_with(handle.clone()); + let poisoned = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let _guard = dispatcher.agent_undo.lock().unwrap(); + panic!("intentional agent-undo poison"); + })); + assert!(poisoned.is_err()); + assert!(dispatcher.agent_undo.is_poisoned()); + + let moved = scoped_dispatch( + &dispatcher, + "chat-session-after-poison", + "move_clips", + serde_json::json!({"moves":[{"clipId":"clip-1","toFrame":18}]}), + ); + assert!(!moved.is_error, "{}", moved.text_joined()); + assert_eq!(handle.timeline().tracks[0].clips[0].start_frame, 18); + assert_eq!( + dispatcher + .agent_undo_stacks() + .get("chat-session-after-poison") + .map(Vec::len), + Some(1) + ); + + let undone = scoped_dispatch( + &dispatcher, + "chat-session-after-poison", + "undo", + serde_json::json!({}), + ); + assert!(!undone.is_error, "{}", undone.text_joined()); + assert_eq!(handle.timeline().tracks[0].clips[0].start_frame, 0); + assert!(dispatcher + .agent_undo_stacks() + .get("chat-session-after-poison") + .is_none()); + } + + #[test] + fn assistant_undo_is_isolated_between_chat_sessions() { + let handle = seeded_handle(); + let dispatcher = dispatcher_with(handle.clone()); + assert!( + !scoped_dispatch( + &dispatcher, + "chat-session-x", + "move_clips", + serde_json::json!({"moves":[{"clipId":"clip-1","toFrame":10}]}) + ) + .is_error + ); + + let wrong_session = + scoped_dispatch(&dispatcher, "chat-session-y", "undo", serde_json::json!({})); + assert!(wrong_session.is_error); + assert!(wrong_session.text_joined().contains("this session")); + assert_eq!(handle.timeline().tracks[0].clips[0].start_frame, 10); + + assert!( + !scoped_dispatch(&dispatcher, "chat-session-x", "undo", serde_json::json!({})).is_error + ); + assert_eq!(handle.timeline().tracks[0].clips[0].start_frame, 0); + } + + #[test] + fn assistant_undo_refuses_intervening_ui_edit_with_the_same_action_name() { + let handle = seeded_handle(); + let dispatcher = dispatcher_with(handle.clone()); + assert!( + !scoped_dispatch( + &dispatcher, + "chat-session-x", + "move_clips", + serde_json::json!({"moves":[{"clipId":"clip-1","toFrame":10}]}) + ) + .is_error + ); + handle + .apply(EditCommand::MoveClips { + moves: vec![ClipMove { + clip_id: "clip-1".into(), + to_track: 0, + to_frame: 20, + }], + }) + .unwrap(); + let undo_depth = handle.state.lock().unwrap().undo_depth(); + + let refused = scoped_dispatch(&dispatcher, "chat-session-x", "undo", serde_json::json!({})); + assert!(refused.is_error); + assert!(refused.text_joined().contains("not undoing")); + assert_eq!(handle.timeline().tracks[0].clips[0].start_frame, 20); + assert_eq!(handle.state.lock().unwrap().undo_depth(), undo_depth); + } + + #[test] + fn assistant_undo_refuses_after_opening_a_different_project() { + let root = tempfile::tempdir().unwrap(); + let handle = Arc::new(TestHandle::new()); + let project_a = root.path().join("A.opentake"); + handle.core.save_project(Some(project_a)).unwrap(); + let dispatcher = dispatcher_with(handle.clone()); + assert!( + !scoped_dispatch( + &dispatcher, + "chat-session-x", + "create_folder", + serde_json::json!({"name":"Agent folder"}) + ) + .is_error + ); + + let project_b = root.path().join("B.opentake"); + AppCore::new() + .save_project(Some(project_b.clone())) + .unwrap(); + handle.core.open_project(project_b).unwrap(); + let refused = scoped_dispatch(&dispatcher, "chat-session-x", "undo", serde_json::json!({})); + assert!(refused.is_error); + assert!(refused.text_joined().contains("not undoing")); + assert!(handle.core.media().folders.is_empty()); + } + fn two_track_ripple_handle() -> Arc { let mut tl = Timeline::new(); tl.fps = 30; @@ -3761,8 +5626,9 @@ mod tests { } #[test] - fn ripple_delete_ranges_frames_are_used_without_rounding() { + fn ripple_delete_ranges_rejects_fractional_frame_units_without_mutation() { let h = two_track_ripple_handle(); + let before = h.timeline(); let d = dispatcher_with(h.clone()); let r = d.dispatch( @@ -3774,14 +5640,71 @@ mod tests { }), ); - assert!(!r.is_error, "{}", r.text_joined()); - let tl = h.timeline(); - let spans: Vec<(i32, i32)> = tl.tracks[1] - .clips - .iter() - .map(|clip| (clip.start_frame, clip.duration_frames)) - .collect(); - assert_eq!(spans, vec![(100, 5), (105, 20)]); + assert!(r.is_error); + assert!( + r.text_joined().contains("finite integer"), + "{}", + r.text_joined() + ); + assert_eq!(h.timeline(), before); + } + + #[test] + fn ripple_range_f64_conversion_rejects_nonfinite_and_out_of_range_values() { + let timeline = two_track_ripple_handle().timeline(); + let cases = [ + ( + RippleDeleteRangesArgs { + track_index: Some(1), + clip_id: None, + ranges: vec![vec![f64::NAN, 110.0]], + units: Some("frames".into()), + }, + RangeUnits::Frames, + ), + ( + RippleDeleteRangesArgs { + track_index: Some(1), + clip_id: None, + ranges: vec![vec![105.0, f64::INFINITY]], + units: Some("frames".into()), + }, + RangeUnits::Frames, + ), + ( + RippleDeleteRangesArgs { + track_index: Some(1), + clip_id: None, + ranges: vec![vec![i32::MAX as f64 + 1.0, i32::MAX as f64 + 2.0]], + units: Some("frames".into()), + }, + RangeUnits::Frames, + ), + ( + RippleDeleteRangesArgs { + track_index: None, + clip_id: Some("clip-b".into()), + ranges: vec![vec![f64::NEG_INFINITY, 0.5]], + units: Some("seconds".into()), + }, + RangeUnits::Seconds, + ), + ( + RippleDeleteRangesArgs { + track_index: None, + clip_id: Some("clip-b".into()), + ranges: vec![vec![0.2, f64::MAX]], + units: Some("seconds".into()), + }, + RangeUnits::Seconds, + ), + ]; + + for (args, units) in cases { + let result = std::panic::catch_unwind(|| build_ripple_ranges(&timeline, &args, units)); + assert!(result.is_ok(), "invalid f64 conversion must not panic"); + assert!(result.unwrap().is_err()); + } } #[test] @@ -3817,6 +5740,7 @@ mod tests { timeline, manifest, pcm: pcm(samples, 1_000), + extract_error: None, }); let d = dispatcher_with(h); @@ -3839,19 +5763,125 @@ mod tests { } #[test] - fn smart_reframe_reports_needs_vision_backend() { + fn auto_cut_to_beats_write_false_is_read_only() { + let handle = linked_beat_handle(); + let before = handle.timeline(); + let dispatcher = dispatcher_with(handle.clone()); + + let result = dispatcher.dispatch( + "auto_cut_to_beats", + serde_json::json!({ + "clipIds": ["video-a"], + "beatMediaRef": "music", + "write": false + }), + ); + + assert!(!result.is_error, "{}", result.text_joined()); + assert_eq!(first_json(&result)["applied"], false); + assert!(handle.commands.lock().unwrap().is_empty()); + assert_eq!(handle.timeline(), before); + } + + #[test] + fn auto_cut_to_beats_cancelled_after_analysis_commits_nothing() { + let handle = linked_beat_handle(); + let before = handle.timeline(); + let dispatcher = dispatcher_with(handle.clone()); + let cancel = opentake_media::MediaCancelToken::new(); + *handle.cancel_after_extract.lock().unwrap() = Some(cancel.clone()); + + let result = dispatcher.dispatch_cancellable( + "auto_cut_to_beats", + serde_json::json!({ + "clipIds": ["video-a"], + "beatMediaRef": "music", + "write": true + }), + &cancel, + ); + + assert!(result.is_error); + assert!(result.text_joined().contains("Cancelled")); + assert!(handle.commands.lock().unwrap().is_empty()); + assert_eq!(handle.timeline(), before); + } + + #[test] + fn auto_cut_to_beats_write_true_is_one_atomic_command_and_preserves_links() { + let handle = linked_beat_handle(); + let dispatcher = dispatcher_with(handle.clone()); + + let before = handle.timeline(); + let contradictory = dispatcher.dispatch( + "auto_cut_to_beats", + serde_json::json!({ + "clipIds": ["video-a"], + "beatMediaRef": "music", + "alignCuts": false, + "write": true + }), + ); + assert!(contradictory.is_error); + assert!(contradictory.text_joined().contains("conflicts")); + assert!(handle.commands.lock().unwrap().is_empty()); + assert_eq!(handle.timeline(), before); + + let rejected = dispatcher.dispatch( + "auto_cut_to_beats", + serde_json::json!({ + "clipIds": ["missing-clip"], + "beatMediaRef": "music", + "write": true + }), + ); + assert!(rejected.is_error); + assert!(rejected.text_joined().contains("clip not found")); + assert!(handle.commands.lock().unwrap().is_empty()); + assert_eq!(handle.timeline(), before); + + let result = dispatcher.dispatch( + "auto_cut_to_beats", + serde_json::json!({ + "clipIds": ["video-a"], + "beatMediaRef": "music", + "write": true + }), + ); + + assert!(!result.is_error, "{}", result.text_joined()); + assert_eq!(first_json(&result)["applied"], true); + let commands = handle.commands.lock().unwrap(); + assert_eq!(commands.len(), 1); + let EditCommand::MoveClips { moves } = &commands[0] else { + panic!("auto cut must use one MoveClips command: {:?}", commands[0]); + }; + assert_eq!(moves.len(), 2); + drop(commands); + + let after = handle.timeline(); + let video = find_clip(&after, "video-a").expect("video remains"); + let audio = find_clip(&after, "audio-a").expect("linked audio remains"); + assert!((4..=5).contains(&video.start_frame)); + assert_eq!(audio.start_frame, video.start_frame); + assert_eq!(video.link_group_id.as_deref(), Some("linked-av")); + assert_eq!(audio.link_group_id, video.link_group_id); + } + + #[test] + fn smart_reframe_is_not_advertised_without_a_vision_backend() { let d = dispatcher_with(empty_manifest_handle(vec![])); + assert!(!d.advertised_tools().contains(&ToolName::SmartReframe)); let reframe = d.dispatch( "smart_reframe", serde_json::json!({"clipIds": ["clip-a"], "aspectRatio": "9:16"}), ); assert!(reframe.is_error); assert!( - reframe - .text_joined() - .contains("needs vision analysis backend") - || reframe.text_joined().contains("needs vision backend") - || reframe.text_joined().contains("needs vision"), + reframe.text_joined().contains("not advertised") + && reframe + .text_joined() + .contains("vision analysis backend is not available"), "{}", reframe.text_joined() ); @@ -3873,6 +5903,7 @@ mod tests { timeline, manifest, pcm: pcm(samples, 1_000), + extract_error: None, }); let d = dispatcher_with(h); @@ -3898,6 +5929,39 @@ mod tests { assert_eq!(json["applied"], serde_json::json!(false)); } + #[test] + fn tighten_silences_success_warning_does_not_expose_extractor_diagnostics() { + const PRIVATE_DIAGNOSTIC: &str = + "ffmpeg could not read /Users/private/voice.wav?token=SIGNED_AUDIO_SECRET"; + let mut timeline = Timeline::new(); + timeline.fps = 30; + let mut track = Track::new("audio-track", ClipType::Audio); + track.clips.push(Clip::new("clip-a", "asset-1", 0, 90)); + timeline.tracks.push(track); + let mut manifest = MediaManifest::new(); + manifest.entries.push(audio_entry("asset-1", "Voice")); + let dispatcher = dispatcher_with(Arc::new(AnalysisHandle { + timeline, + manifest, + pcm: pcm(Vec::new(), 1_000), + extract_error: Some(PRIVATE_DIAGNOSTIC.into()), + })); + + let result = dispatcher.dispatch( + "tighten_silences", + serde_json::json!({"clipIds": ["clip-a"]}), + ); + assert!(!result.is_error, "{}", result.text_joined()); + let text = result.text_joined(); + assert!(!text.contains(PRIVATE_DIAGNOSTIC), "{text}"); + assert!(!text.contains("/Users/private"), "{text}"); + assert!(!text.contains("SIGNED_AUDIO_SECRET"), "{text}"); + let warning = &first_json(&result)["warnings"][0]; + assert_eq!(warning["clipId"], "clip-a"); + assert_eq!(warning["code"], "ANALYSIS_SOURCE_UNAVAILABLE"); + assert!(warning["message"].as_str().unwrap().contains("Relink")); + } + /// A text clip carries no source media (`media_ref` is `""` — see /// `command.rs::add_texts`/`add_captions`), so it can never actually be /// decoded. Built directly (not through `EditCommand::AddTexts`) since this @@ -3926,6 +5990,7 @@ mod tests { timeline, manifest, pcm: pcm(samples, 1_000), + extract_error: None, }); let d = dispatcher_with(h); @@ -3972,6 +6037,7 @@ mod tests { timeline, manifest, pcm: pcm(samples, 1_000), + extract_error: None, }); let d = dispatcher_with(h); @@ -4014,16 +6080,97 @@ mod tests { } #[test] - fn remove_filler_words_stays_disabled_until_transcript_is_wired() { - let d = dispatcher_with(empty_manifest_handle(vec![])); - let r = d.dispatch("remove_filler_words", serde_json::json!({})); - assert!(r.is_error); - assert!( - r.text_joined() - .contains("Unknown tool: remove_filler_words"), - "{}", - r.text_joined() + fn remove_filler_words_returns_reviewable_word_aligned_ranges() { + let (d, _bridge) = transcript_dispatcher(transcript(vec![ + word("Well", 0.0, 0.2), + word("um", 0.2, 0.4), + word("you", 0.5, 0.7), + word("know", 0.7, 0.9), + word("go", 1.0, 1.2), + ])); + assert!(d.advertised_tools().contains(&ToolName::RemoveFillerWords)); + let r = d.dispatch( + "remove_filler_words", + serde_json::json!({ + "clipIds": ["clip-a"], + "fillerWords": ["um", "you know"], + "paddingFrames": 0 + }), + ); + assert!(!r.is_error, "{}", r.text_joined()); + let json = first_json(&r); + assert_eq!(json["applied"], false); + assert_eq!(json["cuts"].as_array().unwrap().len(), 2); + assert_eq!(json["cuts"][0]["text"], "um"); + assert_eq!(json["cuts"][0]["range"], serde_json::json!([6, 12])); + assert_eq!(json["cuts"][1]["text"], "you know"); + assert_eq!(json["cuts"][1]["range"], serde_json::json!([15, 27])); + assert_eq!( + json["commands"][0]["args"]["ranges"], + serde_json::json!([[6, 12], [15, 27]]) + ); + } + + #[test] + fn reviewed_filler_cut_applies_once_and_undo_restores_the_timeline() { + let (d, _bridge) = linked_talking_head_dispatcher(transcript(vec![ + word("Well", 0.0, 0.2), + word("um", 0.2, 0.4), + word("you", 0.5, 0.7), + word("know", 0.7, 0.9), + word("go", 1.0, 1.2), + ])); + let before = d.handle.timeline(); + let preview = d.dispatch( + "remove_filler_words", + serde_json::json!({ + "clipIds": ["clip-v"], + "fillerWords": ["um", "you know"], + "paddingFrames": 0 + }), + ); + let json = first_json(&preview); + let apply = d.dispatch( + "ripple_delete_ranges", + serde_json::json!({ + "trackIndex": 1, + "units": "frames", + "ranges": [json["cuts"][0]["range"].clone()] + }), ); + assert!(!apply.is_error, "{}", apply.text_joined()); + let after = d.handle.timeline(); + assert_ne!(after, before); + assert_eq!(after.tracks.len(), 2); + let video_ranges = after.tracks[0] + .clips + .iter() + .map(|clip| (clip.start_frame, clip.end_frame())) + .collect::>(); + let audio_ranges = after.tracks[1] + .clips + .iter() + .map(|clip| (clip.start_frame, clip.end_frame())) + .collect::>(); + assert_eq!(video_ranges, audio_ranges, "linked A/V ranges drifted"); + assert_eq!(video_ranges.last().map(|range| range.1), Some(894)); + + let post_cut = d.dispatch("get_transcript", serde_json::json!({})); + assert!(!post_cut.is_error, "{}", post_cut.text_joined()); + let post_cut_json = first_json(&post_cut); + let spoken = post_cut_json["clips"] + .as_array() + .unwrap() + .iter() + .flat_map(|clip| clip["words"].as_array().unwrap()) + .filter_map(|word| word[0].as_str()) + .collect::>(); + assert!(!spoken.contains(&"um"), "{spoken:?}"); + assert!(spoken.windows(2).any(|words| words == ["you", "know"])); + + let undo = d.dispatch("undo", serde_json::json!({})); + assert!(!undo.is_error, "{}", undo.text_joined()); + assert_eq!(d.handle.timeline(), before); } #[test] @@ -4267,11 +6414,52 @@ mod tests { assert!(r.text_joined().contains("Deactivated")); } + #[test] + fn dispatch_admission_class_is_read_allowlisted_and_fail_closed() { + for name in [ + "get_timeline", + "inspect_media", + "detect_beats", + "tighten_silences", + ] { + assert_eq!( + dispatch_admission_class(name, &serde_json::json!({})), + DispatchAdmissionClass::ReadOnly, + "{name}" + ); + } + assert_eq!( + dispatch_admission_class("auto_cut_to_beats", &serde_json::json!({})), + DispatchAdmissionClass::ReadOnly + ); + assert_eq!( + dispatch_admission_class("auto_cut_to_beats", &serde_json::json!({"write": false})), + DispatchAdmissionClass::ReadOnly + ); + for (name, args) in [ + ("add_clips", serde_json::json!({})), + ("import_media", serde_json::json!({})), + ("unknown_future_tool", serde_json::json!({})), + ("auto_cut_to_beats", serde_json::json!({"write": true})), + ( + "auto_cut_to_beats", + serde_json::json!({"write": "malformed"}), + ), + ] { + assert_eq!( + dispatch_admission_class(name, &args), + DispatchAdmissionClass::Mutation, + "{name}" + ); + } + } + // MARK: - MediaBridge tools (inspect_timeline / import_media) use crate::mcp::media_bridge::{ - BridgeError, ImportOutcome, ImportSource, InspectResult, InspectedFrame, MediaBridge, - TranscriptSource, TranscriptSourceResult, + BridgeError, ImportOutcome, ImportSource, InspectMediaRequest, InspectMediaResult, + InspectResult, InspectedFrame, InspectedMediaFrame, MediaBridge, TranscriptSource, + TranscriptSourceResult, }; use crate::tools::result::Block; use opentake_media::{TranscriptionResult, TranscriptionSegment, TranscriptionWord}; @@ -4280,8 +6468,6 @@ mod tests { /// folder the dispatcher passed through. struct ImportCall { tag: String, - name: Option, - folder_id: Option, } /// A recording fake bridge: captures the last inspect/import call so tests can @@ -4289,6 +6475,7 @@ mod tests { #[derive(Default)] struct FakeBridge { inspect_calls: Mutex, u32)>>, + media_inspect_calls: Mutex>, import_calls: Mutex>, /// Canned transcripts keyed by media_ref (source-seconds timings). transcripts: Mutex>, @@ -4300,6 +6487,9 @@ mod tests { /// Records the media_refs passed to the last `transcribe_sources` call, /// so tests can assert dedup. transcribe_calls: Mutex>>, + /// Test hook: cancel immediately after transcription work returns, before + /// the dispatcher is allowed to commit captions. + cancel_after_transcribe: Mutex>, /// Canned `search_media` result; when `None` the trait default (disabled) /// runs. Records the `(query, scope, limit, candidate ids)` of each call. search_result: Mutex>, @@ -4320,6 +6510,43 @@ mod tests { } impl MediaBridge for FakeBridge { + fn inspect_media( + &self, + request: &InspectMediaRequest, + ) -> Result { + self.media_inspect_calls + .lock() + .unwrap() + .push(request.clone()); + let transcript = self + .transcripts + .lock() + .unwrap() + .get(&request.media_ref) + .cloned(); + let frames = if request.kind.is_visual() { + vec![InspectedMediaFrame { + timestamp_seconds: request.start_seconds.unwrap_or(0.25), + bytes: vec![0xff, 0xd8, 0xff, 0xe0], + media_type: "image/jpeg".into(), + }] + } else { + Vec::new() + }; + Ok(InspectMediaResult { + frames, + overview_timestamps: Vec::new(), + duration_seconds: 1.0, + width: request.kind.is_visual().then_some(640), + height: request.kind.is_visual().then_some(360), + fps: (request.kind == ClipType::Video).then_some(30.0), + has_audio: request.kind == ClipType::Video, + byte_size: 4096, + transcript, + transcription_unavailable: false, + }) + } + fn transcribe_sources( &self, sources: &[TranscriptSource], @@ -4333,7 +6560,7 @@ mod tests { } let transcripts = self.transcripts.lock().unwrap(); let errors = self.transcribe_errors.lock().unwrap(); - Ok(sources + let results = sources .iter() .map(|s| { if let Some(reason) = errors.get(&s.media_ref) { @@ -4350,7 +6577,11 @@ mod tests { } } }) - .collect()) + .collect(); + if let Some(cancel) = self.cancel_after_transcribe.lock().unwrap().take() { + cancel.cancel(); + } + Ok(results) } fn inspect_timeline( @@ -4379,21 +6610,22 @@ mod tests { fn import_media( &self, source: ImportSource, - name: Option, - folder_id: Option, + _name: Option, + _folder_id: Option, ) -> Result { let tag = match &source { ImportSource::Path(p) => format!("path:{p}"), ImportSource::Bytes { mime_type, .. } => format!("bytes:{mime_type}"), ImportSource::Url { url, .. } => format!("url:{url}"), }; - self.import_calls.lock().unwrap().push(ImportCall { - tag: tag.clone(), - name, - folder_id, - }); + self.import_calls + .lock() + .unwrap() + .push(ImportCall { tag: tag.clone() }); Ok(ImportOutcome { - message: format!("Imported via {tag}."), + asset_count: 1, + folder_count: 0, + recovery_required: false, }) } @@ -4443,15 +6675,217 @@ mod tests { (d, bridge) } + fn inspected_transcript() -> TranscriptionResult { + TranscriptionResult { + text: "hello world".into(), + language: Some("en".into()), + segments: vec![TranscriptionSegment { + text: "hello world".into(), + start: 0.0, + end: 1.0, + }], + words: vec![TranscriptionWord { + text: "hello".into(), + start: Some(0.0), + end: Some(0.5), + }], + } + } + + #[test] + fn inspect_media_returns_real_blocks_metadata_and_transcript() { + let (d, bridge) = dispatcher_with_fake_bridge(); + bridge + .transcripts + .lock() + .unwrap() + .insert("asset-1".into(), inspected_transcript()); + + let result = d.dispatch( + "inspect_media", + serde_json::json!({ + "mediaRef": "asset-1", + "startSeconds": 0.1, + "endSeconds": 0.9, + "maxFrames": 99, + "wordTimestamps": true + }), + ); + assert!(!result.is_error, "{}", result.text_joined()); + assert!(matches!(result.content.first(), Some(Block::Image { .. }))); + let text = result + .content + .iter() + .find_map(|block| match block { + Block::Text { text } if text.starts_with('{') => Some(text), + _ => None, + }) + .expect("inspection metadata block"); + let metadata: Value = serde_json::from_str(text).unwrap(); + assert_eq!(metadata["id"], "asset-1"); + assert_eq!(metadata["type"], "video"); + assert_eq!(metadata["timeRange"], serde_json::json!([0.1, 0.9])); + assert_eq!(metadata["transcription"]["timing"], "sourceSeconds"); + assert_eq!( + metadata["transcription"]["segments"][0], + serde_json::json!(["hello world", 0.0, 1.0]) + ); + assert_eq!( + metadata["transcription"]["words"][0], + serde_json::json!(["hello", 0.0, 0.5]) + ); + + let calls = bridge.media_inspect_calls.lock().unwrap(); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].max_frames, INSPECT_MEDIA_MAX_FRAMES); + assert_eq!(calls[0].start_seconds, Some(0.1)); + assert_eq!(calls[0].end_seconds, Some(0.9)); + } + + #[test] + fn inspect_media_omits_durable_generation_secrets() { + let mut timeline = Timeline::new(); + timeline.fps = 30; + let mut track = opentake_domain::Track::new("track-1", ClipType::Video); + track + .clips + .push(Clip::new("clip-1", "generated-asset", 0, 60)); + timeline.tracks.push(track); + + let mut asset = entry("generated-asset", "Generated clip"); + asset.source = MediaSource::External { + absolute_path: "/Users/ABSOLUTE_PATH_SECRET/generated.mp4".into(), + }; + asset.cached_remote_url = + Some("https://cdn.invalid/result?token=SIGNED_RESULT_SECRET".into()); + asset.generation_input = Some(opentake_domain::GenerationInput { + prompt: "PRIVATE_PROMPT_SECRET".into(), + model: "provider-model".into(), + duration: 2, + aspect_ratio: "16:9".into(), + image_urls: Some(vec![ + "https://cdn.invalid/input?token=SIGNED_IMAGE_SECRET".into() + ]), + reference_image_urls: Some(vec![ + "https://cdn.invalid/reference?token=SIGNED_REFERENCE_SECRET".into(), + ]), + provider_job_id: Some("PROVIDER_JOB_SECRET".into()), + status: Some(GenerationJobStatus::Ready), + progress: Some(0.45678), + ..opentake_domain::GenerationInput::default() + }); + let mut manifest = MediaManifest::new(); + manifest.entries.push(asset); + let dispatcher = Dispatcher::with_bridge( + Arc::new(StateHandle::new(timeline, manifest)), + Arc::new(RwLock::new(PluginRegistry::new())), + Some(Arc::new(FakeBridge::default()) as Arc), + ); + + let result = dispatcher.dispatch( + "inspect_media", + serde_json::json!({"mediaRef": "generated-asset"}), + ); + assert!(!result.is_error, "{}", result.text_joined()); + let serialized = serde_json::to_string(&result).unwrap(); + for secret in [ + "ABSOLUTE_PATH_SECRET", + "SIGNED_RESULT_SECRET", + "SIGNED_IMAGE_SECRET", + "SIGNED_REFERENCE_SECRET", + "PRIVATE_PROMPT_SECRET", + "PROVIDER_JOB_SECRET", + ] { + assert!( + !serialized.contains(secret), + "inspect_media leaked {secret}: {serialized}" + ); + } + let metadata: Value = serde_json::from_str( + result + .content + .iter() + .find_map(|block| match block { + Block::Text { text } if text.starts_with('{') => Some(text), + _ => None, + }) + .expect("inspection metadata"), + ) + .unwrap(); + assert_eq!(metadata["generationStatus"], "none"); + assert_eq!(metadata["generationProgress"], serde_json::json!(0.457)); + assert!(metadata.get("generationInput").is_none()); + assert_eq!(metadata["fileName"], "generated.mp4"); + } + + #[test] + fn inspect_media_clip_mapping_uses_project_frames() { + let (d, bridge) = dispatcher_with_fake_bridge(); + bridge + .transcripts + .lock() + .unwrap() + .insert("asset-1".into(), inspected_transcript()); + + let result = d.dispatch( + "inspect_media", + serde_json::json!({ + "mediaRef": "asset-1", + "clipId": "clip-1", + "wordTimestamps": true + }), + ); + assert!(!result.is_error, "{}", result.text_joined()); + let metadata: Value = serde_json::from_str( + result + .content + .iter() + .find_map(|block| match block { + Block::Text { text } if text.starts_with('{') => Some(text.as_str()), + _ => None, + }) + .unwrap(), + ) + .unwrap(); + assert_eq!(metadata["transcription"]["timing"], "projectFrames"); + assert_eq!( + metadata["transcription"]["segments"][0], + serde_json::json!(["hello world", 0, 30]) + ); + assert_eq!(metadata["timelineMapping"]["clipId"], "clip-1"); + } + + #[test] + fn inspect_media_rejects_missing_asset_and_invalid_range_before_io() { + let (d, bridge) = dispatcher_with_fake_bridge(); + let missing = d.dispatch("inspect_media", serde_json::json!({"mediaRef": "ghost"})); + assert!(missing.is_error); + assert!(missing.text_joined().contains("Media not found: ghost")); + assert_eq!( + missing.public_error_kind(), + Some(PublicErrorKind::ResourceNotFound(ToolName::InspectMedia)) + ); + + let invalid = d.dispatch( + "inspect_media", + serde_json::json!({ + "mediaRef": "asset-1", + "startSeconds": 0.9, + "endSeconds": 0.1 + }), + ); + assert!(invalid.is_error); + assert!(invalid.text_joined().contains("Invalid time range")); + assert!(bridge.media_inspect_calls.lock().unwrap().is_empty()); + } + #[test] - fn inspect_timeline_without_bridge_reports_unavailable() { - // The seeded TestHandle timeline is empty, so first assert the empty guard, - // then a non-empty timeline with no bridge reports "not available". + fn inspect_timeline_without_bridge_is_not_advertised() { let d = dispatcher_with(seeded_handle()); let r = d.dispatch("inspect_timeline", serde_json::json!({ "startFrame": 0 })); assert!(r.is_error); assert!( - r.text_joined().contains("not available"), + r.text_joined().contains("not advertised"), "{}", r.text_joined() ); @@ -4576,7 +7010,7 @@ mod tests { } #[test] - fn import_media_without_bridge_reports_unavailable() { + fn import_media_without_bridge_is_not_advertised() { let d = dispatcher_with(seeded_handle()); let r = d.dispatch( "import_media", @@ -4584,7 +7018,7 @@ mod tests { ); assert!(r.is_error); assert!( - r.text_joined().contains("not available"), + r.text_joined().contains("not advertised"), "{}", r.text_joined() ); @@ -4622,8 +7056,12 @@ mod tests { serde_json::json!({ "source": { "bytes": "AAAA" } }), ); assert!(r.is_error); + assert_eq!( + r.public_error_kind(), + Some(PublicErrorKind::InvalidArguments(ToolName::ImportMedia)) + ); assert!( - r.text_joined().contains("mimeType is required"), + r.text_joined().contains("missing required field"), "{}", r.text_joined() ); @@ -4643,8 +7081,12 @@ mod tests { }), ); assert!(r.is_error); + assert_eq!( + r.public_error_kind(), + Some(PublicErrorKind::InvalidArguments(ToolName::ImportMedia)) + ); assert!( - r.text_joined().contains("source.bytes is too large"), + r.text_joined().contains("value is too large"), "{}", r.text_joined() ); @@ -4657,16 +7099,25 @@ mod tests { #[test] fn import_media_unknown_folder_id_errors() { let (d, _b) = dispatcher_with_fake_bridge(); + const PRIVATE_FOLDER_ID: &str = "ghost-PRIVATE-FOLDER-ID"; let r = d.dispatch( "import_media", - serde_json::json!({ "source": { "path": "/a.mp4" }, "folderId": "ghost" }), + serde_json::json!({ "source": { "url": "https://example.com/a.mp4" }, "folderId": PRIVATE_FOLDER_ID }), ); assert!(r.is_error); + assert_eq!( + r.public_error_kind(), + Some(PublicErrorKind::ResourceNotFound(ToolName::ImportMedia)) + ); assert!( r.text_joined().contains("folderId not found"), "{}", r.text_joined() ); + let safe = crate::mcp::convert::safe_tool_result_for_llm(&r); + assert_eq!(safe["code"], "MCP_RESOURCE_NOT_FOUND"); + assert!(safe["remediation"].as_str().unwrap().contains("Refresh")); + assert!(!safe.to_string().contains(PRIVATE_FOLDER_ID)); } #[test] @@ -4685,23 +7136,27 @@ mod tests { } #[test] - fn import_media_path_forwards_to_bridge_and_returns_message() { + fn import_media_rejects_model_supplied_paths_before_bridge_access() { let (d, bridge) = dispatcher_with_fake_bridge(); - let r = d.dispatch( - "import_media", - serde_json::json!({ "source": { "path": "/clip.mp4" }, "name": "Clip" }), - ); - assert!(!r.is_error, "{}", r.text_joined()); + for path in [ + "/Users/model/Pictures/private.png", + "../../Pictures/private.png", + ] { + let r = d.dispatch( + "import_media", + serde_json::json!({ "source": { "path": path }, "name": "Clip" }), + ); + assert!(r.is_error, "model-supplied path unexpectedly imported"); + assert!(r.text_joined().contains("authority"), "{}", r.text_joined()); + let safe = crate::mcp::convert::safe_tool_result_for_llm(&r); + assert_eq!(safe["code"], "MCP_PATH_AUTHORITY_REQUIRED"); + let safe_wire = safe.to_string(); + assert!(!safe_wire.contains(path), "path leaked: {safe_wire}"); + } assert!( - r.text_joined().contains("Imported via path:/clip.mp4"), - "{}", - r.text_joined() + bridge.import_calls.lock().unwrap().is_empty(), + "path rejection must happen before the bridge can touch metadata" ); - let calls = bridge.import_calls.lock().unwrap(); - assert_eq!(calls.len(), 1); - assert_eq!(calls[0].tag, "path:/clip.mp4"); - assert_eq!(calls[0].name.as_deref(), Some("Clip")); - assert_eq!(calls[0].folder_id, None); } #[test] @@ -4921,7 +7376,7 @@ mod tests { } #[test] - fn search_media_without_bridge_reports_unavailable() { + fn search_media_without_bridge_is_not_advertised() { let mut m = MediaManifest::new(); m.entries.push(entry("v", "Clip")); let handle = Arc::new(StateHandle::new(Timeline::new(), m)); @@ -4929,7 +7384,7 @@ mod tests { let r = d.dispatch("search_media", serde_json::json!({ "query": "x" })); assert!(r.is_error); assert!( - r.text_joined().contains("not available in this build"), + r.text_joined().contains("not advertised"), "{}", r.text_joined() ); @@ -4978,6 +7433,39 @@ mod tests { (d, bridge) } + /// A fixed 30-second talking-head fixture with linked video/audio clips. + /// Only the audio partner is transcribed, matching production caption target + /// selection, while a reviewed ripple cut must keep both tracks frame-exact. + fn linked_talking_head_dispatcher(t: TranscriptionResult) -> (Dispatcher, Arc) { + let mut tl = Timeline::new(); + tl.fps = 30; + + let mut video_track = Track::new("track-v", ClipType::Video); + let mut video = Clip::new("clip-v", "vid", 0, 30 * 30); + video.link_group_id = Some("talking-head-av".into()); + video_track.clips.push(video); + + let mut audio_track = Track::new("track-a", ClipType::Audio); + let mut audio = Clip::new("clip-a", "aud", 0, 30 * 30); + audio.media_type = ClipType::Audio; + audio.link_group_id = Some("talking-head-av".into()); + audio_track.clips.push(audio); + + tl.tracks.push(video_track); + tl.tracks.push(audio_track); + let mut manifest = MediaManifest::new(); + manifest.entries.push(entry("vid", "Camera")); + manifest.entries.push(audio_entry("aud", "Voice")); + let handle = Arc::new(StateHandle::new(tl, manifest)); + let bridge = Arc::new(FakeBridge::default().with_transcript("aud", t)); + let dispatcher = Dispatcher::with_bridge( + handle, + Arc::new(RwLock::new(PluginRegistry::new())), + Some(bridge.clone() as Arc), + ); + (dispatcher, bridge) + } + #[test] fn get_transcript_maps_words_to_project_frames() { let (d, _b) = transcript_dispatcher(transcript(vec![ @@ -5004,8 +7492,7 @@ mod tests { } #[test] - fn get_transcript_without_bridge_reports_unavailable() { - // Same audio timeline but no bridge wired → honest "not available". + fn get_transcript_without_bridge_is_not_advertised() { let mut tl = Timeline::new(); tl.fps = 30; let mut track = opentake_domain::Track::new("track-a", ClipType::Audio); @@ -5019,7 +7506,7 @@ mod tests { let r = d.dispatch("get_transcript", serde_json::json!({})); assert!(r.is_error); assert!( - r.text_joined().contains("not available"), + r.text_joined().contains("not advertised"), "{}", r.text_joined() ); @@ -5093,21 +7580,27 @@ mod tests { #[test] fn get_transcript_skipped_source_reported_not_fatal() { + const PRIVATE_DIAGNOSTIC: &str = + "decode failed at /Users/private/voice.wav?token=SIGNED_TRANSCRIPT_SECRET"; let (d, bridge) = transcript_dispatcher(transcript(vec![word("a", 0.0, 0.5)])); // Force the source to be skipped with a reason. bridge .transcribe_errors .lock() .unwrap() - .insert("aud".into(), "decode failed".into()); + .insert("aud".into(), PRIVATE_DIAGNOSTIC.into()); let r = d.dispatch("get_transcript", serde_json::json!({})); assert!(!r.is_error, "{}", r.text_joined()); + assert!(!r.text_joined().contains(PRIVATE_DIAGNOSTIC)); + assert!(!r.text_joined().contains("/Users/private")); + assert!(!r.text_joined().contains("SIGNED_TRANSCRIPT_SECRET")); let v = first_json(&r); assert_eq!(v["clips"].as_array().unwrap().len(), 0); let skipped = v["skipped"].as_array().unwrap(); assert_eq!(skipped.len(), 1); assert_eq!(skipped[0]["file"], "Voice"); // asset display name - assert_eq!(skipped[0]["reason"], "decode failed"); + assert_eq!(skipped[0]["code"], "TRANSCRIPTION_SOURCE_UNAVAILABLE"); + assert!(skipped[0]["reason"].as_str().unwrap().contains("Relink")); } #[test] @@ -5312,6 +7805,24 @@ mod tests { assert_eq!(d.handle.timeline().tracks.len(), before - 1); } + #[test] + fn add_captions_cancelled_after_transcription_commits_nothing() { + let (dispatcher, bridge) = caption_dispatcher(caption_transcript( + vec![word("a", 0.0, 0.5)], + vec![segment("A.", 0.0, 1.0)], + )); + let before = dispatcher.handle.timeline(); + let cancel = opentake_media::MediaCancelToken::new(); + *bridge.cancel_after_transcribe.lock().unwrap() = Some(cancel.clone()); + + let result = + dispatcher.dispatch_cancellable("add_captions", serde_json::json!({}), &cancel); + + assert!(result.is_error); + assert!(result.text_joined().contains("Cancelled")); + assert_eq!(dispatcher.handle.timeline(), before); + } + #[test] fn add_captions_no_speech_detected_errors() { // Transcript with no segments → no caption lines → "No speech detected". @@ -5365,7 +7876,7 @@ mod tests { } #[test] - fn add_captions_without_bridge_reports_unavailable() { + fn add_captions_without_bridge_is_not_advertised() { let mut tl = Timeline::new(); tl.fps = 30; tl.width = 1920; @@ -5381,7 +7892,7 @@ mod tests { let r = d.dispatch("add_captions", serde_json::json!({})); assert!(r.is_error); assert!( - r.text_joined().contains("not available"), + r.text_joined().contains("not advertised"), "{}", r.text_joined() ); @@ -5614,4 +8125,18 @@ mod tests { ); assert!(r.is_error); } + + /// Composite acceptance entry tracked by the data-safety implementation plan. + /// Keep this as an executable roll-up of the owning MCP boundary tests so the + /// audit command proves validation, mutation, undo, and bridge fail-closed + /// behavior together rather than merely matching a test name. + #[test] + fn cross_cutting_mcp_acceptance() { + precise_path_arg_error_mentions_field(); + add_clips_then_get_timeline_reflects_clip(); + add_captions_is_one_undo_step(); + undo_with_empty_stack_errors(); + import_media_bytes_rejects_oversized_base64_before_bridge(); + import_media_rejects_unknown_nested_source_key(); + } } diff --git a/crates/opentake-agent/src/mcp/generation.rs b/crates/opentake-agent/src/mcp/generation.rs new file mode 100644 index 00000000..e17f91a7 --- /dev/null +++ b/crates/opentake-agent/src/mcp/generation.rs @@ -0,0 +1,223 @@ +//! Provider-neutral generation lifecycle coordination. +//! +//! The Agent dispatcher creates durable placeholders synchronously, while the +//! desktop bridge submits and watches the paid provider job off-thread. This +//! module owns the deterministic terminal pairing contract so every returned +//! result is applied to at most one placeholder and every placeholder reaches +//! one persisted terminal state. + +use std::path::PathBuf; + +use serde::Serialize; + +use crate::tools::args::{ + GenerateAudioArgs, GenerateImageArgs, GenerateVideoArgs, UpscaleMediaArgs, +}; + +/// Typed paid-generation request passed from the tool dispatcher to the +/// desktop runtime. The bridge owns model resolution, reference validation, +/// durable placeholder creation, provider submission, and background watch. +#[derive(Debug, Clone, PartialEq)] +pub enum GenerationRequest { + Video(GenerateVideoArgs), + Image(GenerateImageArgs), + Audio(GenerateAudioArgs), + Upscale(UpscaleMediaArgs), +} + +impl GenerationRequest { + pub fn cost_authorized(&self) -> bool { + match self { + Self::Video(args) => args.cost_authorized == Some(true), + Self::Image(args) => args.cost_authorized == Some(true), + Self::Audio(args) => args.cost_authorized == Some(true), + Self::Upscale(args) => args.cost_authorized == Some(true), + } + } +} + +/// Immediate response from an accepted asynchronous generation submission. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GenerationSubmission { + pub job_id: String, + pub placeholder_asset_ids: Vec, + pub status: String, +} + +/// Host boundary for production generation. Implementations must return only +/// after the placeholder/job record is durably committed, and must continue +/// provider work off the synchronous MCP dispatch thread. +pub trait GenerationBridge: Send + Sync { + /// True only when managed authorization or at least one compatible BYOK + /// credential is currently usable. + fn can_generate(&self) -> bool; + + fn submit( + &self, + request: GenerationRequest, + cancel: &opentake_media::MediaCancelToken, + ) -> Result; +} + +/// A provider result downloaded into a private staging location. The store +/// validates and commits it into the project before reporting success. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DownloadedGenerationArtifact { + pub path: PathBuf, + pub media_type: String, + pub byte_size: u64, +} + +/// Downloads one provider result without exposing credentials or signed URL +/// details to the persistence layer's public error contract. +pub trait GenerationArtifactDownloader { + fn download(&self, asset_id: &str, url: &str) -> Result; +} + +/// Durable state boundary implemented by the desktop project runtime. +pub trait GenerationFinalizationStore { + /// Atomically claim a terminal-finalization lease. `false` means the job is + /// already complete or another callback currently owns the lease. + fn claim_terminal(&self, job_id: &str) -> Result; + + /// Release a failed terminal-finalization lease so restart recovery or a + /// duplicate provider callback can retry. Output operations below must be + /// idempotent because a prior attempt may already have committed a prefix. + fn release_terminal(&self, job_id: &str) -> Result<(), String>; + + /// Commit one staged artifact to the matching placeholder identity. + fn finalize_output( + &self, + asset_id: &str, + artifact: DownloadedGenerationArtifact, + ) -> Result<(), String>; + + /// Persist one fixed, non-sensitive terminal failure code. + fn fail_output(&self, asset_id: &str, code: &str) -> Result<(), String>; + + /// Persist the aggregate job terminal state after every placeholder has a + /// terminal record. + fn complete_job(&self, job_id: &str, succeeded: usize, failed: usize) -> Result<(), String>; +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct GenerationFinalizationSummary { + pub claimed: bool, + pub succeeded: usize, + pub failed: usize, + pub ignored_result_urls: usize, +} + +/// Pair provider URLs to placeholders in order and terminalize every +/// placeholder exactly once. Missing, malformed, download-failed, and +/// commit-failed results become fixed failure codes; extra URLs are ignored. +pub fn finalize_terminal_outputs( + store: &dyn GenerationFinalizationStore, + downloader: &dyn GenerationArtifactDownloader, + job_id: &str, + placeholder_ids: &[String], + result_urls: &[String], +) -> Result { + if !store.claim_terminal(job_id)? { + return Ok(GenerationFinalizationSummary { + claimed: false, + succeeded: 0, + failed: 0, + ignored_result_urls: 0, + }); + } + + let attempt = (|| { + let mut succeeded = 0; + let mut failed = 0; + for (index, asset_id) in placeholder_ids.iter().enumerate() { + let Some(url) = result_urls.get(index) else { + store.fail_output(asset_id, "GENERATION_RESULT_MISSING")?; + failed += 1; + continue; + }; + if !is_accepted_result_url(url) { + store.fail_output(asset_id, "GENERATION_RESULT_URL_INVALID")?; + failed += 1; + continue; + } + let artifact = match downloader.download(asset_id, url) { + Ok(artifact) => artifact, + Err(error) if error == "GENERATION_CANCELLED" => return Err(error), + Err(_) => { + store.fail_output(asset_id, "GENERATION_DOWNLOAD_FAILED")?; + failed += 1; + continue; + } + }; + if store.finalize_output(asset_id, artifact).is_err() { + store.fail_output(asset_id, "GENERATION_FINALIZE_FAILED")?; + failed += 1; + continue; + } + succeeded += 1; + } + store.complete_job(job_id, succeeded, failed)?; + Ok::<_, String>((succeeded, failed)) + })(); + + let (succeeded, failed) = match attempt { + Ok(summary) => summary, + Err(error) => { + if let Err(release_error) = store.release_terminal(job_id) { + return Err(format!( + "generation finalization failed and lease release failed: {release_error}" + )); + } + return Err(error); + } + }; + + Ok(GenerationFinalizationSummary { + claimed: true, + succeeded, + failed, + ignored_result_urls: result_urls.len().saturating_sub(placeholder_ids.len()), + }) +} + +fn is_accepted_result_url(raw: &str) -> bool { + let Ok(url) = reqwest::Url::parse(raw) else { + return false; + }; + match url.scheme() { + "https" => { + url.host_str().is_some() + && url.username().is_empty() + && url.password().is_none() + && url.port().is_none_or(|port| port == 443) + } + "data" => { + let value = url.as_str(); + value.starts_with("data:image/") + || value.starts_with("data:audio/") + || value.starts_with("data:video/") + } + _ => false, + } +} + +#[cfg(test)] +mod tests { + use super::is_accepted_result_url; + + #[test] + fn provider_result_urls_are_https_or_bounded_media_data_urls() { + assert!(is_accepted_result_url("https://cdn.test/result.png")); + assert!(is_accepted_result_url("data:image/png;base64,AAAA")); + assert!(is_accepted_result_url("data:audio/mpeg;base64,AAAA")); + assert!(is_accepted_result_url("data:video/mp4;base64,AAAA")); + assert!(!is_accepted_result_url("http://cdn.test/result.png")); + assert!(!is_accepted_result_url( + "https://user:secret@cdn.test/result.png" + )); + assert!(!is_accepted_result_url("file:///tmp/result.png")); + assert!(!is_accepted_result_url("not-a-url")); + } +} diff --git a/crates/opentake-agent/src/mcp/media_bridge.rs b/crates/opentake-agent/src/mcp/media_bridge.rs index 2bb0a993..113e31b4 100644 --- a/crates/opentake-agent/src/mcp/media_bridge.rs +++ b/crates/opentake-agent/src/mcp/media_bridge.rs @@ -23,6 +23,7 @@ //! Both methods default to `Err("unsupported")` so a hand-rolled bridge (or the //! absence of one) never breaks the build. +use opentake_domain::ClipType; use opentake_media::{MediaCancelToken, TranscriptionResult}; use crate::tools::result::Block; @@ -66,12 +67,65 @@ pub struct InspectResult { pub height: u32, } -/// The outcome of an `import_media` call, mirroring upstream's `.ok("…")` string -/// results. The dispatcher wraps `message` in a [`crate::tools::result::ToolResult`]. +/// One raw-source frame produced for `inspect_media`. +#[derive(Debug, Clone)] +pub struct InspectedMediaFrame { + /// Actual source timestamp decoded for this image. + pub timestamp_seconds: f64, + /// Encoded image bytes (JPEG in the desktop bridge). + pub bytes: Vec, + /// MIME type of `bytes`. + pub media_type: String, +} + +/// Validated source-inspection request. The dispatcher owns tool arguments and +/// manifest/clip validation; the desktop bridge owns retained source resolution, +/// probing, decoding, and transcription. +#[derive(Debug, Clone)] +pub struct InspectMediaRequest { + pub media_ref: String, + pub kind: ClipType, + pub start_seconds: Option, + pub end_seconds: Option, + pub max_frames: usize, + pub overview: bool, +} + +/// Backend facts and content returned for `inspect_media`. The dispatcher turns +/// this neutral result into image blocks plus the compact upstream JSON shape. +#[derive(Debug, Clone)] +pub struct InspectMediaResult { + pub frames: Vec, + /// Source timestamps represented by a single overview storyboard image. + /// Empty for ordinary per-frame inspection. + pub overview_timestamps: Vec, + pub duration_seconds: f64, + pub width: Option, + pub height: Option, + pub fps: Option, + pub has_audio: bool, + pub byte_size: u64, + pub transcript: Option, + /// True when visual inspection succeeded but local ASR was unavailable or + /// failed. Private backend diagnostics never cross this trait boundary. + pub transcription_unavailable: bool, +} + +/// Non-secret facts from a completed `import_media` call. +/// +/// Host adapters deliberately cannot supply arbitrary model-facing success +/// text here: paths, signed URLs, decoder diagnostics, and recovery causes stay +/// behind the bridge. The dispatcher constructs the fixed public response from +/// these bounded scalars. #[derive(Debug, Clone)] pub struct ImportOutcome { - /// Human/LLM-facing confirmation line (same shape as upstream `.ok(...)`). - pub message: String, + /// Number of media assets committed to the project catalog. + pub asset_count: usize, + /// Number of project folders created while mirroring a directory. + pub folder_count: usize, + /// The import itself is authoritative, but a failed postcondition could not + /// be rolled back and the project should be saved/reopened before editing. + pub recovery_required: bool, } /// One decoded `source` object for [`MediaBridge::import_media`]. The dispatcher @@ -104,6 +158,16 @@ pub enum ImportSource { pub struct BridgeError { /// Private diagnostic text; never expose it directly to a model. pub message: String, + /// Fixed classification used to expose a safe recovery contract without + /// forwarding the private diagnostic. + pub kind: BridgeErrorKind, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BridgeErrorKind { + Private, + NotFound, + Unavailable, } impl BridgeError { @@ -111,6 +175,21 @@ impl BridgeError { pub fn new(message: impl Into) -> Self { BridgeError { message: message.into(), + kind: BridgeErrorKind::Private, + } + } + + pub fn not_found(message: impl Into) -> Self { + BridgeError { + message: message.into(), + kind: BridgeErrorKind::NotFound, + } + } + + pub fn unavailable(message: impl Into) -> Self { + BridgeError { + message: message.into(), + kind: BridgeErrorKind::Unavailable, } } } @@ -255,6 +334,18 @@ pub struct SearchMediaResult { /// so the [`Dispatcher`](super::dispatch::Dispatcher) can hold `Arc` across threads (matching [`CoreHandle`](super::core_handle)). pub trait MediaBridge: Send + Sync { + /// Inspect one source asset with real decoded frames and optional on-device + /// transcription. The default is explicitly unavailable so non-desktop + /// embedders do not advertise a fake success. + fn inspect_media( + &self, + _request: &InspectMediaRequest, + ) -> Result { + Err(BridgeError::new( + "inspect_media: source inspection is not available in this build", + )) + } + /// Transcribe each unique source for `get_transcript`, caching so a /// re-transcribe is instant. Per-source errors are returned inline (never /// fatal), matching upstream's skip-don't-fail loop. The default reports @@ -340,9 +431,18 @@ pub trait MediaBridge: Send + Sync { /// bytes (rmcp image content is base64). Kept here so the dispatcher stays free of /// encoding concerns. pub fn frame_to_block(frame: &InspectedFrame) -> Block { + encoded_image_to_block(&frame.bytes, &frame.media_type) +} + +/// Convert a raw-source inspection frame into an MCP image block. +pub fn media_frame_to_block(frame: &InspectedMediaFrame) -> Block { + encoded_image_to_block(&frame.bytes, &frame.media_type) +} + +fn encoded_image_to_block(bytes: &[u8], media_type: &str) -> Block { use base64::Engine as _; - let b64 = base64::engine::general_purpose::STANDARD.encode(&frame.bytes); - Block::image(b64, frame.media_type.clone()) + let b64 = base64::engine::general_purpose::STANDARD.encode(bytes); + Block::image(b64, media_type) } #[cfg(test)] @@ -361,6 +461,22 @@ mod tests { assert!(err.message.contains("not available"), "{}", err.message); } + #[test] + fn default_inspect_media_is_unsupported() { + let b = NoopBridge; + let err = b + .inspect_media(&InspectMediaRequest { + media_ref: "asset".into(), + kind: ClipType::Video, + start_seconds: None, + end_seconds: None, + max_frames: 6, + overview: false, + }) + .unwrap_err(); + assert!(err.message.contains("not available"), "{}", err.message); + } + #[test] fn default_import_media_is_unsupported() { let b = NoopBridge; diff --git a/crates/opentake-agent/src/mcp/media_catalog.rs b/crates/opentake-agent/src/mcp/media_catalog.rs new file mode 100644 index 00000000..53f997fd --- /dev/null +++ b/crates/opentake-agent/src/mcp/media_catalog.rs @@ -0,0 +1,97 @@ +use opentake_domain::{ClipType, GenerationJobStatus, MediaManifest}; +use serde::Serialize; + +/// Model-facing media catalog. This is deliberately separate from the durable +/// manifest so persistence-only fields cannot cross the LLM boundary by default. +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct ModelMediaCatalog<'a> { + version: i64, + entries: Vec>, + folders: Vec>, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct ModelMediaEntry<'a> { + id: &'a str, + name: &'a str, + #[serde(rename = "type")] + kind: ClipType, + duration: f64, + #[serde(skip_serializing_if = "Option::is_none")] + source_width: Option, + #[serde(rename = "sourceFPS", skip_serializing_if = "Option::is_none")] + source_fps: Option, + #[serde(skip_serializing_if = "Option::is_none")] + source_height: Option, + #[serde(skip_serializing_if = "Option::is_none")] + has_audio: Option, + #[serde(skip_serializing_if = "Option::is_none")] + folder_id: Option<&'a str>, + has_proxy: bool, + is_hdr: bool, + generation_status: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + generation_progress: Option, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct ModelMediaFolder<'a> { + id: &'a str, + name: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + parent_folder_id: Option<&'a str>, +} + +impl<'a> From<&'a MediaManifest> for ModelMediaCatalog<'a> { + fn from(manifest: &'a MediaManifest) -> Self { + Self { + version: manifest.version, + entries: manifest + .entries + .iter() + .map(|entry| { + let generation = entry.generation_input.as_ref(); + ModelMediaEntry { + id: &entry.id, + name: &entry.name, + kind: entry.kind, + duration: entry.duration, + source_width: entry.source_width, + source_height: entry.source_height, + source_fps: entry.source_fps, + has_audio: entry.has_audio, + folder_id: entry.folder_id.as_deref(), + has_proxy: entry.proxy.is_some(), + is_hdr: entry.color.as_ref().is_some_and(|color| color.is_hdr()), + generation_status: generation_status_label( + generation.and_then(|input| input.status), + ), + generation_progress: generation.and_then(|input| input.progress), + } + }) + .collect(), + folders: manifest + .folders + .iter() + .map(|folder| ModelMediaFolder { + id: &folder.id, + name: &folder.name, + parent_folder_id: folder.parent_folder_id.as_deref(), + }) + .collect(), + } + } +} + +fn generation_status_label(status: Option) -> &'static str { + match status { + Some(GenerationJobStatus::Queued | GenerationJobStatus::Generating) => "generating", + Some(GenerationJobStatus::Downloading | GenerationJobStatus::Finalizing) => "downloading", + Some(GenerationJobStatus::Failed) => "failed", + Some(GenerationJobStatus::Cancelled) => "cancelled", + Some(GenerationJobStatus::Ready) | None => "none", + } +} diff --git a/crates/opentake-agent/src/mcp/mod.rs b/crates/opentake-agent/src/mcp/mod.rs index 23362344..9da0a0d8 100644 --- a/crates/opentake-agent/src/mcp/mod.rs +++ b/crates/opentake-agent/src/mcp/mod.rs @@ -9,9 +9,14 @@ //! shortens outbound ids. The rmcp server / HTTP handler is a thin shim over this //! and lands in a later phase. +pub mod advanced; pub mod convert; pub mod core_handle; pub mod dispatch; pub mod gen_catalog; +pub mod generation; pub mod media_bridge; +mod media_catalog; +pub mod motion; pub mod server; +pub mod vision; diff --git a/crates/opentake-agent/src/mcp/motion.rs b/crates/opentake-agent/src/mcp/motion.rs new file mode 100644 index 00000000..99c48f88 --- /dev/null +++ b/crates/opentake-agent/src/mcp/motion.rs @@ -0,0 +1,152 @@ +//! Host boundary for deterministic motion-graphic rendering and placement. +//! +//! The Agent crate owns schemas and discovery, while the desktop host owns the +//! browser/renderer, project filesystem authority, media import, and atomic +//! timeline transaction. Keeping those capabilities behind this trait lets the +//! tool contract run against deterministic fakes without advertising a stub in +//! hosts that do not provide the production bridge. + +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; + +#[derive(Debug, Clone, PartialEq)] +pub enum MotionSourceRequest { + Code(String), + Template { + template_id: String, + params: Map, + }, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct AddMotionRequest { + pub source: MotionSourceRequest, + pub start_frame: i32, + pub duration_frames: i32, + pub transparent: bool, + pub track_index: Option, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct EditMotionRequest { + pub clip_id: String, + pub code: Option, + pub params: Option>, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct MotionCommit { + pub clip_id: String, + pub asset_id: String, + pub content_hash: String, + pub action_name: String, + pub output: MotionOutputMetadata, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct ModelSafeMotionCommit<'a> { + status: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + clip_id: Option<&'a str>, + #[serde(skip_serializing_if = "Option::is_none")] + asset_id: Option<&'a str>, + duration_frames: i32, + #[serde(skip_serializing_if = "Option::is_none")] + duration_seconds: Option, + #[serde(skip_serializing_if = "Option::is_none")] + fps: Option, + dimensions: ModelSafeMotionDimensions, +} + +#[derive(Debug, Serialize)] +struct ModelSafeMotionDimensions { + width: u32, + height: u32, +} + +/// Rebuild the renderer-owned commit as the minimal model-facing contract. +/// Provenance hashes, renderer versions, filesystem names, and action text stay +/// behind the host boundary. +pub(crate) fn model_safe_commit(commit: &MotionCommit) -> Value { + let finite_positive = |value: f64| value.is_finite().then_some(value).filter(|v| *v >= 0.0); + let dto = ModelSafeMotionCommit { + status: "completed", + clip_id: safe_motion_id(&commit.clip_id), + asset_id: safe_motion_id(&commit.asset_id), + duration_frames: commit.output.duration_frames.max(0), + duration_seconds: finite_positive(commit.output.duration_seconds), + fps: finite_positive(commit.output.fps), + dimensions: ModelSafeMotionDimensions { + width: commit.output.width, + height: commit.output.height, + }, + }; + serde_json::to_value(dto).unwrap_or_else(|_| serde_json::json!({"status": "completed"})) +} + +fn safe_motion_id(value: &str) -> Option<&str> { + (!value.is_empty() + && value.len() <= 128 + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b':'))) + .then_some(value) +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MotionOutputMetadata { + pub renderer: String, + pub renderer_version: String, + pub output_file: String, + pub fps: f64, + pub width: u32, + pub height: u32, + pub duration_frames: i32, + pub duration_seconds: f64, + pub content_hash: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MotionBridgeErrorKind { + InvalidArguments, + ResourceNotFound, + CapabilityUnavailable, + Cancelled, + RenderFailed, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MotionBridgeError { + pub kind: MotionBridgeErrorKind, + pub message: String, +} + +impl MotionBridgeError { + pub fn new(kind: MotionBridgeErrorKind, message: impl Into) -> Self { + Self { + kind, + message: message.into(), + } + } +} + +pub trait MotionBridge: Send + Sync { + /// True only when the host has a production renderer and project commit + /// path. Discovery omits both motion tools when this returns false. + fn can_render_motion(&self) -> bool; + + fn add( + &self, + request: AddMotionRequest, + cancel: &opentake_media::MediaCancelToken, + ) -> Result; + + fn edit( + &self, + request: EditMotionRequest, + cancel: &opentake_media::MediaCancelToken, + ) -> Result; +} diff --git a/crates/opentake-agent/src/mcp/server.rs b/crates/opentake-agent/src/mcp/server.rs index bba48d01..d65f95d0 100644 --- a/crates/opentake-agent/src/mcp/server.rs +++ b/crates/opentake-agent/src/mcp/server.rs @@ -13,30 +13,254 @@ //! - [`serve`] binds the loopback listener and runs the server. use std::borrow::Cow; -use std::net::{IpAddr, SocketAddr}; -use std::sync::{Arc, RwLock}; +use std::fmt::Write as _; +use std::net::{IpAddr, Ipv4Addr, SocketAddr}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex, RwLock}; use rmcp::model::{ - CallToolRequestParams, CallToolResult, Implementation, ListToolsResult, PaginatedRequestParams, - ServerCapabilities, ServerInfo, Tool, + CallToolRequestParams, CallToolResult, ErrorCode, Implementation, ListToolsResult, + PaginatedRequestParams, ServerCapabilities, ServerInfo, Tool, }; use rmcp::service::RequestContext; use rmcp::{ErrorData as McpError, RoleServer, ServerHandler}; use serde_json::{Map, Value}; +use subtle::ConstantTimeEq; +use tokio_util::sync::CancellationToken; +use crate::chat::ChatTurnGate; +use crate::mcp::advanced::AdvancedWorkflowBridge; use crate::mcp::convert::to_call_tool_result; use crate::mcp::core_handle::CoreHandle; -use crate::mcp::dispatch::Dispatcher; +use crate::mcp::dispatch::{dispatch_admission_class, DispatchAdmissionClass, Dispatcher}; +use crate::mcp::generation::GenerationBridge; use crate::mcp::media_bridge::{MediaBridge, MCP_REQUEST_BODY_MAX}; +use crate::mcp::motion::MotionBridge; use crate::plugin::registry::PluginRegistry; use crate::prompt::assemble::assemble_system_prompt; use crate::tools::descriptions::{description, input_schema}; +use crate::tools::errors::first_non_finite_json_number_path; +#[cfg(test)] use crate::tools::names::ToolName; use crate::tools::panic_boundary::with_redacted_dispatch_panic; +const MCP_MAX_CONCURRENT_DISPATCHES: usize = 8; + /// Default loopback bind address for the MCP server (`agent-SPEC.md` §8.4). pub const DEFAULT_ADDR: &str = "127.0.0.1:19789"; pub const MCP_PORT: u16 = 19789; +static NEXT_MCP_UNDO_SCOPE: AtomicU64 = AtomicU64::new(1); + +fn next_mcp_undo_scope() -> Arc { + format!( + "opentake:mcp:{}:{}", + std::process::id(), + NEXT_MCP_UNDO_SCOPE.fetch_add(1, Ordering::Relaxed) + ) + .into() +} + +fn turn_inactive_error() -> McpError { + McpError::new( + ErrorCode(-32000), + "OpenTake turn is no longer active", + Some(serde_json::json!({ + "code": "OPENTAKE_TURN_CANCELLED" + })), + ) +} + +#[derive(Clone)] +enum DispatchAuthority { + Direct, + Gated { + gate: Arc, + activity: Arc, + undo_scope: Arc, + }, +} + +impl DispatchAuthority { + fn try_enter(&self) -> Result, McpError> { + match self { + Self::Direct => Ok(None), + Self::Gated { activity, .. } => activity + .try_enter() + .map(Some) + .ok_or_else(turn_inactive_error), + } + } + + fn dispatch( + &self, + dispatcher: &Dispatcher, + name: &str, + args: Value, + request_cancel: &opentake_media::MediaCancelToken, + ) -> Option { + match self { + Self::Direct => Some(dispatcher.dispatch_cancellable(name, args, request_cancel)), + Self::Gated { + gate, undo_scope, .. + } => { + gate.dispatch_cancellable_scoped(dispatcher, name, args, undo_scope, request_cancel) + } + } + } + + fn request_cancel(&self) { + if let Self::Gated { gate, .. } = self { + gate.request_cancel(); + } + } +} + +struct DispatchActivity { + state: Mutex, + changed: tokio::sync::Notify, +} + +struct DispatchActivityState { + accepting: bool, + active: usize, +} + +impl DispatchActivity { + fn new() -> Arc { + Arc::new(Self { + state: Mutex::new(DispatchActivityState { + accepting: true, + active: 0, + }), + changed: tokio::sync::Notify::new(), + }) + } + + fn try_enter(self: &Arc) -> Option { + let mut state = self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if !state.accepting { + return None; + } + state.active = state.active.saturating_add(1); + Some(DispatchPermit { + activity: self.clone(), + }) + } + + fn stop_accepting(&self) { + let mut state = self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + state.accepting = false; + if state.active == 0 { + self.changed.notify_one(); + } + } + + async fn wait_zero(&self) { + loop { + let changed = self.changed.notified(); + let active = self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .active; + if active == 0 { + return; + } + changed.await; + } + } + + #[cfg(test)] + fn active(&self) -> usize { + self.state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .active + } +} + +struct DispatchPermit { + activity: Arc, +} + +impl Drop for DispatchPermit { + fn drop(&mut self) { + let mut state = self + .activity + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + state.active = state.active.saturating_sub(1); + if state.active == 0 { + self.activity.changed.notify_one(); + } + } +} + +#[derive(Clone)] +struct DispatchAdmission { + total: Arc, + mutation: Arc, +} + +struct DispatchAdmissionPermit { + _total: tokio::sync::OwnedSemaphorePermit, + _mutation: Option, +} + +impl DispatchAdmission { + fn new() -> Self { + Self::with_total_limit(MCP_MAX_CONCURRENT_DISPATCHES) + } + + fn with_total_limit(total_limit: usize) -> Self { + Self { + total: Arc::new(tokio::sync::Semaphore::new(total_limit.max(1))), + mutation: Arc::new(tokio::sync::Semaphore::new(1)), + } + } + + fn try_enter( + &self, + class: DispatchAdmissionClass, + ) -> Result { + let total = self + .total + .clone() + .try_acquire_owned() + .map_err(|_| dispatch_busy_error())?; + let mutation = match class { + DispatchAdmissionClass::ReadOnly => None, + DispatchAdmissionClass::Mutation => Some( + self.mutation + .clone() + .try_acquire_owned() + .map_err(|_| dispatch_busy_error())?, + ), + }; + Ok(DispatchAdmissionPermit { + _total: total, + _mutation: mutation, + }) + } +} + +fn dispatch_busy_error() -> McpError { + McpError::new( + ErrorCode(-32001), + "OpenTake MCP endpoint is busy", + Some(serde_json::json!({ + "code": "OPENTAKE_MCP_BUSY", + "retryable": true + })), + ) +} fn map_dispatch_join_error(error: tokio::task::JoinError) -> McpError { tracing::error!( @@ -53,6 +277,8 @@ fn map_dispatch_join_error(error: tokio::task::JoinError) -> McpError { pub struct McpServer { dispatcher: Arc, instructions: String, + authority: DispatchAuthority, + admission: DispatchAdmission, } impl McpServer { @@ -68,22 +294,108 @@ impl McpServer { handle: Arc, registry: Arc>, bridge: Option>, + ) -> Self { + Self::with_bridges(handle, registry, bridge, None) + } + + pub fn with_bridges( + handle: Arc, + registry: Arc>, + bridge: Option>, + generation_bridge: Option>, + ) -> Self { + Self::with_capability_bridges(handle, registry, bridge, generation_bridge, None) + } + + pub fn with_capability_bridges( + handle: Arc, + registry: Arc>, + bridge: Option>, + generation_bridge: Option>, + motion_bridge: Option>, + ) -> Self { + Self::with_all_capability_bridges( + handle, + registry, + bridge, + generation_bridge, + motion_bridge, + None, + ) + } + + pub fn with_all_capability_bridges( + handle: Arc, + registry: Arc>, + bridge: Option>, + generation_bridge: Option>, + motion_bridge: Option>, + advanced_bridge: Option>, + ) -> Self { + Self::with_all_capability_bridges_and_admission( + handle, + registry, + bridge, + generation_bridge, + motion_bridge, + advanced_bridge, + DispatchAdmission::new(), + ) + } + + fn with_all_capability_bridges_and_admission( + handle: Arc, + registry: Arc>, + bridge: Option>, + generation_bridge: Option>, + motion_bridge: Option>, + advanced_bridge: Option>, + admission: DispatchAdmission, ) -> Self { let instructions = registry .read() .map(|r| assemble_system_prompt(&r, "default")) .unwrap_or_default(); McpServer { - dispatcher: Arc::new(Dispatcher::with_bridge(handle, registry, bridge)), + dispatcher: Arc::new(Dispatcher::with_all_capability_bridges( + handle, + registry, + bridge, + generation_bridge, + motion_bridge, + advanced_bridge, + )), instructions, + authority: DispatchAuthority::Direct, + admission, } } - /// All tool schemas (1:1 with [`ToolName::ALL`]). - fn tools() -> Vec { - ToolName::ALL - .iter() - .map(|&t| { + fn from_gated_dispatcher( + dispatcher: Arc, + instructions: String, + gate: Arc, + activity: Arc, + admission: DispatchAdmission, + ) -> Self { + Self { + dispatcher, + instructions, + authority: DispatchAuthority::Gated { + gate, + activity, + undo_scope: next_mcp_undo_scope(), + }, + admission, + } + } + + /// Tool schemas for capabilities live in this exact host session. + fn tools(&self) -> Vec { + self.dispatcher + .advertised_tools() + .into_iter() + .map(|t| { let obj = input_schema(t) .as_object() .cloned() @@ -105,6 +417,53 @@ impl McpServer { .unwrap_or(Value::Object(Map::new())); to_call_tool_result(self.dispatcher.dispatch(name, args)) } + + async fn dispatch_tool( + &self, + name: String, + args: Value, + request_cancelled: CancellationToken, + ) -> Result { + if request_cancelled.is_cancelled() + && matches!(&self.authority, DispatchAuthority::Gated { .. }) + { + self.authority.request_cancel(); + return Err(turn_inactive_error()); + } + let admission_permit = self + .admission + .try_enter(dispatch_admission_class(&name, &args))?; + let permit = self.authority.try_enter()?; + let dispatcher = self.dispatcher.clone(); + let authority = self.authority.clone(); + let cancel = opentake_media::MediaCancelToken::new(); + let worker_cancel = cancel.clone(); + let worker_authority = authority.clone(); + let mut worker = tokio::task::spawn_blocking(move || { + let _admission_permit = admission_permit; + let _permit = permit; + with_redacted_dispatch_panic(|| { + worker_authority + .dispatch(&dispatcher, &name, args, &worker_cancel) + .map(to_call_tool_result) + .ok_or_else(turn_inactive_error) + }) + }); + let joined = tokio::select! { + result = &mut worker => result, + () = request_cancelled.cancelled() => { + cancel.cancel(); + authority.request_cancel(); + worker.await + } + } + .map_err(map_dispatch_join_error)?; + if request_cancelled.is_cancelled() { + cancel.cancel(); + authority.request_cancel(); + } + joined + } } impl ServerHandler for McpServer { @@ -120,7 +479,7 @@ impl ServerHandler for McpServer { _context: RequestContext, ) -> Result { Ok(ListToolsResult { - tools: Self::tools(), + tools: self.tools(), next_cursor: None, meta: None, }) @@ -131,31 +490,15 @@ impl ServerHandler for McpServer { request: CallToolRequestParams, context: RequestContext, ) -> Result { - let dispatcher = self.dispatcher.clone(); let name = request.name.to_string(); let args = request .arguments .map(Value::Object) .unwrap_or(Value::Object(Map::new())); - let cancel = opentake_media::MediaCancelToken::new(); - let worker_cancel = cancel.clone(); // rmcp cancels `context.ct` for the protocol's explicit // `notifications/cancelled`. This does not claim raw TCP disconnect // detection; it is the MCP cancellation semantic exposed by rmcp. - let mut worker = tokio::task::spawn_blocking(move || { - with_redacted_dispatch_panic(|| { - to_call_tool_result(dispatcher.dispatch_cancellable(&name, args, &worker_cancel)) - }) - }); - let result = tokio::select! { - result = &mut worker => result, - () = context.ct.cancelled() => { - cancel.cancel(); - worker.await - } - } - .map_err(map_dispatch_join_error); - result + self.dispatch_tool(name, args, context.ct).await } } @@ -269,6 +612,47 @@ async fn localhost_guard( } } +fn bearer_token_matches(headers: &axum::http::HeaderMap, expected: &str) -> bool { + let mut values = headers.get_all(axum::http::header::AUTHORIZATION).iter(); + let Some(value) = values.next() else { + return false; + }; + if values.next().is_some() { + return false; + } + let Ok(value) = value.to_str() else { + return false; + }; + let Some((scheme, supplied)) = value.split_once(' ') else { + return false; + }; + scheme.eq_ignore_ascii_case("bearer") + && supplied.len() == expected.len() + && bool::from(supplied.as_bytes().ct_eq(expected.as_bytes())) +} + +/// Authenticate every route on a per-turn endpoint before any MCP session is +/// created. The fixed-size token is compared in constant time after its public +/// length and scheme have been validated. +async fn ephemeral_bearer_guard( + axum::extract::State(expected): axum::extract::State>, + request: axum::extract::Request, + next: axum::middleware::Next, +) -> axum::response::Response { + use axum::response::IntoResponse; + + if bearer_token_matches(request.headers(), &expected) { + next.run(request).await + } else { + ( + axum::http::StatusCode::UNAUTHORIZED, + [(axum::http::header::WWW_AUTHENTICATE, "Bearer")], + "OpenTake MCP authentication required", + ) + .into_response() + } +} + /// Reject explicit protocol versions that the linked rmcp SDK cannot serve. /// Missing versions retain rmcp's backwards-compatible negotiation behavior. async fn protocol_version_guard( @@ -368,6 +752,43 @@ async fn content_type_guard( next.run(request).await } +/// Buffer the already bounded MCP request once so non-standard JSON numeric +/// tokens and exponent overflow can be rejected with the tool-relative path +/// before rmcp's JSON decoder loses that context. +async fn finite_number_guard( + request: axum::extract::Request, + next: axum::middleware::Next, +) -> axum::response::Response { + use axum::response::IntoResponse; + + if request.method() != axum::http::Method::POST || request.uri().path() != "/mcp" { + return next.run(request).await; + } + let (parts, body) = request.into_parts(); + let bytes = match axum::body::to_bytes(body, MCP_REQUEST_BODY_MAX).await { + Ok(bytes) => bytes, + Err(_) => { + return ( + axum::http::StatusCode::PAYLOAD_TOO_LARGE, + "OpenTake MCP request body is too large", + ) + .into_response(); + } + }; + if let Some(path) = first_non_finite_json_number_path(&bytes) { + return ( + axum::http::StatusCode::BAD_REQUEST, + format!("{path}: value must be finite"), + ) + .into_response(); + } + next.run(axum::http::Request::from_parts( + parts, + axum::body::Body::from(bytes), + )) + .await +} + /// Minimal OAuth protected-resource metadata: the server requires no auth (it is /// loopback-only), so it advertises no authorization servers. async fn oauth_protected_resource() -> axum::Json { @@ -415,6 +836,54 @@ pub fn build_router_with_bridge_for_port( registry: Arc>, bridge: Option>, expected_port: u16, +) -> axum::Router { + build_router_with_bridges_for_port(handle, registry, bridge, None, expected_port) +} + +pub fn build_router_with_bridges_for_port( + handle: Arc, + registry: Arc>, + bridge: Option>, + generation_bridge: Option>, + expected_port: u16, +) -> axum::Router { + build_router_with_capability_bridges_for_port( + handle, + registry, + bridge, + generation_bridge, + None, + expected_port, + ) +} + +pub fn build_router_with_capability_bridges_for_port( + handle: Arc, + registry: Arc>, + bridge: Option>, + generation_bridge: Option>, + motion_bridge: Option>, + expected_port: u16, +) -> axum::Router { + build_router_with_all_capability_bridges_for_port( + handle, + registry, + bridge, + generation_bridge, + motion_bridge, + None, + expected_port, + ) +} + +pub fn build_router_with_all_capability_bridges_for_port( + handle: Arc, + registry: Arc>, + bridge: Option>, + generation_bridge: Option>, + motion_bridge: Option>, + advanced_bridge: Option>, + expected_port: u16, ) -> axum::Router { use rmcp::transport::streamable_http_server::session::local::LocalSessionManager; use rmcp::transport::streamable_http_server::{ @@ -423,12 +892,17 @@ pub fn build_router_with_bridge_for_port( use tower::ServiceBuilder; use tower_http::limit::RequestBodyLimitLayer; + let admission = DispatchAdmission::new(); let service = StreamableHttpService::new( move || { - Ok(McpServer::with_bridge( + Ok(McpServer::with_all_capability_bridges_and_admission( handle.clone(), registry.clone(), bridge.clone(), + generation_bridge.clone(), + motion_bridge.clone(), + advanced_bridge.clone(), + admission.clone(), )) }, Arc::new(LocalSessionManager::default()), @@ -444,6 +918,7 @@ pub fn build_router_with_bridge_for_port( axum::routing::get(oauth_protected_resource), ) .route_service("/mcp", service) + .layer(axum::middleware::from_fn(finite_number_guard)) .layer(axum::middleware::from_fn(content_type_guard)) .layer(axum::middleware::from_fn(protocol_version_guard)) .layer(axum::middleware::from_fn_with_state( @@ -452,6 +927,274 @@ pub fn build_router_with_bridge_for_port( )) } +fn build_gated_router_for_port( + dispatcher: Arc, + instructions: String, + gate: Arc, + activity: Arc, + shutdown: CancellationToken, + expected_port: u16, + bearer_token: Option>, +) -> axum::Router { + use rmcp::transport::streamable_http_server::session::local::LocalSessionManager; + use rmcp::transport::streamable_http_server::{ + StreamableHttpServerConfig, StreamableHttpService, + }; + use tower::ServiceBuilder; + use tower_http::limit::RequestBodyLimitLayer; + + let mut config = StreamableHttpServerConfig::default(); + config.cancellation_token = shutdown; + let admission = DispatchAdmission::new(); + let service = StreamableHttpService::new( + move || { + Ok(McpServer::from_gated_dispatcher( + dispatcher.clone(), + instructions.clone(), + gate.clone(), + activity.clone(), + admission.clone(), + )) + }, + Arc::new(LocalSessionManager::default()), + config, + ); + let service = ServiceBuilder::new() + .layer(RequestBodyLimitLayer::new(MCP_REQUEST_BODY_MAX)) + .service(service); + + let router = axum::Router::new() + .route( + "/.well-known/oauth-protected-resource", + axum::routing::get(oauth_protected_resource), + ) + .route_service("/mcp", service) + .layer(axum::middleware::from_fn(finite_number_guard)) + .layer(axum::middleware::from_fn(content_type_guard)) + .layer(axum::middleware::from_fn(protocol_version_guard)) + .layer(axum::middleware::from_fn_with_state( + expected_port, + localhost_guard, + )); + match bearer_token { + Some(token) => router.layer(axum::middleware::from_fn_with_state( + token, + ephemeral_bearer_guard, + )), + None => router, + } +} + +#[derive(Debug, thiserror::Error)] +pub enum EphemeralMcpError { + #[error("could not bind the private OpenTake MCP endpoint")] + Bind(#[source] std::io::Error), + #[error("the private OpenTake MCP endpoint failed")] + Serve(#[source] std::io::Error), + #[error("the private OpenTake MCP endpoint task failed")] + Join, + #[error("could not create private OpenTake MCP credentials")] + Entropy(#[source] getrandom::Error), +} + +struct CancelTokenOnDrop(CancellationToken); + +impl Drop for CancelTokenOnDrop { + fn drop(&mut self) { + self.0.cancel(); + } +} + +/// A project-authorized MCP endpoint owned by exactly one in-app Agent turn. +/// Call [`Self::close`] before releasing the turn so blocking tool work cannot +/// outlive its project identity. +#[must_use = "the endpoint must be closed before its Agent turn is released"] +pub struct EphemeralMcpEndpoint { + addr: SocketAddr, + url: String, + bearer_token: Arc, + shutdown: CancellationToken, + activity: Arc, + cancel_gate: Arc, + stopped: CancellationToken, + join: Option>>, + closed: bool, +} + +impl EphemeralMcpEndpoint { + pub fn addr(&self) -> SocketAddr { + self.addr + } + + pub fn url(&self) -> &str { + &self.url + } + + /// Per-turn bearer credential. It is intentionally absent from the URL and + /// has no `Debug` representation; callers should place it only in a child + /// process environment variable. + pub fn bearer_token(&self) -> &str { + &self.bearer_token + } + + /// Completes if the listener exits before the owner begins normal cleanup. + pub async fn stopped(&self) { + self.stopped.cancelled().await; + } + + /// Stop admission first, terminate transport sessions, then wait for every + /// blocking dispatcher call before joining the listener task. + pub async fn close(mut self) -> Result<(), EphemeralMcpError> { + self.activity.stop_accepting(); + self.shutdown.cancel(); + self.activity.wait_zero().await; + let result = match self.join.as_mut() { + Some(join) => match join.await { + Ok(Ok(())) => Ok(()), + Ok(Err(error)) => Err(EphemeralMcpError::Serve(error)), + Err(error) => { + tracing::error!( + target: "opentake::mcp::private", + task_cancelled = error.is_cancelled(), + task_panic = error.is_panic(), + "private MCP listener task failed" + ); + Err(EphemeralMcpError::Join) + } + }, + None => Err(EphemeralMcpError::Join), + }; + self.join.take(); + self.closed = true; + result + } +} + +impl Drop for EphemeralMcpEndpoint { + fn drop(&mut self) { + if self.closed { + return; + } + self.activity.stop_accepting(); + self.cancel_gate.request_cancel(); + self.shutdown.cancel(); + if let Some(join) = self.join.take() { + join.abort(); + } + } +} + +/// Bind a per-turn project-authorized MCP server on a fresh IPv4 loopback port. +pub async fn bind_ephemeral_gated( + dispatcher: Arc, + registry: Arc>, + gate: Arc, +) -> Result { + bind_ephemeral_gated_on( + SocketAddr::from((Ipv4Addr::LOCALHOST, 0)), + dispatcher, + registry, + gate, + ) + .await +} + +async fn bind_ephemeral_gated_on( + addr: SocketAddr, + dispatcher: Arc, + registry: Arc>, + gate: Arc, +) -> Result { + if !addr.ip().is_loopback() { + return Err(EphemeralMcpError::Bind(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "private MCP endpoint requires a loopback address", + ))); + } + let listener = tokio::net::TcpListener::bind(addr) + .await + .map_err(EphemeralMcpError::Bind)?; + let bound_addr = listener.local_addr().map_err(EphemeralMcpError::Bind)?; + let mut secret = [0_u8; 32]; + getrandom::fill(&mut secret).map_err(EphemeralMcpError::Entropy)?; + let mut encoded_secret = String::with_capacity(secret.len() * 2); + for byte in secret { + write!(&mut encoded_secret, "{byte:02x}").expect("writing to a String cannot fail"); + } + let bearer_token: Arc = encoded_secret.into(); + let instructions = registry + .read() + .map(|registry| assemble_system_prompt(®istry, "default")) + .unwrap_or_default(); + let activity = DispatchActivity::new(); + let shutdown = CancellationToken::new(); + let stopped = CancellationToken::new(); + let cancel_gate = gate.clone(); + let router = build_gated_router_for_port( + dispatcher, + instructions, + gate, + activity.clone(), + shutdown.clone(), + bound_addr.port(), + Some(bearer_token.clone()), + ); + let listener_shutdown = shutdown.clone(); + let listener_stopped = stopped.clone(); + let join = tokio::spawn(async move { + let _stopped = CancelTokenOnDrop(listener_stopped); + axum::serve(listener, router) + .with_graceful_shutdown(listener_shutdown.cancelled_owned()) + .await + }); + Ok(EphemeralMcpEndpoint { + addr: bound_addr, + url: format!("http://{bound_addr}/mcp"), + bearer_token, + shutdown, + activity, + cancel_gate, + stopped, + join: Some(join), + closed: false, + }) +} + +/// Serve a long-lived loopback MCP endpoint over an already-constructed shared +/// dispatcher. Every call still passes through `gate`; unlike the direct legacy +/// constructors this cannot silently create a second undo/plugin/capability +/// universe beside the in-app Agent. +pub async fn serve_gated_dispatcher( + addr: SocketAddr, + dispatcher: Arc, + registry: Arc>, + gate: Arc, +) -> std::io::Result<()> { + if !addr.ip().is_loopback() { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("MCP server requires a loopback bind address, got {addr}"), + )); + } + let listener = tokio::net::TcpListener::bind(addr).await?; + let bound_addr = listener.local_addr()?; + let instructions = registry + .read() + .map(|registry| assemble_system_prompt(®istry, "default")) + .unwrap_or_default(); + let router = build_gated_router_for_port( + dispatcher, + instructions, + gate, + DispatchActivity::new(), + CancellationToken::new(), + bound_addr.port(), + None, + ); + tracing::info!("MCP server listening on http://{bound_addr}/mcp"); + axum::serve(listener, router).await +} + /// Bind `addr` (loopback) and serve the MCP router with no media bridge. See /// [`serve_with_bridge`]. pub async fn serve( @@ -469,6 +1212,48 @@ pub async fn serve_with_bridge( handle: Arc, registry: Arc>, bridge: Option>, +) -> std::io::Result<()> { + serve_with_bridges(addr, handle, registry, bridge, None).await +} + +pub async fn serve_with_bridges( + addr: SocketAddr, + handle: Arc, + registry: Arc>, + bridge: Option>, + generation_bridge: Option>, +) -> std::io::Result<()> { + serve_with_capability_bridges(addr, handle, registry, bridge, generation_bridge, None).await +} + +pub async fn serve_with_capability_bridges( + addr: SocketAddr, + handle: Arc, + registry: Arc>, + bridge: Option>, + generation_bridge: Option>, + motion_bridge: Option>, +) -> std::io::Result<()> { + serve_with_all_capability_bridges( + addr, + handle, + registry, + bridge, + generation_bridge, + motion_bridge, + None, + ) + .await +} + +pub async fn serve_with_all_capability_bridges( + addr: SocketAddr, + handle: Arc, + registry: Arc>, + bridge: Option>, + generation_bridge: Option>, + motion_bridge: Option>, + advanced_bridge: Option>, ) -> std::io::Result<()> { if !addr.ip().is_loopback() { return Err(std::io::Error::new( @@ -478,7 +1263,15 @@ pub async fn serve_with_bridge( } let listener = tokio::net::TcpListener::bind(addr).await?; let bound_addr = listener.local_addr()?; - let router = build_router_with_bridge_for_port(handle, registry, bridge, bound_addr.port()); + let router = build_router_with_all_capability_bridges_for_port( + handle, + registry, + bridge, + generation_bridge, + motion_bridge, + advanced_bridge, + bound_addr.port(), + ); tracing::info!("MCP server listening on http://{bound_addr}/mcp"); axum::serve(listener, router).await } @@ -487,10 +1280,13 @@ pub async fn serve_with_bridge( mod tests { use super::*; use crate::mcp::core_handle::CoreHandle; + use crate::tools::result::ToolResult; use opentake_core::AppCore; use opentake_domain::{ClipType, MediaManifest, Timeline}; use opentake_ops::command::{EditCommand, EditResult}; use std::path::PathBuf; + use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; + use std::sync::Condvar; struct TestHandle { core: AppCore, @@ -526,17 +1322,209 @@ mod tests { McpServer::new(Arc::new(TestHandle::new()), registry) } + struct CountingGate { + dispatches: AtomicUsize, + cancellations: AtomicUsize, + allow: AtomicBool, + } + + impl CountingGate { + fn new(allow: bool) -> Self { + Self { + dispatches: AtomicUsize::new(0), + cancellations: AtomicUsize::new(0), + allow: AtomicBool::new(allow), + } + } + } + + impl ChatTurnGate for CountingGate { + fn timeline(&self, dispatcher: &Dispatcher) -> Option { + Some(dispatcher.timeline()) + } + + fn dispatch( + &self, + _dispatcher: &Dispatcher, + _name: &str, + _args: Value, + ) -> Option { + self.dispatches.fetch_add(1, Ordering::SeqCst); + self.allow + .load(Ordering::SeqCst) + .then(|| ToolResult::ok("gated")) + } + + fn request_cancel(&self) { + self.cancellations.fetch_add(1, Ordering::SeqCst); + } + } + + struct BlockingGate { + entered: Mutex>>, + cancellation_seen: Mutex>>, + released: Mutex, + release_changed: Condvar, + } + + impl BlockingGate { + fn new( + entered: tokio::sync::oneshot::Sender<()>, + cancellation_seen: tokio::sync::oneshot::Sender<()>, + ) -> Self { + Self { + entered: Mutex::new(Some(entered)), + cancellation_seen: Mutex::new(Some(cancellation_seen)), + released: Mutex::new(false), + release_changed: Condvar::new(), + } + } + + fn release(&self) { + *self + .released + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = true; + self.release_changed.notify_all(); + } + } + + impl ChatTurnGate for BlockingGate { + fn timeline(&self, dispatcher: &Dispatcher) -> Option { + Some(dispatcher.timeline()) + } + + fn dispatch( + &self, + _dispatcher: &Dispatcher, + _name: &str, + _args: Value, + ) -> Option { + if let Some(entered) = self + .entered + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .take() + { + let _ = entered.send(()); + } + let mut released = self + .released + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + while !*released { + released = self + .release_changed + .wait(released) + .unwrap_or_else(std::sync::PoisonError::into_inner); + } + Some(ToolResult::ok("released")) + } + + fn request_cancel(&self) { + if let Some(seen) = self + .cancellation_seen + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .take() + { + let _ = seen.send(()); + } + } + } + + struct RecordingBlockingGate { + blocked_name: Option<&'static str>, + entered: tokio::sync::mpsc::UnboundedSender, + released: Mutex, + release_changed: Condvar, + } + + impl RecordingBlockingGate { + fn new( + blocked_name: Option<&'static str>, + entered: tokio::sync::mpsc::UnboundedSender, + ) -> Self { + Self { + blocked_name, + entered, + released: Mutex::new(false), + release_changed: Condvar::new(), + } + } + + fn release(&self) { + *self + .released + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = true; + self.release_changed.notify_all(); + } + } + + impl ChatTurnGate for RecordingBlockingGate { + fn timeline(&self, dispatcher: &Dispatcher) -> Option { + Some(dispatcher.timeline()) + } + + fn dispatch( + &self, + _dispatcher: &Dispatcher, + name: &str, + _args: Value, + ) -> Option { + let _ = self.entered.send(name.to_string()); + if self.blocked_name.is_none() || self.blocked_name == Some(name) { + let mut released = self + .released + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + while !*released { + released = self + .release_changed + .wait(released) + .unwrap_or_else(std::sync::PoisonError::into_inner); + } + } + Some(ToolResult::ok("admitted")) + } + } + + fn gated_server(gate: Arc, activity: Arc) -> McpServer { + let registry = Arc::new(RwLock::new(PluginRegistry::with_builtins())); + let dispatcher = Arc::new(Dispatcher::new(Arc::new(TestHandle::new()), registry)); + McpServer::from_gated_dispatcher( + dispatcher, + String::new(), + gate, + activity, + DispatchAdmission::new(), + ) + } + + fn gated_server_with_shared_admission( + dispatcher: Arc, + gate: Arc, + activity: Arc, + admission: DispatchAdmission, + ) -> McpServer { + McpServer::from_gated_dispatcher(dispatcher, String::new(), gate, activity, admission) + } + #[test] - fn lists_all_44_tools() { - assert_eq!(McpServer::tools().len(), ToolName::ALL.len()); - // Names round-trip to the wire names. - let names: Vec = McpServer::tools() + fn lists_every_advertised_tool() { + let server = server(); + let expected = ToolName::ALL .iter() - .map(|t| t.name.to_string()) - .collect(); + .filter(|tool| !tool.requires_media_bridge()) + .count(); + assert_eq!(server.tools().len(), expected); + // Names round-trip to the wire names. + let names: Vec = server.tools().iter().map(|t| t.name.to_string()).collect(); assert!(names.contains(&"add_clips".to_string())); assert!(names.contains(&"detect_beats".to_string())); assert!(names.contains(&"activate_workflow".to_string())); + assert!(!names.contains(&"remove_filler_words".to_string())); } #[test] @@ -618,6 +1606,453 @@ mod tests { assert!(!wire.contains("oauth-super-secret-token")); } + #[tokio::test] + async fn gated_dispatch_never_bypasses_gate_and_fails_closed() { + let gate = Arc::new(CountingGate::new(true)); + let server = gated_server(gate.clone(), DispatchActivity::new()); + let result = server + .dispatch_tool( + "get_timeline".into(), + serde_json::json!({}), + CancellationToken::new(), + ) + .await + .expect("authorized gate result"); + assert_ne!(result.is_error, Some(true)); + assert_eq!(gate.dispatches.load(Ordering::SeqCst), 1); + + gate.allow.store(false, Ordering::SeqCst); + let error = server + .dispatch_tool( + "get_timeline".into(), + serde_json::json!({}), + CancellationToken::new(), + ) + .await + .expect_err("stale gate must fail closed"); + let wire = serde_json::to_string(&error).unwrap(); + assert!(wire.contains("OPENTAKE_TURN_CANCELLED"), "{wire}"); + assert_eq!(gate.dispatches.load(Ordering::SeqCst), 2); + } + + #[tokio::test] + async fn protocol_cancel_requests_whole_turn_and_awaits_worker() { + let (entered_tx, entered_rx) = tokio::sync::oneshot::channel(); + let (cancel_tx, cancel_rx) = tokio::sync::oneshot::channel(); + let gate = Arc::new(BlockingGate::new(entered_tx, cancel_tx)); + let activity = DispatchActivity::new(); + let server = Arc::new(gated_server(gate.clone(), activity.clone())); + let request_cancel = CancellationToken::new(); + let worker_server = server.clone(); + let worker_cancel = request_cancel.clone(); + let task = tokio::spawn(async move { + worker_server + .dispatch_tool("get_timeline".into(), serde_json::json!({}), worker_cancel) + .await + }); + + entered_rx.await.expect("worker entered the gate"); + assert_eq!(activity.active(), 1); + request_cancel.cancel(); + cancel_rx.await.expect("whole-turn cancellation requested"); + assert!(!task.is_finished(), "blocking worker must still be awaited"); + assert_eq!(activity.active(), 1); + + gate.release(); + task.await + .expect("dispatch task joined") + .expect("tool result"); + assert_eq!(activity.active(), 0); + } + + #[tokio::test] + async fn stopping_admission_rejects_new_calls_and_waits_for_active_permit() { + let activity = DispatchActivity::new(); + let permit = activity.try_enter().expect("first dispatch admitted"); + activity.stop_accepting(); + assert!( + activity.try_enter().is_none(), + "new dispatch must be rejected" + ); + + let waiter_activity = activity.clone(); + let (drained_tx, mut drained_rx) = tokio::sync::oneshot::channel(); + tokio::spawn(async move { + waiter_activity.wait_zero().await; + let _ = drained_tx.send(()); + }); + assert!( + matches!( + drained_rx.try_recv(), + Err(tokio::sync::oneshot::error::TryRecvError::Empty) + ), + "drain must wait for the active permit" + ); + drop(permit); + drained_rx.await.expect("drain completed after permit drop"); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn endpoint_total_admission_caps_concurrent_read_workers() { + let registry = Arc::new(RwLock::new(PluginRegistry::with_builtins())); + let dispatcher = Arc::new(Dispatcher::new(Arc::new(TestHandle::new()), registry)); + let activity = DispatchActivity::new(); + let admission = DispatchAdmission::with_total_limit(2); + let (entered_tx, mut entered_rx) = tokio::sync::mpsc::unbounded_channel(); + let gate = Arc::new(RecordingBlockingGate::new(None, entered_tx)); + + let first = Arc::new(gated_server_with_shared_admission( + dispatcher.clone(), + gate.clone(), + activity.clone(), + admission.clone(), + )); + let second = Arc::new(gated_server_with_shared_admission( + dispatcher.clone(), + gate.clone(), + activity.clone(), + admission.clone(), + )); + let third = gated_server_with_shared_admission( + dispatcher, + gate.clone(), + activity.clone(), + admission, + ); + let first_task = tokio::spawn(async move { + first + .dispatch_tool( + "get_timeline".into(), + serde_json::json!({}), + CancellationToken::new(), + ) + .await + }); + let second_task = tokio::spawn(async move { + second + .dispatch_tool( + "get_media".into(), + serde_json::json!({}), + CancellationToken::new(), + ) + .await + }); + let mut entered = vec![ + entered_rx.recv().await.expect("first read entered"), + entered_rx.recv().await.expect("second read entered"), + ]; + entered.sort(); + assert_eq!(entered, ["get_media", "get_timeline"]); + + let error = tokio::time::timeout( + std::time::Duration::from_millis(200), + third.dispatch_tool( + "list_folders".into(), + serde_json::json!({}), + CancellationToken::new(), + ), + ) + .await + .expect("over-cap call must fail immediately") + .expect_err("third read must be rejected while capacity is full"); + let wire = serde_json::to_string(&error).expect("encode busy error"); + assert!(wire.contains("OPENTAKE_MCP_BUSY"), "{wire}"); + assert!(entered_rx.try_recv().is_err(), "busy call reached the gate"); + assert_eq!(activity.active(), 2); + + gate.release(); + first_task + .await + .expect("first task joined") + .expect("first read completed"); + second_task + .await + .expect("second task joined") + .expect("second read completed"); + assert_eq!(activity.active(), 0); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn endpoint_serializes_mutations_without_blocking_admitted_reads() { + let registry = Arc::new(RwLock::new(PluginRegistry::with_builtins())); + let dispatcher = Arc::new(Dispatcher::new(Arc::new(TestHandle::new()), registry)); + let activity = DispatchActivity::new(); + let admission = DispatchAdmission::with_total_limit(2); + let (entered_tx, mut entered_rx) = tokio::sync::mpsc::unbounded_channel(); + let gate = Arc::new(RecordingBlockingGate::new(Some("add_clips"), entered_tx)); + let mutation_server = Arc::new(gated_server_with_shared_admission( + dispatcher.clone(), + gate.clone(), + activity.clone(), + admission.clone(), + )); + let competing_mutation = gated_server_with_shared_admission( + dispatcher.clone(), + gate.clone(), + activity.clone(), + admission.clone(), + ); + let read_server = gated_server_with_shared_admission( + dispatcher, + gate.clone(), + activity.clone(), + admission, + ); + + let mutation_task = tokio::spawn(async move { + mutation_server + .dispatch_tool( + "add_clips".into(), + serde_json::json!({}), + CancellationToken::new(), + ) + .await + }); + assert_eq!( + entered_rx.recv().await.as_deref(), + Some("add_clips"), + "first mutation entered" + ); + + let error = competing_mutation + .dispatch_tool( + "remove_clips".into(), + serde_json::json!({}), + CancellationToken::new(), + ) + .await + .expect_err("a second mutation must fail busy"); + let wire = serde_json::to_string(&error).expect("encode busy error"); + assert!(wire.contains("OPENTAKE_MCP_BUSY"), "{wire}"); + assert!(entered_rx.try_recv().is_err(), "busy mutation reached gate"); + + let read = read_server + .dispatch_tool( + "get_timeline".into(), + serde_json::json!({}), + CancellationToken::new(), + ) + .await + .expect("read admitted beside mutation"); + assert_ne!(read.is_error, Some(true)); + assert_eq!(entered_rx.recv().await.as_deref(), Some("get_timeline")); + + gate.release(); + mutation_task + .await + .expect("mutation task joined") + .expect("first mutation completed"); + assert_eq!(activity.active(), 0); + } + + #[tokio::test] + async fn ephemeral_endpoint_uses_dynamic_port_and_closes_listener() { + let registry = Arc::new(RwLock::new(PluginRegistry::with_builtins())); + let dispatcher = Arc::new(Dispatcher::new( + Arc::new(TestHandle::new()), + registry.clone(), + )); + let gate = Arc::new(CountingGate::new(true)); + let endpoint = bind_ephemeral_gated(dispatcher, registry, gate.clone()) + .await + .expect("bind private endpoint"); + let addr = endpoint.addr(); + assert_ne!(addr.port(), 0); + assert_eq!(endpoint.url(), format!("http://{addr}/mcp")); + assert_eq!(endpoint.bearer_token().len(), 64); + let stream = tokio::net::TcpStream::connect(addr) + .await + .expect("listener accepts while active"); + drop(stream); + endpoint.close().await.expect("close private endpoint"); + assert_eq!(gate.cancellations.load(Ordering::SeqCst), 0); + assert!(tokio::net::TcpStream::connect(addr).await.is_err()); + } + + #[tokio::test] + async fn dropping_ephemeral_endpoint_cancels_gate_and_aborts_listener() { + let registry = Arc::new(RwLock::new(PluginRegistry::with_builtins())); + let dispatcher = Arc::new(Dispatcher::new( + Arc::new(TestHandle::new()), + registry.clone(), + )); + let gate = Arc::new(CountingGate::new(true)); + let endpoint = bind_ephemeral_gated(dispatcher, registry, gate.clone()) + .await + .expect("bind private endpoint"); + let addr = endpoint.addr(); + drop(endpoint); + + assert_eq!(gate.cancellations.load(Ordering::SeqCst), 1); + tokio::time::timeout(std::time::Duration::from_secs(5), async { + loop { + if tokio::net::TcpStream::connect(addr).await.is_err() { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("dropped endpoint listener must terminate"); + } + + #[tokio::test] + async fn stopped_guard_fires_when_its_listener_task_panics() { + let stopped = CancellationToken::new(); + let task_token = stopped.clone(); + let task = tokio::spawn(async move { + let _stopped = CancelTokenOnDrop(task_token); + panic!("simulated private listener panic"); + }); + assert!(task.await.expect_err("task must panic").is_panic()); + assert!(stopped.is_cancelled()); + } + + #[tokio::test] + async fn ephemeral_http_route_dispatches_only_through_the_gate() { + let registry = Arc::new(RwLock::new(PluginRegistry::with_builtins())); + let dispatcher = Arc::new(Dispatcher::new( + Arc::new(TestHandle::new()), + registry.clone(), + )); + let gate = Arc::new(CountingGate::new(true)); + let endpoint = bind_ephemeral_gated(dispatcher, registry, gate.clone()) + .await + .expect("bind gated endpoint"); + let client = reqwest::Client::new(); + let initialize_body = serde_json::json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2025-06-18", + "capabilities": {}, + "clientInfo": { "name": "gated-test", "version": "0" } + } + }); + let missing = client + .post(endpoint.url()) + .header("content-type", "application/json") + .header("accept", "application/json, text/event-stream") + .json(&initialize_body) + .send() + .await + .expect("unauthenticated initialize request"); + assert_eq!(missing.status(), reqwest::StatusCode::UNAUTHORIZED); + let wrong = client + .post(endpoint.url()) + .bearer_auth("0".repeat(64)) + .header("content-type", "application/json") + .header("accept", "application/json, text/event-stream") + .json(&initialize_body) + .send() + .await + .expect("wrong-token initialize request"); + assert_eq!(wrong.status(), reqwest::StatusCode::UNAUTHORIZED); + let initialize = client + .post(endpoint.url()) + .bearer_auth(endpoint.bearer_token()) + .header("content-type", "application/json") + .header("accept", "application/json, text/event-stream") + .json(&initialize_body) + .send() + .await + .expect("initialize request"); + assert!(initialize.status().is_success()); + let session = initialize + .headers() + .get("mcp-session-id") + .expect("stateful session") + .clone(); + + let call = |id| { + serde_json::json!({ + "jsonrpc": "2.0", + "id": id, + "method": "tools/call", + "params": { "name": "get_timeline", "arguments": {} } + }) + }; + let allowed = client + .post(endpoint.url()) + .bearer_auth(endpoint.bearer_token()) + .header("content-type", "application/json") + .header("accept", "application/json, text/event-stream") + .header("mcp-session-id", session.clone()) + .header("mcp-protocol-version", "2025-06-18") + .json(&call(2)) + .send() + .await + .expect("allowed tool call"); + assert!(allowed.status().is_success()); + assert!(allowed.text().await.unwrap().contains("gated")); + assert_eq!(gate.dispatches.load(Ordering::SeqCst), 1); + + gate.allow.store(false, Ordering::SeqCst); + let denied = client + .post(endpoint.url()) + .bearer_auth(endpoint.bearer_token()) + .header("content-type", "application/json") + .header("accept", "application/json, text/event-stream") + .header("mcp-session-id", session) + .header("mcp-protocol-version", "2025-06-18") + .json(&call(3)) + .send() + .await + .expect("denied tool call"); + assert!(denied.status().is_success()); + assert!(denied + .text() + .await + .unwrap() + .contains("OPENTAKE_TURN_CANCELLED")); + assert_eq!(gate.dispatches.load(Ordering::SeqCst), 2); + endpoint.close().await.expect("close gated endpoint"); + } + + #[tokio::test] + async fn ephemeral_bearer_token_is_unique_and_expires_with_its_turn() { + let registry = Arc::new(RwLock::new(PluginRegistry::with_builtins())); + let dispatcher = Arc::new(Dispatcher::new( + Arc::new(TestHandle::new()), + registry.clone(), + )); + let first = bind_ephemeral_gated( + dispatcher.clone(), + registry.clone(), + Arc::new(CountingGate::new(true)), + ) + .await + .expect("bind first endpoint"); + let expired = first.bearer_token().to_owned(); + first.close().await.expect("close first endpoint"); + + let second = bind_ephemeral_gated(dispatcher, registry, Arc::new(CountingGate::new(true))) + .await + .expect("bind second endpoint"); + assert_ne!(expired, second.bearer_token()); + let response = reqwest::Client::new() + .post(second.url()) + .bearer_auth(expired) + .header("content-type", "application/json") + .header("accept", "application/json, text/event-stream") + .json(&serde_json::json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2025-06-18", + "capabilities": {}, + "clientInfo": { "name": "expired-test", "version": "0" } + } + })) + .send() + .await + .expect("send expired credential"); + assert_eq!(response.status(), reqwest::StatusCode::UNAUTHORIZED); + second.close().await.expect("close second endpoint"); + } + #[test] fn host_guard_accepts_local_rejects_remote() { assert!(host_is_local("127.0.0.1:19789", MCP_PORT)); diff --git a/crates/opentake-agent/src/mcp/vision.rs b/crates/opentake-agent/src/mcp/vision.rs new file mode 100644 index 00000000..aea9c06e --- /dev/null +++ b/crates/opentake-agent/src/mcp/vision.rs @@ -0,0 +1,20 @@ +//! Host boundary for capability-gated vision analysis (subject-aware +//! reframing). +//! +//! The agent owns the `smart_reframe` schema and discovery contract; the +//! desktop host would own frame sampling and saliency/subject analysis. No +//! host implements this backend yet, so `smart_reframe` stays schema-known but +//! is never advertised: discovery remains fail-closed until a production +//! backend exists. A future host attaches a [`VisionBridge`] through +//! `Dispatcher::with_vision_bridge` (the setter keeps every existing bridge +//! constructor source-compatible). + +/// Host capability seam for subject-aware reframing. Discovery appends the +/// vision tool set only while an injected bridge reports a usable backend; +/// hosts without one get a fail-closed "vision analysis backend is not +/// available" result instead of a placeholder advertisement. +pub trait VisionBridge: Send + Sync { + /// True only when the host can sample frames and run subject/saliency + /// analysis today. `smart_reframe` is discovered only while this holds. + fn can_reframe(&self) -> bool; +} diff --git a/crates/opentake-agent/src/prompt/base.rs b/crates/opentake-agent/src/prompt/base.rs index a7df9837..58d2ee2b 100644 --- a/crates/opentake-agent/src/prompt/base.rs +++ b/crates/opentake-agent/src/prompt/base.rs @@ -5,6 +5,8 @@ //! sentences (frame math, the short-id "pass back verbatim" rule, the //! transcript-driven warning, the calm HIG voice) are kept VERBATIM. +use crate::tools::names::ToolName; + /// Section: who you are + the timeline model. Keeps the short-id contract /// sentence verbatim — without it the short-id system (`tools::short_id`) breaks. pub const CORE_MODEL: &str = "You are a creative AI assistant connected to OpenTake, an AI-native video editor. Help the user build and edit their project by calling the tools this server exposes.\n\n# Core model\n- The timeline has a fixed fps and resolution. All timing is in FRAMES, not seconds: frame = seconds × fps.\n- Tracks are ordered and typed (video or audio). Video clips, images, and text overlays all live on video tracks.\n- A clip references a media asset and occupies [startFrame, startFrame + durationFrames) on its track.\n- Clips have trimStartFrame / trimEndFrame (source-media offsets, not timeline offsets), speed, volume, and opacity.\n- Media assets live in a project library and are referenced by ID. They may be user-imported or AI-generated.\n- IDs (clipId, mediaRef, folderId, captionGroupId) are returned as short prefixes. Pass them back exactly as given — never pad, complete, or guess a longer form."; @@ -12,6 +14,11 @@ pub const CORE_MODEL: &str = "You are a creative AI assistant connected to OpenT /// Section: the always-do checklist (read-before-edit, model gating). pub const ALWAYS_DO: &str = "# Always do\n- Call get_timeline once per session (or after an out-of-band change) for fps, tracks, and existing clip frames. Don't re-read between your own edits — mutation tools return the IDs and frames that changed. Re-read only after a failure that suggests your model is stale. Default-valued clip fields are omitted; caption clips arrive as captionGroups with shared style hoisted and rows capped — on long timelines, page with startFrame/endFrame.\n- Call get_media before referencing any asset — every mediaRef comes from there.\n- Call list_models before generate_video, generate_image, generate_audio, or upscale_media so the model you pick supports the duration, aspect ratio, references, voice, or asset type you need.\n- get_timeline returns canGenerate. If false, every generation and upscale tool will fail — tell the user to sign in to OpenTake and subscribe before proposing them. (inspect_media transcription runs on-device and is unaffected.)\n- Before describing any user-supplied asset (referenceMediaRefs, startFrameMediaRef, etc.), call inspect_media and describe what you actually see — never paraphrase the filename. On long media, work coarse to fine: overview=true for a storyboard image, read the transcript segments, then zoom into a window with startSeconds/endSeconds for full frames. Plan splits, trims, and captions from segment timestamps; wordTimestamps=true on a narrow window for exact word boundaries.\n- To find a moment across the library (\"the sunset shot\", \"where she mentions the budget\"), call search_media before inspecting files one by one — describe what's on screen or quote the words said. Hits are source-second ranges ready to convert into add_clips trims."; +/// Always-do checklist when paid generation is deliberately absent from the +/// advertised tool catalog. The prompt must never teach the model to call a +/// capability that discovery does not expose. +pub const ALWAYS_DO_WITHOUT_GENERATION: &str = "# Always do\n- Call get_timeline once per session (or after an out-of-band change) for fps, tracks, and existing clip frames. Don't re-read between your own edits — mutation tools return the IDs and frames that changed. Re-read only after a failure that suggests your model is stale. Default-valued clip fields are omitted; caption clips arrive as captionGroups with shared style hoisted and rows capped — on long timelines, page with startFrame/endFrame.\n- Call get_media before referencing any asset — every mediaRef comes from there.\n- Before describing any user-supplied asset, call inspect_media and describe what you actually see — never paraphrase the filename. On long media, work coarse to fine: overview=true for a storyboard image, read the transcript segments, then zoom into a window with startSeconds/endSeconds for full frames. Plan splits, trims, and captions from segment timestamps; wordTimestamps=true on a narrow window for exact word boundaries.\n- To find a moment across the library (\"the sunset shot\", \"where she mentions the budget\"), call search_media before inspecting files one by one — describe what's on screen or quote the words said. Hits are source-second ranges ready to convert into add_clips trims."; + /// Section: editing surface + the transcript-driven warning (kept verbatim). pub const EDITING: &str = "# Editing\n- Placements must match track type: video on video tracks, audio on audio tracks.\n- The clip-editing surface mirrors human gestures — one tool per gesture, applied to a selection:\n • move_clips: change track and/or startFrame. Linked partners follow the frame delta; track changes don't propagate.\n • set_clip_properties: apply the same values (durationFrames, trim, speed, volume, opacity, transform, reversed, or text-style fields) to one or more clipIds. For per-clip differences, make separate calls. Setting volume or opacity here clears any existing keyframes on that property.\n • set_keyframes: replace the keyframe track for one (clipId, property) pair. Empty array clears. Frames are clip-relative.\n • split_clip: atFrame must be strictly inside the clip.\n- speed 1.0 is normal; <1.0 stretches the clip longer on the timeline; >1.0 shortens it. trim* values are source offsets, not timeline offsets. reversed=true plays a video clip backward through the same trimmed source window.\n- Edits are undoable and effectively free. Don't ask permission for individual edits — just explain what you changed.\n- Transcript-driven cuts (filler, dead air, duplicate/retake removal): read the WORD-level get_transcript end-to-end as prose at least once before deduping. The segments view and the ripple_delete diff are lossy — they hide reworded retakes (\"in one state\" vs \"in one place\") and sub-frame seam fragments (a word whose start == end rounds to zero frames). Verify a suspected dangling fragment against the words, not the summary."; @@ -33,24 +40,34 @@ pub const COMMUNICATION: &str = "# Communication\n- Default to one or two senten /// The model-strategy placeholder token replaced at assembly time. pub const MODEL_STRATEGY_TOKEN: &str = "{MODEL_STRATEGY}"; +fn generation_tools_are_advertised() -> bool { + [ + ToolName::GenerateVideo, + ToolName::GenerateImage, + ToolName::GenerateAudio, + ToolName::UpscaleMedia, + ] + .iter() + .all(|tool| ToolName::ALL.contains(tool)) +} + /// All sections in order, joined into the base prompt. `model_strategy` fills /// the generation placeholder (empty string drops the token cleanly). pub fn base_prompt(model_strategy: &str) -> String { - let generation = if model_strategy.is_empty() { - GENERATION.replace(MODEL_STRATEGY_TOKEN, "") + let mut sections = vec![CORE_MODEL.to_owned()]; + if generation_tools_are_advertised() { + sections.push(ALWAYS_DO.to_owned()); + let generation = GENERATION.replace(MODEL_STRATEGY_TOKEN, model_strategy); + sections.push(EDITING.to_owned()); + sections.push(generation); + sections.push(AUDIO_GENERATION.to_owned()); + sections.push(PROMPT_CRAFT.to_owned()); } else { - GENERATION.replace(MODEL_STRATEGY_TOKEN, model_strategy) - }; - [ - CORE_MODEL, - ALWAYS_DO, - EDITING, - &generation, - AUDIO_GENERATION, - PROMPT_CRAFT, - COMMUNICATION, - ] - .join("\n\n") + sections.push(ALWAYS_DO_WITHOUT_GENERATION.to_owned()); + sections.push(EDITING.to_owned()); + } + sections.push(COMMUNICATION.to_owned()); + sections.join("\n\n") } #[cfg(test)] @@ -92,14 +109,32 @@ mod tests { } #[test] - fn model_strategy_token_replaced() { + fn model_strategy_is_ignored_while_generation_is_hidden() { let with = base_prompt("Use Model X for video."); - assert!(with.contains("Use Model X for video.")); + assert!(!with.contains("Use Model X for video.")); assert!(!with.contains(MODEL_STRATEGY_TOKEN)); let without = base_prompt(""); assert!(!without.contains(MODEL_STRATEGY_TOKEN)); } + #[test] + fn prompt_does_not_teach_hidden_tools() { + let prompt = base_prompt("default"); + for hidden in [ + "generate_video", + "generate_image", + "generate_audio", + "upscale_media", + "add_motion_graphic", + "edit_motion_graphic", + ] { + assert!( + !prompt.contains(hidden), + "prompt advertises hidden {hidden}" + ); + } + } + #[test] fn signin_uses_opentake() { assert!(ALWAYS_DO.contains("sign in to OpenTake and subscribe")); diff --git a/crates/opentake-agent/src/tools/args.rs b/crates/opentake-agent/src/tools/args.rs index 1a40d730..25634c68 100644 --- a/crates/opentake-agent/src/tools/args.rs +++ b/crates/opentake-agent/src/tools/args.rs @@ -6,6 +6,7 @@ //! entry keys). use serde::Deserialize; +use serde_json::Value; use crate::tools::errors::ToolArgs; @@ -631,6 +632,7 @@ pub struct AutoCutToBeatsArgs { pub min_clip_frames: Option, pub max_clip_frames: Option, pub align_cuts: Option, + pub write: Option, } impl ToolArgs for AutoCutToBeatsArgs { const ALLOWED_KEYS: &'static [&'static str] = &[ @@ -642,6 +644,7 @@ impl ToolArgs for AutoCutToBeatsArgs { "minClipFrames", "maxClipFrames", "alignCuts", + "write", ]; } @@ -678,10 +681,231 @@ impl ToolArgs for TightenSilencesArgs { ]; } +// --- remove_filler_words --- +#[derive(Debug, Clone, Default, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct RemoveFillerWordsArgs { + pub clip_ids: Option>, + pub track_index: Option, + pub filler_words: Option>, + pub padding_frames: Option, +} +impl ToolArgs for RemoveFillerWordsArgs { + const ALLOWED_KEYS: &'static [&'static str] = + &["clipIds", "trackIndex", "fillerWords", "paddingFrames"]; +} + +// --- capability-gated advanced AI workflows --- +#[derive(Debug, Clone, Default, Deserialize, PartialEq)] +pub struct MotionRegionArg { + pub x: f64, + pub y: f64, + pub width: f64, + pub height: f64, +} +impl ToolArgs for MotionRegionArg { + const ALLOWED_KEYS: &'static [&'static str] = &["x", "y", "width", "height"]; +} + +#[derive(Debug, Clone, Default, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct TrackMotionArgs { + pub clip_id: String, + pub region: Value, + pub start_frame: Option, + pub end_frame: Option, + pub apply: Option, +} +impl ToolArgs for TrackMotionArgs { + const ALLOWED_KEYS: &'static [&'static str] = + &["clipId", "region", "startFrame", "endFrame", "apply"]; +} + +#[derive(Debug, Clone, Default, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct GenerateMatteArgs { + pub clip_id: String, + pub model: Option, + pub start_frame: Option, + pub end_frame: Option, + pub apply: Option, +} +impl ToolArgs for GenerateMatteArgs { + const ALLOWED_KEYS: &'static [&'static str] = + &["clipId", "model", "startFrame", "endFrame", "apply"]; +} + +#[derive(Debug, Clone, Default, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct RemoveObjectArgs { + pub clip_id: String, + pub mask_id: String, + pub start_frame: Option, + pub end_frame: Option, + pub provider: Option, + pub model: Option, + pub cost_authorized: Option, + pub apply: Option, +} +impl ToolArgs for RemoveObjectArgs { + const ALLOWED_KEYS: &'static [&'static str] = &[ + "clipId", + "maskId", + "startFrame", + "endFrame", + "provider", + "model", + "costAuthorized", + "apply", + ]; +} + +#[derive(Debug, Clone, Default, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct MatchColorArgs { + pub clip_id: String, + pub reference_media_ref: String, + pub reference_frame: Option, + pub target_frame: Option, + pub apply: Option, +} +impl ToolArgs for MatchColorArgs { + const ALLOWED_KEYS: &'static [&'static str] = &[ + "clipId", + "referenceMediaRef", + "referenceFrame", + "targetFrame", + "apply", + ]; +} + +#[derive(Debug, Clone, Default, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct SeparateStemsArgs { + pub media_ref: String, + pub provider: Option, + pub model: Option, + pub import_to_tracks: Option, + pub start_frame: Option, +} +impl ToolArgs for SeparateStemsArgs { + const ALLOWED_KEYS: &'static [&'static str] = &[ + "mediaRef", + "provider", + "model", + "importToTracks", + "startFrame", + ]; +} + +#[derive(Debug, Clone, Default, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct TranslateCaptionsArgs { + pub caption_clip_ids: Vec, + pub source_locale: Option, + pub target_locale: String, + pub provider: Option, + pub model: Option, + pub cost_authorized: Option, + pub apply: Option, +} +impl ToolArgs for TranslateCaptionsArgs { + const ALLOWED_KEYS: &'static [&'static str] = &[ + "captionClipIds", + "sourceLocale", + "targetLocale", + "provider", + "model", + "costAuthorized", + "apply", + ]; +} + +#[derive(Debug, Clone, Default, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct ScriptToVideoArgs { + pub segments: Vec, + pub apply: Option, +} +impl ToolArgs for ScriptToVideoArgs { + const ALLOWED_KEYS: &'static [&'static str] = &["segments", "apply"]; +} + +#[derive(Debug, Clone, Default, Deserialize, PartialEq)] +#[serde(deny_unknown_fields)] +#[serde(rename_all = "camelCase")] +pub struct ScriptSegmentArg { + pub script: String, + pub media_ref: String, + pub narration_media_ref: Option, + pub duration_frames: i32, + pub transition: Option, +} +impl ToolArgs for ScriptSegmentArg { + const ALLOWED_KEYS: &'static [&'static str] = &[ + "script", + "mediaRef", + "narrationMediaRef", + "durationFrames", + "transition", + ]; +} + +#[derive(Debug, Clone, Default, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct GenerateAvatarArgs { + pub portrait_media_ref: String, + pub audio_media_ref: String, + pub consent_id: String, + pub provider: Option, + pub model: Option, + pub cost_authorized: Option, + pub start_frame: Option, +} +impl ToolArgs for GenerateAvatarArgs { + const ALLOWED_KEYS: &'static [&'static str] = &[ + "portraitMediaRef", + "audioMediaRef", + "consentId", + "provider", + "model", + "costAuthorized", + "startFrame", + ]; +} + +#[derive(Debug, Clone, Default, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct CloneVoiceArgs { + pub action: String, + pub reference_audio_media_ref: Option, + pub consent_id: String, + pub voice_id: Option, + pub voice_name: Option, + pub prompt: Option, + pub provider: Option, + pub model: Option, + pub cost_authorized: Option, +} +impl ToolArgs for CloneVoiceArgs { + const ALLOWED_KEYS: &'static [&'static str] = &[ + "action", + "referenceAudioMediaRef", + "consentId", + "voiceId", + "voiceName", + "prompt", + "provider", + "model", + "costAuthorized", + ]; +} + // --- generate_video --- #[derive(Debug, Clone, Default, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct GenerateVideoArgs { + pub cost_authorized: Option, pub prompt: String, pub name: Option, pub model: Option, @@ -700,6 +924,7 @@ pub struct GenerateVideoArgs { impl ToolArgs for GenerateVideoArgs { const ALLOWED_KEYS: &'static [&'static str] = &[ "prompt", + "costAuthorized", "name", "model", "duration", @@ -720,23 +945,27 @@ impl ToolArgs for GenerateVideoArgs { #[derive(Debug, Clone, Default, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct GenerateImageArgs { + pub cost_authorized: Option, pub prompt: String, pub name: Option, pub model: Option, pub aspect_ratio: Option, pub resolution: Option, pub quality: Option, + pub num_images: Option, pub reference_media_refs: Option>, pub folder_id: Option, } impl ToolArgs for GenerateImageArgs { const ALLOWED_KEYS: &'static [&'static str] = &[ "prompt", + "costAuthorized", "name", "model", "aspectRatio", "resolution", "quality", + "numImages", "referenceMediaRefs", "folderId", ]; @@ -746,6 +975,7 @@ impl ToolArgs for GenerateImageArgs { #[derive(Debug, Clone, Default, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct GenerateAudioArgs { + pub cost_authorized: Option, pub prompt: Option, pub name: Option, pub model: Option, @@ -762,6 +992,7 @@ pub struct GenerateAudioArgs { impl ToolArgs for GenerateAudioArgs { const ALLOWED_KEYS: &'static [&'static str] = &[ "prompt", + "costAuthorized", "name", "model", "voice", @@ -780,12 +1011,14 @@ impl ToolArgs for GenerateAudioArgs { #[derive(Debug, Clone, Default, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct UpscaleMediaArgs { + pub cost_authorized: Option, pub media_ref: String, pub model: Option, pub source_clip_id: Option, } impl ToolArgs for UpscaleMediaArgs { - const ALLOWED_KEYS: &'static [&'static str] = &["mediaRef", "model", "sourceClipId"]; + const ALLOWED_KEYS: &'static [&'static str] = + &["costAuthorized", "mediaRef", "model", "sourceClipId"]; } // --- import_media --- @@ -1175,13 +1408,13 @@ mod tests { fn apply_effect_decodes_with_params() { let v = serde_json::json!({ "clipIds": ["a"], - "effects": [{"name": "gaussianBlur", "params": {"radius": 4.0}}] + "effects": [{"name": "grayscale", "params": {"amount": 0.4}}] }); let a: ApplyEffectArgs = decode_tool_args(&v, "").unwrap(); assert_eq!(a.effects.len(), 1); let e: EffectArg = decode_tool_args(&a.effects[0], "effects[0]").unwrap(); - assert_eq!(e.name, "gaussianBlur"); - assert_eq!(e.params.unwrap().get("radius"), Some(&4.0)); + assert_eq!(e.name, "grayscale"); + assert_eq!(e.params.unwrap().get("amount"), Some(&0.4)); } #[test] diff --git a/crates/opentake-agent/src/tools/descriptions.rs b/crates/opentake-agent/src/tools/descriptions.rs index 3a5e1a5a..df810189 100644 --- a/crates/opentake-agent/src/tools/descriptions.rs +++ b/crates/opentake-agent/src/tools/descriptions.rs @@ -55,12 +55,14 @@ pub fn description(tool: ToolName) -> &'static str { ToolName::DetectBeats => "Detects musical beat positions for a clip or media asset using lightweight PCM energy/onset analysis. Returns project-frame beat hints and strengths; it does not mutate the timeline.", - ToolName::AutoCutToBeats => "Plans beat-synced cuts for one or more clips against an audio or music source. Returns beat frames, suggested cut frames, and optional clip placement hints; it does not mutate the timeline. Apply the plan with existing edit tools.", + ToolName::AutoCutToBeats => "Plans beat-synced alignment for visual clips against an audio or music source. The default write=false returns beat frames, suggested cut frames, and clip placement hints without mutation. Set write=true to align the selected visual clips and their linked A/V partners through one atomic MoveClips command.", ToolName::SmartReframe => "Plans subject-aware reframing for target aspect ratios such as 9:16 or 1:1. The typed surface is present, but MCP frame sampling / vision analysis is not wired yet; calls return a deterministic needs-vision-backend error and do not mutate the timeline.", ToolName::TightenSilences => "Plans silence tightening by finding low-energy PCM spans and converting them into ripple_delete_ranges candidate commands. Returns a preview only; it does not mutate the timeline.", + ToolName::RemoveFillerWords => "Transcribes the current spoken timeline and returns reviewable filler-word cuts aligned to word timestamps. Supports an exact configurable lexicon including multi-word phrases. It does not mutate the timeline: remove rejected cuts, then call each returned ripple_delete_ranges command to apply the accepted ranges as one undoable edit per track.", + ToolName::GenerateVideo => "Starts an async AI video generation. Returns a placeholder asset ID immediately; generation runs in the background and the asset becomes usable in add_clips once ready. Costs real money and is not undoable.", ToolName::GenerateImage => "Starts an async AI image generation. Returns a placeholder asset ID immediately; generation runs in the background. Costs real money and is not undoable.", @@ -101,12 +103,22 @@ pub fn description(tool: ToolName) -> &'static str { ToolName::SetMask => "Sets the vector mask(s) on one or more clips in one undoable action — the masks generate a per-pixel alpha that hides everything outside them (intersection of all masks). Each mask is one of: a linear/gradient split (a line through a point with a normal), a circle/ellipse (center + per-axis radius), or a polygon/pen shape (a list of points). feather softens the edge in normalized canvas units; invert flips inside/outside. Coordinates are 0–1 normalized canvas space. Pass an empty masks array to clear all masks. Applies to every clip in clipIds.", - ToolName::ApplyEffect => "Sets the effect chain on one or more clips in one undoable action — an ordered list of named pixel effects, each a shader pass with named numeric parameters. Each effect is { name, params } where name selects the effect (e.g. 'gaussianBlur') and params are its scalar inputs (e.g. { radius: 4 }); pass enabled:false to keep a disabled effect in the chain. The list replaces the clip's current effects; pass an empty array to clear them. Applies to every clip in clipIds.", + ToolName::ApplyEffect => "Sets the effect chain on one or more clips in one undoable action. The closed effect registry is grayscale, sepia, and invert; each accepts an optional amount from 0 to 1 (default 1). Effects execute in list order in the shared preview/export GPU compositor. Pass enabled:false to retain a disabled effect. The list replaces the current chain; pass an empty array to clear it. Unknown names, parameters, non-finite values, and out-of-range values are rejected instead of rendering unchanged. Applies to every clip in clipIds.", + + // --- OpenTake deterministic motion graphics (Issue #34 fallback vertical) --- + ToolName::AddMotionGraphic => "Renders a deterministic motion graphic to MP4, imports it, and places it on the timeline as one durable undoable workflow. Returns the new clipId. The packaged Beta uses the pinned Motion Canvas 3.17.2 runner for 'title-card'; it also supports the local 'lower-third.glass' template and self-contained HTML/CSS/JS fallback (animated through OpenTake.onSeek). Raw TypeScript/TSX and transparent output are reported as unsupported instead of being accepted as placeholders.\n\nstartFrame/durationFrames are project frames (from get_timeline). trackIndex is optional — omit to auto-create a new visual track; set it to target an existing non-audio track.", - // --- OpenTake Motion Canvas graphics (docs/MOTION-GRAPHICS-PLUGIN.md, Issue #34) --- - ToolName::AddMotionGraphic => "Adds a Motion Canvas-generated animation/video segment (animated title, explainer card, data callout, timeline insert, or transition card) to the timeline as a single undoable workflow, and returns its clipId. In v1, OpenTake asks the Motion Canvas plugin to render a materialized .mp4, imports that output as a normal media asset, then places it on the timeline. Preview and export therefore reuse the ordinary video pipeline.\n\nThe 'source' object is exactly one of:\n • { code: \"\" } — a self-contained Motion Canvas scene/project snippet. Prefer deterministic frame-driven animation, not wall-clock timers.\n • { templateId, params } — instantiate a registered Motion Canvas template by id with typed params (string/number/bool/color; colors are hex '#RRGGBB'/'#RRGGBBAA'). The template declares which params it accepts.\n\nstartFrame/durationFrames are project frames (from get_timeline). trackIndex is optional — omit to auto-create a new video track at the top for the generated segment; set it to target an existing non-audio track. transparent is accepted for forward compatibility, but v1 mp4 materialization is opaque; transparent overlays are a later PNG-sequence/native-motion path.", + ToolName::EditMotionGraphic => "Re-renders an existing OpenTake motion graphic as one durable undoable workflow while preserving its timeline clipId and placement. Pass the clipId and either replacement self-contained HTML/CSS/JS for a code-authored graphic or parameter overrides for a template-authored graphic. Ordinary video clips and unsupported source types are rejected with typed errors.", - ToolName::EditMotionGraphic => "Edits an existing Motion Canvas-generated clip and re-renders it as a single undoable workflow. Pass the clipId (from add_motion_graphic or get_timeline) and at least one of:\n • code — replace the Motion Canvas TS/TSX source of a code-authored graphic.\n • params — override template params (merged over the current bindings) of a template-authored graphic.\n\nThe clip must carry Motion Canvas metadata from add_motion_graphic; ordinary video clips are rejected. Re-rendering should update or replace the generated media asset and keep the timeline placement stable so later agent steps can keep using the same clip context.", + ToolName::TrackMotion => "Analyzes a bounded source region and returns editable position keyframes that follow the subject. Defaults to preview-only; set apply=true only after reviewing confidence and samples. Applying is one undoable edit. The tool is advertised only when a production tracking backend is available.", + ToolName::GenerateMatte => "Generates a frame-aligned reusable alpha matte for one clip without modifying the source asset. Defaults to preview-only and reports model/version/progress metadata. Applying the matte is one undoable edit. The tool is advertised only when an installed compatible model is available.", + ToolName::RemoveObject => "Produces a non-destructive derivative for the selected mask and frame range. Defaults to preview-only; provider costs require costAuthorized=true. Apply imports and swaps the reviewed derivative as one undoable workflow. Cancellation or failure leaves media and timeline unchanged.", + ToolName::MatchColor => "Analyzes a target clip and reference frame, then returns an editable ColorGrade plus deterministic comparison metrics. Defaults to preview-only; apply=true accepts the grade in one undoable edit. Source media and the previous grade remain recoverable.", + ToolName::SeparateStems => "Separates an audio-bearing asset into aligned vocals and accompaniment derivatives with source/model provenance. Optionally imports both stems to synchronized tracks in one undoable workflow. Cancellation or failure adds no media or tracks.", + ToolName::TranslateCaptions => "Translates selected caption clips while preserving every clip id and frame range. Defaults to a reviewable per-caption diff; apply=true accepts only the returned changes as one undoable edit. Provider costs require costAuthorized=true.", + ToolName::ScriptToVideo => "Builds and validates a persisted, reviewable multi-segment assembly plan from exact media and narration references. Defaults to planning only; apply=true places the reviewed segments and transitions through existing edit commands as one undoable workflow.", + ToolName::GenerateAvatar => "Generates a lip-synchronized avatar video from a portrait and narration through a configured provider. Requires explicit recorded consent and costAuthorized=true. Success imports the result; cancellation or failure imports nothing.", + ToolName::CloneVoice => "Enrolls, uses, or revokes a provider voice model. Every action requires a recorded consent id; paid enrollment/generation requires costAuthorized=true. Raw credentials and reference-audio bytes are never persisted in project metadata, and revoked voices cannot generate.", } } @@ -374,7 +386,8 @@ pub fn input_schema(tool: ToolName) -> Value { "endFrame": {"type": "integer", "description": "Optional project-frame window end (exclusive)."}, "minClipFrames": {"type": "integer", "description": "Optional lower bound for generated cut lengths."}, "maxClipFrames": {"type": "integer", "description": "Optional upper bound for generated cut lengths."}, - "alignCuts": {"type": "boolean", "description": "Optional. true means move/split cuts to the detected beat grid."} + "alignCuts": {"type": "boolean", "description": "Optional. true means align proposed cuts to the detected beat grid."}, + "write": {"type": "boolean", "description": "Optional, default false. true applies all selected clip placements and linked A/V partners in one atomic command."} }), &[], ), @@ -400,8 +413,19 @@ pub fn input_schema(tool: ToolName) -> Value { &[], ), + ToolName::RemoveFillerWords => object( + json!({ + "clipIds": {"type": "array", "items": {"type": "string"}, "description": "Optional spoken clip ids to transcribe and analyze."}, + "trackIndex": {"type": "integer", "description": "Optional spoken track index to analyze. Mutually exclusive with clipIds."}, + "fillerWords": {"type": "array", "items": {"type": "string"}, "description": "Optional exact filler lexicon. Multi-word phrases such as 'you know' are supported."}, + "paddingFrames": {"type": "integer", "minimum": 0, "description": "Optional context frames to preserve before and after each matched filler phrase."} + }), + &[], + ), + ToolName::GenerateVideo => object( json!({ + "costAuthorized": {"type": "boolean", "description": "Required true. Set only after the user explicitly approves the paid generation request in this conversation."}, "prompt": {"type": "string", "description": "Text description of the video to generate"}, "name": {"type": "string", "description": "Display name for the asset in the media library. Defaults to first 30 chars of prompt."}, "model": {"type": "string", "description": "Model ID (e.g. 'veo3.1-fast'). Use list_models to see options. Defaults to first available model."}, @@ -417,25 +441,28 @@ pub fn input_schema(tool: ToolName) -> Value { "referenceAudioMediaRefs": {"type": "array", "items": {"type": "string"}, "description": "Media asset IDs of audio references (Seedance only). Refer to them as @Audio1, @Audio2. See maxReferenceAudios and maxCombinedAudioRefSeconds."}, "folderId": {"type": "string", "description": "Optional. Folder id (from list_folders or create_folder) to place the result in. Omit for the project root."} }), - &["prompt"], + &["costAuthorized", "prompt"], ), ToolName::GenerateImage => object( json!({ + "costAuthorized": {"type": "boolean", "description": "Required true. Set only after the user explicitly approves the paid generation request in this conversation."}, "prompt": {"type": "string", "description": "Text description of the image to generate"}, "name": {"type": "string", "description": "Display name for the asset in the media library. Defaults to first 30 chars of prompt."}, "model": {"type": "string", "description": "Model ID (e.g. 'nano-banana-pro'). Use list_models to see options. Defaults to first available model."}, "aspectRatio": {"type": "string", "description": "Aspect ratio (e.g. '16:9', '9:16')"}, "resolution": {"type": "string", "description": "Resolution (e.g. '2K', '4K')"}, "quality": {"type": "string", "description": "Image quality (e.g. 'low', 'medium', 'high'). Only supported by some models — see list_models."}, + "numImages": {"type": "integer", "minimum": 1, "maximum": 4, "description": "Number of ordered image results to generate. Defaults to 1 and is capped at 4."}, "referenceMediaRefs": {"type": "array", "items": {"type": "string"}, "description": "Media asset IDs to use as reference images"}, "folderId": {"type": "string", "description": "Optional. Folder id (from list_folders or create_folder) to place the result in. Omit for the project root."} }), - &["prompt"], + &["costAuthorized", "prompt"], ), ToolName::GenerateAudio => object( json!({ + "costAuthorized": {"type": "boolean", "description": "Required true. Set only after the user explicitly approves the paid generation request in this conversation."}, "prompt": {"type": "string", "description": "Required for TTS (the text to speak) and text-to-music (style/mood/genre; MiniMax needs ≥10 chars). For Lyria 3 Pro, include lyrics, tempo, language, and vocal style directly in the prompt. Optional style guide for video-to-music models."}, "name": {"type": "string", "description": "Display name for the asset in the media library. Defaults to first 30 chars of prompt."}, "model": {"type": "string", "description": "Model ID. Use list_models with type='audio' to see options and their 'inputs'. Defaults to the first model."}, @@ -449,16 +476,17 @@ pub fn input_schema(tool: ToolName) -> Value { "videoSourceMediaRef": {"type": "string", "description": "Video-to-audio models only. Score this existing video asset instead of a timeline span. Mutually exclusive with the videoSource frames."}, "folderId": {"type": "string", "description": "Optional. Folder id (from list_folders or create_folder) to place the result in. Omit for the project root."} }), - &[], + &["costAuthorized"], ), ToolName::UpscaleMedia => object( json!({ + "costAuthorized": {"type": "boolean", "description": "Required true. Set only after the user explicitly approves the paid upscale request in this conversation."}, "mediaRef": {"type": "string", "description": "ID of the video or image asset to upscale"}, "model": {"type": "string", "description": "Upscaler model ID (e.g. 'bytedance-upscaler', 'seedvr-image-upscaler'). Defaults to the first model that supports the asset's type."}, "sourceClipId": {"type": "string", "description": "Optional. Video clip id (from get_timeline) referencing mediaRef. When set and the clip is trimmed, only the clip's visible range is upscaled, not the full source."} }), - &["mediaRef"], + &["costAuthorized", "mediaRef"], ), ToolName::ImportMedia => object( @@ -656,8 +684,8 @@ pub fn input_schema(tool: ToolName) -> Value { "items": { "type": "object", "properties": { - "name": {"type": "string", "description": "Effect identifier, e.g. 'gaussianBlur'."}, - "params": {"type": "object", "description": "Named numeric parameters for the effect, e.g. { \"radius\": 4 }.", "additionalProperties": {"type": "number"}}, + "name": {"type": "string", "enum": ["grayscale", "sepia", "invert"], "description": "Identifier from the closed rendered effect registry."}, + "params": {"type": "object", "description": "Optional effect strength; defaults to 1.", "properties": {"amount": {"type": "number", "minimum": 0, "maximum": 1}}, "additionalProperties": false}, "enabled": {"type": "boolean", "description": "Whether the effect is active (default true)."} }, "required": ["name"] @@ -672,16 +700,16 @@ pub fn input_schema(tool: ToolName) -> Value { json!({ "source": { "type": "object", - "description": "Exactly one of code or templateId must be set. code is Motion Canvas TS/TSX scene/project source; templateId instantiates a registered Motion Canvas template with params.", + "description": "Exactly one of code or templateId must be set. code is self-contained HTML/CSS/JS using OpenTake.onSeek; templateId selects a registered local template.", "properties": { - "code": {"type": "string", "description": "Motion Canvas TypeScript/TSX scene or project source. Prefer deterministic frame-driven animation, not wall-clock timers."}, - "templateId": {"type": "string", "description": "Registered Motion Canvas template id (e.g. 'lower-third.glass'). Mutually exclusive with code."}, + "code": {"type": "string", "description": "Self-contained HTML/CSS/JS document. Animate deterministically with OpenTake.onSeek; raw TS/TSX is not supported by this Beta renderer."}, + "templateId": {"type": "string", "enum": ["title-card", "lower-third.glass"], "description": "Registered local motion template. Mutually exclusive with code."}, "params": {"type": "object", "description": "Template params: name -> value. Values are string, number, bool, or a hex color string '#RRGGBB'/'#RRGGBBAA'. Only valid with templateId.", "additionalProperties": {"oneOf": [{"type": "string"}, {"type": "number"}, {"type": "boolean"}]}} } }, "startFrame": {"type": "integer", "description": "Timeline frame position to place the graphic (project frames)."}, "durationFrames": {"type": "integer", "description": "Clip length on the timeline, in project frames (>= 1)."}, - "transparent": {"type": "boolean", "description": "Forward-compatible alpha intent. v1 Motion Canvas mp4 materialization is opaque; transparent overlays are a later PNG-sequence/native-motion path."}, + "transparent": {"type": "boolean", "description": "Forward-compatible alpha intent. The current MP4 path rejects true with a typed unsupported-capability error."}, "trackIndex": {"type": "integer", "description": "Optional. Existing non-audio track index (0-based) to place the graphic on. Omit to auto-create a new video track at the top."} }), &["source", "startFrame", "durationFrames"], @@ -689,12 +717,99 @@ pub fn input_schema(tool: ToolName) -> Value { ToolName::EditMotionGraphic => object( json!({ - "clipId": {"type": "string", "description": "The Motion Canvas-generated clip id to edit (from add_motion_graphic or get_timeline)."}, - "code": {"type": "string", "description": "Replacement Motion Canvas TypeScript/TSX source for a code-authored graphic. Only valid when the clip was authored with code."}, - "params": {"type": "object", "description": "Template param overrides (merged over current bindings) for a template-authored Motion Canvas graphic. Values are string, number, bool, or a hex color string. Only valid when the clip was authored from a template.", "additionalProperties": {"oneOf": [{"type": "string"}, {"type": "number"}, {"type": "boolean"}]}} + "clipId": {"type": "string", "description": "The OpenTake motion clip id to edit (from add_motion_graphic or get_timeline)."}, + "code": {"type": "string", "description": "Replacement self-contained HTML/CSS/JS for a code-authored graphic."}, + "params": {"type": "object", "description": "Template parameter overrides merged over current bindings. Only valid for a template-authored graphic.", "additionalProperties": {"oneOf": [{"type": "string"}, {"type": "number"}, {"type": "boolean"}]}} + }), + &["clipId"], + ), + + ToolName::TrackMotion => object( + json!({ + "clipId": {"type": "string"}, + "region": {"type": "object", "properties": {"x": {"type": "number"}, "y": {"type": "number"}, "width": {"type": "number"}, "height": {"type": "number"}}, "required": ["x", "y", "width", "height"]}, + "startFrame": {"type": "integer"}, "endFrame": {"type": "integer"}, + "apply": {"type": "boolean", "default": false} + }), + &["clipId", "region"], + ), + ToolName::GenerateMatte => object( + json!({ + "clipId": {"type": "string"}, "model": {"type": "string"}, + "startFrame": {"type": "integer"}, "endFrame": {"type": "integer"}, + "apply": {"type": "boolean", "default": false} }), &["clipId"], ), + ToolName::RemoveObject => object( + json!({ + "clipId": {"type": "string"}, "maskId": {"type": "string"}, + "startFrame": {"type": "integer"}, "endFrame": {"type": "integer"}, + "provider": {"type": "string"}, "model": {"type": "string"}, + "costAuthorized": {"type": "boolean"}, "apply": {"type": "boolean", "default": false} + }), + &["clipId", "maskId"], + ), + ToolName::MatchColor => object( + json!({ + "clipId": {"type": "string"}, "referenceMediaRef": {"type": "string"}, + "referenceFrame": {"type": "integer"}, "targetFrame": {"type": "integer"}, + "apply": {"type": "boolean", "default": false} + }), + &["clipId", "referenceMediaRef"], + ), + ToolName::SeparateStems => object( + json!({ + "mediaRef": {"type": "string"}, "provider": {"type": "string"}, + "model": {"type": "string"}, "importToTracks": {"type": "boolean", "default": false}, + "startFrame": {"type": "integer"} + }), + &["mediaRef"], + ), + ToolName::TranslateCaptions => object( + json!({ + "captionClipIds": {"type": "array", "items": {"type": "string"}, "minItems": 1}, + "sourceLocale": {"type": "string"}, "targetLocale": {"type": "string"}, + "provider": {"type": "string"}, "model": {"type": "string"}, + "costAuthorized": {"type": "boolean"}, "apply": {"type": "boolean", "default": false} + }), + &["captionClipIds", "targetLocale"], + ), + ToolName::ScriptToVideo => object( + json!({ + "segments": {"type": "array", "minItems": 1, "items": {"type": "object", "properties": { + "script": {"type": "string"}, "mediaRef": {"type": "string"}, + "narrationMediaRef": {"type": "string"}, "durationFrames": {"type": "integer"}, + "transition": {"type": "string"} + }, "required": ["script", "mediaRef", "durationFrames"]}}, + "apply": {"type": "boolean", "default": false} + }), + &["segments"], + ), + ToolName::GenerateAvatar => object( + json!({ + "portraitMediaRef": {"type": "string"}, "audioMediaRef": {"type": "string"}, + "consentId": {"type": "string"}, "provider": {"type": "string"}, + "model": {"type": "string"}, "costAuthorized": {"type": "boolean"}, + "startFrame": {"type": "integer"} + }), + &[ + "portraitMediaRef", + "audioMediaRef", + "consentId", + "costAuthorized", + ], + ), + ToolName::CloneVoice => object( + json!({ + "action": {"type": "string", "enum": ["enroll", "generate", "revoke"]}, + "referenceAudioMediaRef": {"type": "string"}, "consentId": {"type": "string"}, + "voiceId": {"type": "string"}, "voiceName": {"type": "string"}, + "prompt": {"type": "string"}, "provider": {"type": "string"}, + "model": {"type": "string"}, "costAuthorized": {"type": "boolean"} + }), + &["action", "consentId"], + ), }; close_declared_objects(&mut schema); schema diff --git a/crates/opentake-agent/src/tools/errors.rs b/crates/opentake-agent/src/tools/errors.rs index 71a7618c..5f1ad68d 100644 --- a/crates/opentake-agent/src/tools/errors.rs +++ b/crates/opentake-agent/src/tools/errors.rs @@ -79,13 +79,264 @@ pub fn first_non_finite_number_path(value: &Value, path: &str) -> Option .iter() .enumerate() .find_map(|(i, v)| first_non_finite_number_path(v, &format!("{path}[{i}]"))), - Value::Object(map) => map - .iter() - .find_map(|(k, v)| first_non_finite_number_path(v, &format!("{path}.{k}"))), + Value::Object(map) => map.iter().find_map(|(k, v)| { + let child = if path.is_empty() { + k.clone() + } else { + format!("{path}.{k}") + }; + first_non_finite_number_path(v, &child) + }), _ => None, } } +/// Inspect bounded raw JSON before `serde_json`/rmcp decoding so JSON's +/// non-standard `NaN`/`Infinity` tokens and finite-syntax overflow numbers can +/// still receive the same path-precise tool error as in-process values. +pub fn first_non_finite_json_number_path(input: &[u8]) -> Option { + RawNumberPathScanner::new(input) + .scan_value("", 0) + .map(argument_relative_path) +} + +const RAW_NUMBER_MAX_DEPTH: usize = 128; +const RAW_NUMBER_MAX_PATH: usize = 256; + +struct RawNumberPathScanner<'a> { + input: &'a [u8], + cursor: usize, +} + +impl<'a> RawNumberPathScanner<'a> { + fn new(input: &'a [u8]) -> Self { + Self { input, cursor: 0 } + } + + fn scan_value(&mut self, path: &str, depth: usize) -> Option { + if depth > RAW_NUMBER_MAX_DEPTH { + return None; + } + self.skip_whitespace(); + match self.input.get(self.cursor).copied()? { + b'{' => self.scan_object(path, depth), + b'[' => self.scan_array(path, depth), + b'"' => { + self.scan_string()?; + None + } + b'-' if self.consume_word(b"-Infinity") => Some(path.to_string()), + b'N' if self.consume_word(b"NaN") => Some(path.to_string()), + b'I' if self.consume_word(b"Infinity") => Some(path.to_string()), + b'-' | b'0'..=b'9' => self.scan_number(path), + b't' => { + self.consume_word(b"true"); + None + } + b'f' => { + self.consume_word(b"false"); + None + } + b'n' => { + self.consume_word(b"null"); + None + } + _ => { + self.cursor += 1; + None + } + } + } + + fn scan_object(&mut self, path: &str, depth: usize) -> Option { + self.cursor += 1; + loop { + self.skip_whitespace(); + if self.input.get(self.cursor) == Some(&b'}') { + self.cursor += 1; + return None; + } + let key = self.scan_string()?; + self.skip_whitespace(); + if self.input.get(self.cursor) != Some(&b':') { + return None; + } + self.cursor += 1; + let child_path = bounded_object_path(path, &key); + if let Some(found) = self.scan_value(&child_path, depth + 1) { + return Some(found); + } + self.skip_whitespace(); + match self.input.get(self.cursor) { + Some(b',') => self.cursor += 1, + Some(b'}') => { + self.cursor += 1; + return None; + } + _ => return None, + } + } + } + + fn scan_array(&mut self, path: &str, depth: usize) -> Option { + self.cursor += 1; + let mut index = 0; + loop { + self.skip_whitespace(); + if self.input.get(self.cursor) == Some(&b']') { + self.cursor += 1; + return None; + } + let child_path = bounded_array_path(path, index); + if let Some(found) = self.scan_value(&child_path, depth + 1) { + return Some(found); + } + index += 1; + self.skip_whitespace(); + match self.input.get(self.cursor) { + Some(b',') => self.cursor += 1, + Some(b']') => { + self.cursor += 1; + return None; + } + _ => return None, + } + } + } + + fn scan_string(&mut self) -> Option { + let start = self.cursor; + if self.input.get(self.cursor) != Some(&b'"') { + return None; + } + self.cursor += 1; + while let Some(byte) = self.input.get(self.cursor).copied() { + match byte { + b'\\' => { + self.cursor += 2; + } + b'"' => { + self.cursor += 1; + return serde_json::from_slice(&self.input[start..self.cursor]).ok(); + } + _ => self.cursor += 1, + } + } + None + } + + fn scan_number(&mut self, path: &str) -> Option { + let start = self.cursor; + if self.input.get(self.cursor) == Some(&b'-') { + self.cursor += 1; + } + self.consume_digits(); + if self.input.get(self.cursor) == Some(&b'.') { + self.cursor += 1; + self.consume_digits(); + } + if self + .input + .get(self.cursor) + .is_some_and(|byte| matches!(byte, b'e' | b'E')) + { + self.cursor += 1; + if self + .input + .get(self.cursor) + .is_some_and(|byte| matches!(byte, b'+' | b'-')) + { + self.cursor += 1; + } + self.consume_digits(); + } + let token = std::str::from_utf8(&self.input[start..self.cursor]).ok()?; + token + .parse::() + .ok() + .filter(|number| !number.is_finite()) + .map(|_| path.to_string()) + } + + fn consume_digits(&mut self) { + while self.input.get(self.cursor).is_some_and(u8::is_ascii_digit) { + self.cursor += 1; + } + } + + fn consume_word(&mut self, word: &[u8]) -> bool { + if !self.input[self.cursor..].starts_with(word) { + return false; + } + let end = self.cursor + word.len(); + if self + .input + .get(end) + .is_some_and(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'.')) + { + return false; + } + self.cursor = end; + true + } + + fn skip_whitespace(&mut self) { + while self + .input + .get(self.cursor) + .is_some_and(u8::is_ascii_whitespace) + { + self.cursor += 1; + } + } +} + +fn bounded_object_path(path: &str, key: &str) -> String { + if path == "$" || path.len() + key.len() + usize::from(!path.is_empty()) > RAW_NUMBER_MAX_PATH { + "$".to_string() + } else if path.is_empty() { + key.to_string() + } else { + format!("{path}.{key}") + } +} + +fn bounded_array_path(path: &str, index: usize) -> String { + if path == "$" { + return "$".to_string(); + } + let child = format!("{path}[{index}]"); + if child.len() > RAW_NUMBER_MAX_PATH { + "$".to_string() + } else { + child + } +} + +fn argument_relative_path(path: String) -> String { + for marker in ["params.arguments.", ".params.arguments."] { + if let Some(index) = path.find(marker) { + let relative = &path[index + marker.len()..]; + return if safe_planned_non_finite_path(relative) { + relative.to_string() + } else { + "arguments".to_string() + }; + } + } + "arguments".to_string() +} + +fn safe_planned_non_finite_path(path: &str) -> bool { + let Some(index) = path + .strip_prefix("entries[") + .and_then(|tail| tail.strip_suffix("].startFrame")) + else { + return false; + }; + !index.is_empty() && index.bytes().all(|byte| byte.is_ascii_digit()) +} + /// Decode `dict` into `T` with the full three-layer guard: /// 1. unknown-key rejection (incl. nested entries), 2. non-finite-number /// rejection, 3. path-precise serde decode errors. 1:1 port of @@ -304,6 +555,33 @@ mod tests { ); } + #[test] + fn non_finite_number_rejected_with_path() { + for number in ["NaN", "Infinity", "-Infinity", "1e400"] { + let body = format!( + r#"{{"jsonrpc":"2.0","params":{{"arguments":{{"entries":[0,1,2,{{"startFrame":{number}}}]}}}}}}"# + ); + assert_eq!( + first_non_finite_json_number_path(body.as_bytes()).as_deref(), + Some("entries[3].startFrame"), + "{number}" + ); + } + assert_eq!( + first_non_finite_json_number_path( + br#"{"params":{"arguments":{"entries":[{"startFrame":120.5}]}}}"# + ), + None + ); + assert_eq!( + first_non_finite_json_number_path( + br#"{"params":{"arguments":{"callerOwnedSecret":Infinity}}}"# + ) + .as_deref(), + Some("arguments") + ); + } + #[test] fn validate_unknown_keys_ok_when_subset() { let map = serde_json::json!({"mediaRef":"m"}); diff --git a/crates/opentake-agent/src/tools/names.rs b/crates/opentake-agent/src/tools/names.rs index 1f19d1ba..e6ab16ad 100644 --- a/crates/opentake-agent/src/tools/names.rs +++ b/crates/opentake-agent/src/tools/names.rs @@ -34,6 +34,7 @@ pub enum ToolName { AutoCutToBeats, SmartReframe, TightenSilences, + RemoveFillerWords, // --- Media generation / import (5) --- GenerateVideo, GenerateImage, @@ -60,9 +61,46 @@ pub enum ToolName { // --- OpenTake Motion Canvas graphics (docs/MOTION-GRAPHICS-PLUGIN.md, Issue #34) --- AddMotionGraphic, EditMotionGraphic, + // --- Advanced AI workflows (capability-gated by the desktop host) --- + TrackMotion, + GenerateMatte, + RemoveObject, + MatchColor, + SeparateStems, + TranslateCaptions, + ScriptToVideo, + GenerateAvatar, + CloneVoice, } impl ToolName { + /// Whether discovery of this tool requires a live host media bridge. + /// Keeping this predicate next to the catalog prevents MCP and in-app Chat + /// from drifting into different fail-closed capability sets. + pub const fn requires_media_bridge(self) -> bool { + matches!( + self, + ToolName::InspectMedia + | ToolName::GetTranscript + | ToolName::InspectTimeline + | ToolName::SearchMedia + | ToolName::AddCaptions + | ToolName::RemoveFillerWords + | ToolName::ImportMedia + ) + } + + /// Why a schema-known tool is deliberately hidden from discovery, or + /// `None` when the tool is not capability-gated. The dispatch gate appends + /// this to its fail-closed "not advertised" result so a model invoking a + /// gated tool by name learns the missing backend instead of guessing. + pub const fn hidden_capability_reason(self) -> Option<&'static str> { + match self { + ToolName::SmartReframe => Some("vision analysis backend is not available"), + _ => None, + } + } + /// The wire name (matches upstream / spec exactly). pub fn as_str(self) -> &'static str { match self { @@ -89,6 +127,7 @@ impl ToolName { ToolName::AutoCutToBeats => "auto_cut_to_beats", ToolName::SmartReframe => "smart_reframe", ToolName::TightenSilences => "tighten_silences", + ToolName::RemoveFillerWords => "remove_filler_words", ToolName::GenerateVideo => "generate_video", ToolName::GenerateImage => "generate_image", ToolName::GenerateAudio => "generate_audio", @@ -110,11 +149,102 @@ impl ToolName { ToolName::ApplyEffect => "apply_effect", ToolName::AddMotionGraphic => "add_motion_graphic", ToolName::EditMotionGraphic => "edit_motion_graphic", + ToolName::TrackMotion => "track_motion", + ToolName::GenerateMatte => "generate_matte", + ToolName::RemoveObject => "remove_object", + ToolName::MatchColor => "match_color", + ToolName::SeparateStems => "separate_stems", + ToolName::TranslateCaptions => "translate_captions", + ToolName::ScriptToVideo => "script_to_video", + ToolName::GenerateAvatar => "generate_avatar", + ToolName::CloneVoice => "clone_voice", } } - /// All tools in registration order. - pub const ALL: [ToolName; 44] = [ + /// Base tools advertised to MCP and in-app Chat in registration order. + /// Provider-backed generation, Motion, and vision-analysis tools are + /// appended only when the current host reports their respective live + /// capabilities. + pub const ALL: [ToolName; 38] = [ + ToolName::GetTimeline, + ToolName::GetMedia, + ToolName::InspectMedia, + ToolName::GetTranscript, + ToolName::InspectTimeline, + ToolName::SearchMedia, + ToolName::ListModels, + ToolName::AddClips, + ToolName::InsertClips, + ToolName::RemoveClips, + ToolName::RemoveTracks, + ToolName::MoveClips, + ToolName::SetClipProperties, + ToolName::SetKeyframes, + ToolName::SplitClip, + ToolName::RippleDeleteRanges, + ToolName::Undo, + ToolName::AddTexts, + ToolName::AddCaptions, + ToolName::DetectBeats, + ToolName::AutoCutToBeats, + ToolName::TightenSilences, + ToolName::RemoveFillerWords, + ToolName::ImportMedia, + ToolName::ListFolders, + ToolName::CreateFolder, + ToolName::MoveToFolder, + ToolName::RenameMedia, + ToolName::RenameFolder, + ToolName::DeleteMedia, + ToolName::DeleteFolder, + ToolName::ActivateWorkflow, + ToolName::ListWorkflows, + ToolName::DeactivateWorkflow, + ToolName::SetColorGrade, + ToolName::ChromaKey, + ToolName::SetMask, + ToolName::ApplyEffect, + ]; + + /// Provider-backed tools appended to a host catalog only while its live + /// generation bridge reports usable authorization. + pub const GENERATION: [ToolName; 4] = [ + ToolName::GenerateVideo, + ToolName::GenerateImage, + ToolName::GenerateAudio, + ToolName::UpscaleMedia, + ]; + + /// Motion tools appended only by a host with a production render/import/ + /// placement bridge. They remain known for strict compatibility parsing in + /// all other hosts. + pub const MOTION: [ToolName; 2] = [ToolName::AddMotionGraphic, ToolName::EditMotionGraphic]; + + /// Vision-analysis tools appended only by a host with a live frame-sampling + /// / saliency backend. They remain known for strict compatibility parsing + /// in all other hosts. + pub const VISION: [ToolName; 1] = [ToolName::SmartReframe]; + + /// Advanced workflows are schema-known but never unconditionally + /// advertised. The desktop host appends only the exact capabilities backed + /// by installed local models or a configured provider. + pub const ADVANCED_AI: [ToolName; 9] = [ + ToolName::TrackMotion, + ToolName::GenerateMatte, + ToolName::RemoveObject, + ToolName::MatchColor, + ToolName::SeparateStems, + ToolName::TranslateCaptions, + ToolName::ScriptToVideo, + ToolName::GenerateAvatar, + ToolName::CloneVoice, + ]; + + /// Every recognized schema/wire name, including capabilities deliberately + /// hidden from discovery until a real backend exists. Keeping this set lets + /// strict argument validation and compatibility tests cover future tools + /// without advertising placeholder behavior to models. + pub const KNOWN: [ToolName; 54] = [ ToolName::GetTimeline, ToolName::GetMedia, ToolName::InspectMedia, @@ -138,6 +268,7 @@ impl ToolName { ToolName::AutoCutToBeats, ToolName::SmartReframe, ToolName::TightenSilences, + ToolName::RemoveFillerWords, ToolName::GenerateVideo, ToolName::GenerateImage, ToolName::GenerateAudio, @@ -159,6 +290,15 @@ impl ToolName { ToolName::ApplyEffect, ToolName::AddMotionGraphic, ToolName::EditMotionGraphic, + ToolName::TrackMotion, + ToolName::GenerateMatte, + ToolName::RemoveObject, + ToolName::MatchColor, + ToolName::SeparateStems, + ToolName::TranslateCaptions, + ToolName::ScriptToVideo, + ToolName::GenerateAvatar, + ToolName::CloneVoice, ]; /// The 31 upstream-equivalent tools (Issue #9's "31 tools"). @@ -200,7 +340,7 @@ impl ToolName { impl FromStr for ToolName { type Err = (); fn from_str(s: &str) -> Result { - ToolName::ALL + ToolName::KNOWN .iter() .copied() .find(|t| t.as_str() == s) @@ -218,8 +358,49 @@ mod tests { } #[test] - fn all_set_is_44() { - assert_eq!(ToolName::ALL.len(), 44); + fn advertised_set_is_38_and_known_set_is_54() { + assert_eq!(ToolName::ALL.len(), 38); + assert_eq!(ToolName::KNOWN.len(), 54); + assert!(ToolName::ALL + .iter() + .all(|tool| ToolName::KNOWN.contains(tool))); + } + + #[test] + fn advanced_ai_tools_are_known_but_capability_gated() { + for tool in ToolName::ADVANCED_AI { + assert_eq!(ToolName::from_str(tool.as_str()), Ok(tool)); + assert!(ToolName::KNOWN.contains(&tool)); + assert!(!ToolName::ALL.contains(&tool)); + assert!(!ToolName::UPSTREAM.contains(&tool)); + } + } + + #[test] + fn vision_tools_are_known_but_capability_gated() { + assert_eq!(ToolName::VISION, [ToolName::SmartReframe]); + for tool in ToolName::VISION { + assert_eq!(ToolName::from_str(tool.as_str()), Ok(tool)); + assert!(ToolName::KNOWN.contains(&tool)); + assert!(!ToolName::ALL.contains(&tool)); + assert!(!ToolName::UPSTREAM.contains(&tool)); + assert_eq!( + tool.hidden_capability_reason(), + Some("vision analysis backend is not available") + ); + } + } + + #[test] + fn ungated_tools_have_no_hidden_capability_reason() { + for tool in ToolName::ALL { + assert_eq!( + tool.hidden_capability_reason(), + None, + "{} is advertised but reports a hidden capability", + tool.as_str() + ); + } } #[test] @@ -228,11 +409,13 @@ mod tests { assert_eq!(ToolName::AutoCutToBeats.as_str(), "auto_cut_to_beats"); assert_eq!(ToolName::SmartReframe.as_str(), "smart_reframe"); assert_eq!(ToolName::TightenSilences.as_str(), "tighten_silences"); + assert_eq!(ToolName::RemoveFillerWords.as_str(), "remove_filler_words"); for t in [ ToolName::DetectBeats, ToolName::AutoCutToBeats, ToolName::SmartReframe, ToolName::TightenSilences, + ToolName::RemoveFillerWords, ] { assert_eq!(ToolName::from_str(t.as_str()), Ok(t)); assert!(!ToolName::UPSTREAM.contains(&t)); @@ -247,14 +430,17 @@ mod tests { for t in [ToolName::AddMotionGraphic, ToolName::EditMotionGraphic] { assert_eq!(ToolName::from_str(t.as_str()), Ok(t)); } - // They are present in ALL exactly once each. + // They stay out of the unconditional base catalog: a capable desktop + // host appends MOTION, while non-rendering hosts remain fail-closed. assert_eq!( - ToolName::ALL + ToolName::KNOWN .iter() .filter(|t| matches!(t, ToolName::AddMotionGraphic | ToolName::EditMotionGraphic)) .count(), 2 ); + assert!(!ToolName::ALL.contains(&ToolName::AddMotionGraphic)); + assert!(!ToolName::ALL.contains(&ToolName::EditMotionGraphic)); // ...and are NOT part of the 31 upstream tools. assert!(!ToolName::UPSTREAM.contains(&ToolName::AddMotionGraphic)); assert!(!ToolName::UPSTREAM.contains(&ToolName::EditMotionGraphic)); diff --git a/crates/opentake-agent/src/tools/result.rs b/crates/opentake-agent/src/tools/result.rs index 738ace36..9f7b6d05 100644 --- a/crates/opentake-agent/src/tools/result.rs +++ b/crates/opentake-agent/src/tools/result.rs @@ -51,6 +51,10 @@ pub struct ToolResult { pub(crate) enum PublicErrorKind { UnknownTool, InvalidArguments(ToolName), + ResourceNotFound(ToolName), + CapabilityUnavailable(ToolName), + PathAuthorityRequired(ToolName), + AnalysisLowConfidence(ToolName), } impl PublicErrorKind { @@ -58,6 +62,10 @@ impl PublicErrorKind { match self { Self::UnknownTool => "MCP_UNKNOWN_TOOL", Self::InvalidArguments(_) => "MCP_INVALID_ARGUMENTS", + Self::ResourceNotFound(_) => "MCP_RESOURCE_NOT_FOUND", + Self::CapabilityUnavailable(_) => "MCP_CAPABILITY_UNAVAILABLE", + Self::PathAuthorityRequired(_) => "MCP_PATH_AUTHORITY_REQUIRED", + Self::AnalysisLowConfidence(_) => "MCP_ANALYSIS_LOW_CONFIDENCE", } } @@ -65,6 +73,16 @@ impl PublicErrorKind { match self { Self::UnknownTool => "The requested tool is not available.", Self::InvalidArguments(_) => "The tool request has invalid arguments.", + Self::ResourceNotFound(_) => "The referenced project resource was not found.", + Self::CapabilityUnavailable(_) => { + "This capability is unavailable for the referenced media." + } + Self::PathAuthorityRequired(_) => { + "Local file paths require access granted by the user in OpenTake." + } + Self::AnalysisLowConfidence(_) => { + "The analysis could not identify the requested subject reliably." + } } } @@ -72,6 +90,18 @@ impl PublicErrorKind { match self { Self::UnknownTool => "Choose a tool returned by the current tool catalog, then retry.", Self::InvalidArguments(_) => "Correct the reported arguments, then retry.", + Self::ResourceNotFound(_) => { + "Refresh project state, choose an existing resource ID, then retry." + } + Self::CapabilityUnavailable(_) => { + "Use a supported source type or restore the source media, then retry." + } + Self::PathAuthorityRequired(_) => { + "Import the file with OpenTake's native file picker, then reference its project media ID." + } + Self::AnalysisLowConfidence(_) => { + "Choose a tighter, higher-contrast subject region and retry." + } } } } diff --git a/crates/opentake-agent/tests/advanced_ai_workflows.rs b/crates/opentake-agent/tests/advanced_ai_workflows.rs new file mode 100644 index 00000000..a9e9b059 --- /dev/null +++ b/crates/opentake-agent/tests/advanced_ai_workflows.rs @@ -0,0 +1,262 @@ +use std::path::PathBuf; +use std::sync::{Arc, RwLock}; + +use opentake_agent::mcp::advanced::{ + AdvancedWorkflowBridge, AdvancedWorkflowCommit, AdvancedWorkflowError, AdvancedWorkflowRequest, +}; +use opentake_agent::mcp::core_handle::CoreHandle; +use opentake_agent::mcp::dispatch::Dispatcher; +use opentake_agent::plugin::registry::PluginRegistry; +use opentake_agent::tools::names::ToolName; +use opentake_domain::{MediaManifest, Timeline}; +use opentake_ops::{EditCommand, EditResult}; +use serde_json::{json, Value}; + +struct ReadOnlyHandle; + +impl CoreHandle for ReadOnlyHandle { + fn timeline(&self) -> Timeline { + Timeline::new() + } + + fn media(&self) -> MediaManifest { + MediaManifest::new() + } + + fn apply(&self, _cmd: EditCommand) -> anyhow::Result { + anyhow::bail!("advanced workflow fixture is read-only") + } + + fn project_dir(&self) -> Option { + None + } +} + +struct DeterministicAdvancedBridge; + +impl AdvancedWorkflowBridge for DeterministicAdvancedBridge { + fn supported_tools(&self) -> Vec { + ToolName::ADVANCED_AI.to_vec() + } + + fn execute( + &self, + request: AdvancedWorkflowRequest, + _cancel: &opentake_media::MediaCancelToken, + ) -> Result { + Ok(AdvancedWorkflowCommit { + result: json!({"tool": request.tool().as_str(), "status": "completed"}), + action_name: None, + }) + } +} + +struct MisleadingAdvancedBridge; + +impl AdvancedWorkflowBridge for MisleadingAdvancedBridge { + fn supported_tools(&self) -> Vec { + vec![ToolName::TrackMotion] + } + + fn execute( + &self, + _request: AdvancedWorkflowRequest, + _cancel: &opentake_media::MediaCancelToken, + ) -> Result { + Ok(AdvancedWorkflowCommit { + result: json!({"status":"completed-without-edit"}), + action_name: Some("Track Motion".into()), + }) + } +} + +struct LeakyAdvancedBridge; + +impl AdvancedWorkflowBridge for LeakyAdvancedBridge { + fn supported_tools(&self) -> Vec { + ToolName::ADVANCED_AI.to_vec() + } + + fn execute( + &self, + request: AdvancedWorkflowRequest, + _cancel: &opentake_media::MediaCancelToken, + ) -> Result { + Ok(AdvancedWorkflowCommit { + result: json!({ + "tool": request.tool().as_str(), + "status": "completed", + "clipId": "clip-safe", + "assetId": "asset-safe", + "previewPath": "/Users/private/advanced-output.mov", + "signedUrl": "https://provider.invalid/output?token=SIGNED_WORKFLOW_SECRET", + "providerRequestId": "provider-secret-request-id", + "prompt": "PRIVATE_WORKFLOW_PROMPT", + "errors": [{"message": "raw provider error with sk-secret-provider-key"}], + }), + action_name: None, + }) + } +} + +fn dispatcher(advanced: bool) -> Dispatcher { + Dispatcher::with_all_capability_bridges( + Arc::new(ReadOnlyHandle), + Arc::new(RwLock::new(PluginRegistry::new())), + None, + None, + None, + advanced.then(|| Arc::new(DeterministicAdvancedBridge) as Arc), + ) +} + +fn cases() -> [(ToolName, Value); 9] { + [ + ( + ToolName::TrackMotion, + json!({"clipId":"clip","region":{"x":0.1,"y":0.1,"width":0.2,"height":0.2}}), + ), + (ToolName::GenerateMatte, json!({"clipId":"clip"})), + ( + ToolName::RemoveObject, + json!({"clipId":"clip","maskId":"mask"}), + ), + ( + ToolName::MatchColor, + json!({"clipId":"clip","referenceMediaRef":"asset"}), + ), + (ToolName::SeparateStems, json!({"mediaRef":"asset"})), + ( + ToolName::TranslateCaptions, + json!({"captionClipIds":["caption"],"targetLocale":"zh-CN"}), + ), + ( + ToolName::ScriptToVideo, + json!({"segments":[{"script":"intro","mediaRef":"asset","durationFrames":30}]}), + ), + ( + ToolName::GenerateAvatar, + json!({"portraitMediaRef":"portrait","audioMediaRef":"audio","consentId":"consent","costAuthorized":true}), + ), + ( + ToolName::CloneVoice, + json!({"action":"revoke","consentId":"consent","voiceId":"voice"}), + ), + ] +} + +#[test] +fn advanced_ai_workflows_are_hidden_without_a_live_host() { + let dispatcher = dispatcher(false); + for (tool, args) in cases() { + assert!(!dispatcher.advertised_tools().contains(&tool)); + let result = dispatcher.dispatch(tool.as_str(), args); + assert!(result.is_error); + assert!(result.text_joined().contains("not advertised")); + } +} + +#[test] +fn advanced_ai_workflows_route_through_exact_tool_contracts() { + let dispatcher = dispatcher(true); + for (tool, args) in cases() { + assert!(ToolName::KNOWN.contains(&tool)); + assert!(dispatcher.advertised_tools().contains(&tool)); + let result = dispatcher.dispatch(tool.as_str(), args); + assert!( + !result.is_error, + "{}: {}", + tool.as_str(), + result.text_joined() + ); + assert!(result.text_joined().contains(tool.as_str())); + assert!(result.text_joined().contains("completed")); + } + + for tool in [ToolName::TightenSilences, ToolName::RemoveFillerWords] { + assert!(ToolName::ALL.contains(&tool)); + } +} + +#[test] +fn advanced_dispatch_results_never_forward_private_host_payload_fields() { + let dispatcher = Dispatcher::with_all_capability_bridges( + Arc::new(ReadOnlyHandle), + Arc::new(RwLock::new(PluginRegistry::new())), + None, + None, + None, + Some(Arc::new(LeakyAdvancedBridge)), + ); + for (tool, args) in cases() { + let result = dispatcher.dispatch(tool.as_str(), args); + assert!( + !result.is_error, + "{}: {}", + tool.as_str(), + result.text_joined() + ); + let text = result.text_joined(); + assert!(text.contains(tool.as_str()), "{text}"); + for private in [ + "/Users/private/advanced-output.mov", + "SIGNED_WORKFLOW_SECRET", + "provider-secret-request-id", + "PRIVATE_WORKFLOW_PROMPT", + "sk-secret-provider-key", + ] { + assert!( + !text.contains(private), + "{} leaked {private}: {text}", + tool.as_str() + ); + } + } +} + +#[test] +fn advanced_nested_contracts_reject_unknown_fields_before_host_execution() { + let dispatcher = dispatcher(true); + let result = dispatcher.dispatch( + "track_motion", + json!({"clipId":"clip","region":{"x":0.0,"y":0.0,"width":1.0,"height":1.0,"secret":true}}), + ); + assert!(result.is_error); + assert!( + result.text_joined().contains("region:") && result.text_joined().contains("'secret'"), + "{}", + result.text_joined() + ); + + let result = dispatcher.dispatch( + "script_to_video", + json!({"segments":[{"script":"x","mediaRef":"asset","durationFrames":30,"secret":true}]}), + ); + assert!(result.is_error); + assert!( + result.text_joined().contains("segments[0]:") && result.text_joined().contains("'secret'"), + "{}", + result.text_joined() + ); +} + +#[test] +fn action_name_without_a_real_undo_transaction_grants_no_undo_authority() { + let dispatcher = Dispatcher::with_all_capability_bridges( + Arc::new(ReadOnlyHandle), + Arc::new(RwLock::new(PluginRegistry::new())), + None, + None, + None, + Some(Arc::new(MisleadingAdvancedBridge)), + ); + let result = dispatcher.dispatch( + "track_motion", + json!({"clipId":"clip","region":{"x":0.1,"y":0.1,"width":0.2,"height":0.2}}), + ); + assert!(!result.is_error, "{}", result.text_joined()); + + let undo = dispatcher.dispatch("undo", json!({})); + assert!(undo.is_error); + assert!(undo.text_joined().contains("No assistant edit")); +} diff --git a/crates/opentake-agent/tests/advertised_tool_acceptance.rs b/crates/opentake-agent/tests/advertised_tool_acceptance.rs new file mode 100644 index 00000000..233c2a3c --- /dev/null +++ b/crates/opentake-agent/tests/advertised_tool_acceptance.rs @@ -0,0 +1,269 @@ +use std::path::PathBuf; +use std::sync::{Arc, RwLock}; + +use opentake_agent::mcp::core_handle::CoreHandle; +use opentake_agent::mcp::dispatch::Dispatcher; +use opentake_agent::mcp::motion::{ + AddMotionRequest, EditMotionRequest, MotionBridge, MotionBridgeError, MotionCommit, + MotionOutputMetadata, +}; +use opentake_agent::plugin::registry::PluginRegistry; +use opentake_agent::tools::names::ToolName; +use opentake_domain::{MediaManifest, Timeline}; +use opentake_ops::{EditCommand, EditResult}; + +struct ReadOnlyHandle; + +struct DeterministicMotionBridge; + +const PRIVATE_RENDERER: &str = "PRIVATE_MOTION_RENDERER"; +const PRIVATE_RENDERER_VERSION: &str = "PRIVATE_MOTION_RENDERER_VERSION"; +const PRIVATE_OUTPUT_FILE: &str = "/Users/private/motion/output.mp4"; +const PRIVATE_ADD_HASH: &str = "PRIVATE_ADD_CONTENT_HASH"; +const PRIVATE_EDIT_HASH: &str = "PRIVATE_EDIT_CONTENT_HASH"; + +fn output_metadata(content_hash: &str) -> MotionOutputMetadata { + MotionOutputMetadata { + renderer: PRIVATE_RENDERER.into(), + renderer_version: PRIVATE_RENDERER_VERSION.into(), + output_file: PRIVATE_OUTPUT_FILE.into(), + fps: 30.0, + width: 64, + height: 36, + duration_frames: 30, + duration_seconds: 1.0, + content_hash: content_hash.into(), + } +} + +impl MotionBridge for DeterministicMotionBridge { + fn can_render_motion(&self) -> bool { + true + } + + fn add( + &self, + _request: AddMotionRequest, + _cancel: &opentake_media::MediaCancelToken, + ) -> Result { + Ok(MotionCommit { + clip_id: "motion-clip".into(), + asset_id: "motion-asset".into(), + content_hash: PRIVATE_ADD_HASH.into(), + action_name: "Add Motion Graphic".into(), + output: output_metadata(PRIVATE_ADD_HASH), + }) + } + + fn edit( + &self, + request: EditMotionRequest, + _cancel: &opentake_media::MediaCancelToken, + ) -> Result { + Ok(MotionCommit { + clip_id: request.clip_id, + asset_id: "edited-motion-asset".into(), + content_hash: PRIVATE_EDIT_HASH.into(), + action_name: "Edit Motion Graphic".into(), + output: output_metadata(PRIVATE_EDIT_HASH), + }) + } +} + +impl CoreHandle for ReadOnlyHandle { + fn timeline(&self) -> Timeline { + Timeline::new() + } + + fn media(&self) -> MediaManifest { + MediaManifest::new() + } + + fn apply(&self, _cmd: EditCommand) -> anyhow::Result { + anyhow::bail!("advertised-tool fixture is read-only") + } + + fn project_dir(&self) -> Option { + None + } +} + +#[test] +fn every_advertised_tool_is_live_or_absent() { + let dispatcher = Dispatcher::with_capability_bridges( + Arc::new(ReadOnlyHandle), + Arc::new(RwLock::new(PluginRegistry::new())), + None, + None, + Some(Arc::new(DeterministicMotionBridge)), + ); + let cases = [ + ( + ToolName::InspectMedia, + serde_json::json!({"mediaRef": "asset"}), + ), + ( + ToolName::GenerateVideo, + serde_json::json!({"prompt": "clip"}), + ), + ( + ToolName::GenerateImage, + serde_json::json!({"prompt": "still"}), + ), + ( + ToolName::GenerateAudio, + serde_json::json!({"prompt": "music"}), + ), + ( + ToolName::UpscaleMedia, + serde_json::json!({"mediaRef": "asset"}), + ), + ( + ToolName::AddMotionGraphic, + serde_json::json!({ + "source": {"code": "export default {}"}, + "startFrame": 0, + "durationFrames": 30 + }), + ), + ( + ToolName::EditMotionGraphic, + serde_json::json!({"clipId": "clip", "code": "export default {}"}), + ), + ( + ToolName::SmartReframe, + serde_json::json!({"clipIds": ["clip-a"], "aspectRatio": "9:16"}), + ), + ]; + let advertised = dispatcher.advertised_tools(); + + for (tool, args) in cases { + if !advertised.contains(&tool) { + let result = dispatcher.dispatch(tool.as_str(), args); + assert!( + result.text_joined().contains("not advertised"), + "{} is hidden from discovery but direct dispatch did not fail closed: {}", + tool.as_str(), + result.text_joined() + ); + continue; + } + let result = dispatcher.dispatch(tool.as_str(), args); + assert!( + !result.text_joined().contains("not yet implemented"), + "{} is advertised but still reaches a placeholder stub: {}", + tool.as_str(), + result.text_joined() + ); + assert!( + !result.text_joined().contains("not advertised"), + "{} was advertised but dispatch rejected it: {}", + tool.as_str(), + result.text_joined() + ); + } +} + +#[test] +fn motion_tools_are_absent_without_a_live_host_bridge() { + let dispatcher = Dispatcher::new( + Arc::new(ReadOnlyHandle), + Arc::new(RwLock::new(PluginRegistry::new())), + ); + + for tool in ToolName::MOTION { + assert!(!dispatcher.advertised_tools().contains(&tool)); + } +} + +#[test] +fn vision_tools_are_absent_without_a_vision_backend() { + let dispatcher = Dispatcher::new( + Arc::new(ReadOnlyHandle), + Arc::new(RwLock::new(PluginRegistry::new())), + ); + + for tool in ToolName::VISION { + assert!(!dispatcher.advertised_tools().contains(&tool)); + // Capability-gated tools still fail closed with the exact missing + // capability named, never as a silent success or a placeholder stub. + let result = dispatcher.dispatch( + tool.as_str(), + serde_json::json!({"clipIds": ["clip-a"], "aspectRatio": "9:16"}), + ); + assert!(result.is_error); + assert!( + result.text_joined().contains("not advertised"), + "{}", + result.text_joined() + ); + assert!( + result + .text_joined() + .contains("vision analysis backend is not available"), + "{}", + result.text_joined() + ); + } +} + +#[test] +fn motion_tool_results_expose_only_typed_safe_commit_fields() { + let dispatcher = Dispatcher::with_capability_bridges( + Arc::new(ReadOnlyHandle), + Arc::new(RwLock::new(PluginRegistry::new())), + None, + None, + Some(Arc::new(DeterministicMotionBridge)), + ); + let cases = [ + ( + ToolName::AddMotionGraphic, + serde_json::json!({ + "source": {"code": "export default {}"}, + "startFrame": 0, + "durationFrames": 30 + }), + "motion-clip", + "motion-asset", + ), + ( + ToolName::EditMotionGraphic, + serde_json::json!({ + "clipId": "motion-input-clip", + "code": "export default {}" + }), + "motion-input-clip", + "edited-motion-asset", + ), + ]; + + for (tool, args, clip_id, asset_id) in cases { + let result = dispatcher.dispatch(tool.as_str(), args); + assert!(!result.is_error, "{}", result.text_joined()); + let text = result.text_joined(); + let value: serde_json::Value = serde_json::from_str(&text).expect("typed motion result"); + assert_eq!(value["status"], "completed"); + assert_eq!(value["clipId"], clip_id); + assert_eq!(value["assetId"], asset_id); + assert_eq!(value["durationFrames"], 30); + assert_eq!(value["dimensions"]["width"], 64); + assert_eq!(value["dimensions"]["height"], 36); + for private in [ + PRIVATE_RENDERER, + PRIVATE_RENDERER_VERSION, + PRIVATE_OUTPUT_FILE, + PRIVATE_ADD_HASH, + PRIVATE_EDIT_HASH, + "contentHash", + "outputFile", + "rendererVersion", + ] { + assert!( + !text.contains(private), + "{} leaked {private}: {text}", + tool.as_str() + ); + } + } +} diff --git a/crates/opentake-agent/tests/completion_43312d5e9f613913.rs b/crates/opentake-agent/tests/completion_43312d5e9f613913.rs new file mode 100644 index 00000000..32653bbd --- /dev/null +++ b/crates/opentake-agent/tests/completion_43312d5e9f613913.rs @@ -0,0 +1,76 @@ +use std::path::PathBuf; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex, RwLock}; + +use opentake_agent::mcp::core_handle::CoreHandle; +use opentake_agent::mcp::dispatch::Dispatcher; +use opentake_agent::plugin::registry::PluginRegistry; +use opentake_domain::{Clip, ClipType, MediaManifest, Timeline, Track}; +use opentake_ops::{apply as ops_apply, EditCommand, EditResult, EditorState, SeqIdGen}; + +struct RecordingCore { + state: Mutex, + apply_calls: AtomicUsize, +} + +impl RecordingCore { + fn new() -> Self { + let mut timeline = Timeline::new(); + let mut track = Track::new("video-track", ClipType::Video); + track.clips.push(Clip::new("clip-a", "asset-a", 0, 30)); + timeline.tracks.push(track); + Self { + state: Mutex::new(EditorState::new(timeline, MediaManifest::new())), + apply_calls: AtomicUsize::new(0), + } + } +} + +impl CoreHandle for RecordingCore { + fn timeline(&self) -> Timeline { + self.state.lock().expect("state lock").timeline.clone() + } + + fn media(&self) -> MediaManifest { + self.state.lock().expect("state lock").manifest.clone() + } + + fn apply(&self, command: EditCommand) -> anyhow::Result { + self.apply_calls.fetch_add(1, Ordering::SeqCst); + ops_apply( + &mut self.state.lock().expect("state lock"), + command, + &SeqIdGen::new("contract-"), + ) + .map_err(|error| anyhow::anyhow!(error.to_string())) + } + + fn project_dir(&self) -> Option { + None + } +} + +#[test] +fn completion_43312d5e9f613913_tauri_exposes_typed_core_edit_commands_with_stab() { + let core = Arc::new(RecordingCore::new()); + let dispatcher = Dispatcher::new(core.clone(), Arc::new(RwLock::new(PluginRegistry::new()))); + let before = core.timeline(); + + let malformed = dispatcher.dispatch( + "remove_clips", + serde_json::json!({"clipIds": "not-an-array", "hostPath": "/private/secret"}), + ); + assert!(malformed.is_error); + assert_eq!(core.apply_calls.load(Ordering::SeqCst), 0); + assert_eq!(core.timeline(), before); + assert!(!malformed.text_joined().contains("/private/secret")); + + let valid = dispatcher.dispatch("remove_clips", serde_json::json!({"clipIds": ["clip-a"]})); + assert!(!valid.is_error, "{}", valid.text_joined()); + assert_eq!(core.apply_calls.load(Ordering::SeqCst), 1); + assert!(core + .timeline() + .tracks + .iter() + .all(|track| track.clips.is_empty())); +} diff --git a/crates/opentake-agent/tests/editing_automation_acceptance.rs b/crates/opentake-agent/tests/editing_automation_acceptance.rs new file mode 100644 index 00000000..ec3b4480 --- /dev/null +++ b/crates/opentake-agent/tests/editing_automation_acceptance.rs @@ -0,0 +1,239 @@ +use std::path::PathBuf; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex, RwLock}; + +use opentake_agent::mcp::core_handle::CoreHandle; +use opentake_agent::mcp::dispatch::Dispatcher; +use opentake_agent::plugin::registry::PluginRegistry; +use opentake_domain::{ + Clip, ClipType, Crop, MediaManifest, MediaManifestEntry, MediaSource, Timeline, Track, +}; +use opentake_media::analysis::{ + detect_autocrop, detect_beats, AutocropConfig, BeatDetectionConfig, FrameBuffer, PixelFormat, +}; +use opentake_media::{PcmBuffer, PcmFormat, PcmSpec}; +use opentake_ops::intent::{plan_beat_sync_placement, plan_smart_reframe, IntentClipEntry}; +use opentake_ops::{apply as ops_apply, EditCommand, EditResult, EditorState, SeqIdGen}; + +struct AutomationHandle { + state: Mutex, + pcm: PcmBuffer, + apply_calls: AtomicUsize, +} + +impl AutomationHandle { + fn new() -> Self { + let mut timeline = Timeline::new(); + timeline.fps = 10; + let mut track = Track::new("video-track", ClipType::Video); + track.clips.push(Clip::new("clip-a", "asset-1", 0, 10)); + timeline.tracks.push(track); + + let mut manifest = MediaManifest::new(); + manifest.entries.push(MediaManifestEntry { + id: "asset-1".into(), + name: "Source.mov".into(), + kind: ClipType::Video, + source: MediaSource::External { + absolute_path: "/fixture/Source.mov".into(), + }, + duration: 1.0, + generation_input: None, + source_width: Some(1920), + source_height: Some(1080), + source_fps: Some(10.0), + has_audio: Some(true), + color: None, + proxy: None, + folder_id: None, + cached_remote_url: None, + cached_remote_url_expires_at: None, + }); + + let mut samples = vec![0.5; 300]; + samples.extend(std::iter::repeat_n(0.0, 400)); + samples.extend(std::iter::repeat_n(0.5, 300)); + Self { + state: Mutex::new(EditorState::new(timeline, manifest)), + pcm: PcmBuffer { + spec: PcmSpec { + sample_rate: 1_000, + channels: 1, + format: PcmFormat::F32, + }, + samples_f32: samples, + }, + apply_calls: AtomicUsize::new(0), + } + } +} + +impl CoreHandle for AutomationHandle { + fn timeline(&self) -> Timeline { + self.state.lock().expect("state lock").timeline.clone() + } + + fn media(&self) -> MediaManifest { + self.state.lock().expect("state lock").manifest.clone() + } + + fn apply(&self, command: EditCommand) -> anyhow::Result { + self.apply_calls.fetch_add(1, Ordering::AcqRel); + let ids = SeqIdGen::new("automation-"); + ops_apply(&mut self.state.lock().expect("state lock"), command, &ids) + .map_err(|error| anyhow::anyhow!(error.to_string())) + } + + fn project_dir(&self) -> Option { + None + } + + fn extract_analysis_pcm( + &self, + _media_ref: &str, + _spec: PcmSpec, + _range: Option<(f64, f64)>, + ) -> anyhow::Result { + Ok(self.pcm.clone()) + } +} + +fn first_json(result: &opentake_agent::tools::result::ToolResult) -> serde_json::Value { + let text = match &result.content[0] { + opentake_agent::tools::result::Block::Text { text } => text, + other => panic!("expected JSON text block, got {other:?}"), + }; + serde_json::from_str(text).expect("valid tool JSON") +} + +#[test] +fn automation_children_are_atomic_reviewable_and_command_routed() { + // The media children are deterministic and reject malformed input without + // producing a partial proposal. + let beat_config = BeatDetectionConfig { + sample_rate: 1_000, + fps: 10.0, + window_size_samples: 100, + hop_size_samples: 100, + min_onset_strength: 0.05, + min_gap_frames: 1, + }; + let mut pulse = vec![0.0; 1_000]; + pulse[500..530].fill(1.0); + assert_eq!( + detect_beats(&pulse, beat_config), + detect_beats(&pulse, beat_config) + ); + assert!(detect_beats( + &pulse, + BeatDetectionConfig { + sample_rate: 0, + ..beat_config + } + ) + .is_empty()); + + let pixels = [0_u8, 0, 0, 255, 255, 255]; + let valid_frame = FrameBuffer { + width: 2, + height: 1, + data: &pixels, + pixel_format: PixelFormat::Rgb, + }; + assert_eq!( + detect_autocrop(&valid_frame, AutocropConfig::default()), + detect_autocrop(&valid_frame, AutocropConfig::default()) + ); + let truncated = FrameBuffer { + data: &pixels[..3], + ..valid_frame + }; + assert_eq!(detect_autocrop(&truncated, AutocropConfig::default()), None); + + let handle = Arc::new(AutomationHandle::new()); + let dispatcher = Dispatcher::new(handle.clone(), Arc::new(RwLock::new(PluginRegistry::new()))); + let before = handle.timeline(); + + // Analysis surfaces return reviewable proposals or typed diagnostics. They + // never reach the edit boundary themselves. + let beats = dispatcher.dispatch("detect_beats", serde_json::json!({"mediaRef": "asset-1"})); + assert!(!beats.is_error, "{}", beats.text_joined()); + assert_eq!(first_json(&beats)["applied"], false); + + let silences = dispatcher.dispatch( + "tighten_silences", + serde_json::json!({ + "clipIds": ["clip-a"], + "thresholdDb": -40.0, + "minSilenceFrames": 2, + "paddingFrames": 0 + }), + ); + assert!(!silences.is_error, "{}", silences.text_joined()); + let silence_json = first_json(&silences); + assert_eq!(silence_json["applied"], false); + assert_eq!(silence_json["commands"][0]["tool"], "ripple_delete_ranges"); + + let unavailable = dispatcher.dispatch( + "smart_reframe", + serde_json::json!({"clipIds": ["clip-a"], "aspectRatio": "9:16"}), + ); + assert!(unavailable.is_error); + assert!(unavailable.text_joined().contains("not advertised")); + assert!(unavailable + .text_joined() + .contains("vision analysis backend is not available")); + assert_eq!(handle.apply_calls.load(Ordering::Acquire), 0); + assert_eq!(handle.timeline(), before); + + // A valid write is normalized to exactly one existing EditCommand. The + // command boundary owns the mutation and its single undo restores the exact + // prior timeline. + let crop = Crop { + left: 0.1, + top: 0.0, + right: 0.1, + bottom: 0.0, + }; + let plan = plan_smart_reframe(&["clip-a".into()], crop, None).expect("reframe plan"); + assert_eq!(plan.label, "smart_reframe"); + assert_eq!(plan.commands.len(), 1); + let applied = handle + .apply(plan.commands[0].clone()) + .expect("atomic command"); + assert!(applied.changed); + assert_eq!(handle.apply_calls.load(Ordering::Acquire), 1); + assert_eq!(handle.timeline().tracks[0].clips[0].crop, crop); + handle.apply(EditCommand::Undo).expect("single undo"); + assert_eq!(handle.timeline(), before); + + // Rejected plans are typed, remain command-free, and preserve state. + let rejected = plan_smart_reframe(&[], crop, None).expect_err("empty clip ids must fail"); + assert!(rejected.to_string().contains("empty clipIds")); + assert_eq!(handle.timeline(), before); + + let entry = IntentClipEntry { + media_ref: "asset-1".into(), + media_type: ClipType::Video, + source_clip_type: ClipType::Video, + track_index: None, + start_frame: 0, + duration_frames: 5, + trim_start_frame: None, + trim_end_frame: None, + has_audio: true, + add_linked_audio: true, + transform: None, + }; + let beat_plan = + plan_beat_sync_placement(&before, vec![entry.clone()], &[3]).expect("beat placement plan"); + assert_eq!(beat_plan.commands.len(), 1); + assert!(matches!( + beat_plan.commands[0], + EditCommand::AddClipsAutoTrack { .. } + )); + let bad_beats = + plan_beat_sync_placement(&before, vec![entry], &[]).expect_err("missing beat must fail"); + assert!(bad_beats.to_string().contains("Need at least 1 beat")); + assert_eq!(handle.timeline(), before); +} diff --git a/crates/opentake-agent/tests/generation_dispatch.rs b/crates/opentake-agent/tests/generation_dispatch.rs new file mode 100644 index 00000000..46c9e9af --- /dev/null +++ b/crates/opentake-agent/tests/generation_dispatch.rs @@ -0,0 +1,285 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex, RwLock}; + +use opentake_agent::mcp::core_handle::AppCoreHandle; +use opentake_agent::mcp::dispatch::Dispatcher; +use opentake_agent::mcp::generation::{ + finalize_terminal_outputs, DownloadedGenerationArtifact, GenerationArtifactDownloader, + GenerationBridge, GenerationFinalizationStore, GenerationRequest, GenerationSubmission, +}; +use opentake_agent::plugin::registry::PluginRegistry; +use opentake_core::AppCore; +use serde_json::json; + +#[derive(Default)] +struct RecordingStore { + claimed: Mutex>, + completed: Mutex>, + finalized: Mutex>, + failed: Mutex>, + completions: Mutex>, + output_write_failures_remaining: Mutex, +} + +impl GenerationFinalizationStore for RecordingStore { + fn claim_terminal(&self, job_id: &str) -> Result { + if self.completed.lock().unwrap().contains(job_id) { + return Ok(false); + } + Ok(self.claimed.lock().unwrap().insert(job_id.to_string())) + } + + fn release_terminal(&self, job_id: &str) -> Result<(), String> { + self.claimed.lock().unwrap().remove(job_id); + Ok(()) + } + + fn finalize_output( + &self, + asset_id: &str, + artifact: DownloadedGenerationArtifact, + ) -> Result<(), String> { + let mut failures = self.output_write_failures_remaining.lock().unwrap(); + if *failures > 0 { + *failures -= 1; + return Err("transient manifest write failure".to_string()); + } + self.finalized + .lock() + .unwrap() + .insert(asset_id.to_string(), artifact.path); + Ok(()) + } + + fn fail_output(&self, asset_id: &str, code: &str) -> Result<(), String> { + let mut failures = self.output_write_failures_remaining.lock().unwrap(); + if *failures > 0 { + *failures -= 1; + return Err("transient manifest write failure".to_string()); + } + self.failed + .lock() + .unwrap() + .insert(asset_id.to_string(), code.to_string()); + Ok(()) + } + + fn complete_job(&self, job_id: &str, succeeded: usize, failed: usize) -> Result<(), String> { + if !self.completed.lock().unwrap().insert(job_id.to_string()) { + return Ok(()); + } + self.completions + .lock() + .unwrap() + .push((job_id.to_string(), succeeded, failed)); + Ok(()) + } +} + +struct FixtureDownloader; + +impl GenerationArtifactDownloader for FixtureDownloader { + fn download(&self, asset_id: &str, url: &str) -> Result { + if url.contains("download-fails") { + return Err("provider download failed with private detail".to_string()); + } + Ok(DownloadedGenerationArtifact { + path: PathBuf::from(format!("/fixture/{asset_id}.bin")), + media_type: "application/octet-stream".to_string(), + byte_size: 7, + }) + } +} + +struct RecordingGenerationBridge { + available: AtomicBool, + submissions: AtomicUsize, +} + +impl RecordingGenerationBridge { + fn new(available: bool) -> Self { + Self { + available: AtomicBool::new(available), + submissions: AtomicUsize::new(0), + } + } +} + +impl GenerationBridge for RecordingGenerationBridge { + fn can_generate(&self) -> bool { + self.available.load(Ordering::Acquire) + } + + fn submit( + &self, + _request: GenerationRequest, + _cancel: &opentake_media::MediaCancelToken, + ) -> Result { + self.submissions.fetch_add(1, Ordering::AcqRel); + Ok(GenerationSubmission { + job_id: "job-dispatch".to_string(), + placeholder_asset_ids: vec!["asset-placeholder".to_string()], + status: "queued".to_string(), + }) + } +} + +fn dispatcher_with_generation_bridge(bridge: Arc) -> Dispatcher { + Dispatcher::with_bridges( + Arc::new(AppCoreHandle::new(AppCore::new())), + Arc::new(RwLock::new(PluginRegistry::new())), + None, + Some(bridge), + ) +} + +#[test] +fn placeholder_persist_finalize_all_results_and_failures() { + let store = RecordingStore::default(); + let summary = finalize_terminal_outputs( + &store, + &FixtureDownloader, + "job-1", + &[ + "asset-a".into(), + "asset-b".into(), + "asset-c".into(), + "asset-d".into(), + ], + &[ + "https://results.test/a.png".into(), + "https://results.test/download-fails.png".into(), + "not-a-result-url".into(), + "https://results.test/extra.png".into(), + "https://results.test/ignored-extra.png".into(), + ], + ) + .expect("terminal outputs are recorded"); + + assert!(summary.claimed); + assert_eq!(summary.succeeded, 2); + assert_eq!(summary.failed, 2); + assert_eq!(summary.ignored_result_urls, 1); + assert_eq!( + store + .finalized + .lock() + .unwrap() + .keys() + .cloned() + .collect::>(), + vec!["asset-a", "asset-d"] + ); + assert_eq!( + store.failed.lock().unwrap().clone(), + BTreeMap::from([ + ( + "asset-b".to_string(), + "GENERATION_DOWNLOAD_FAILED".to_string() + ), + ( + "asset-c".to_string(), + "GENERATION_RESULT_URL_INVALID".to_string() + ), + ]) + ); + assert_eq!( + store.completions.lock().unwrap().as_slice(), + &[("job-1".to_string(), 2, 2)] + ); +} + +#[test] +fn placeholder_persists_and_every_terminal_result_finalizes_once() { + let store = RecordingStore::default(); + let placeholders = vec!["asset-a".to_string(), "asset-b".to_string()]; + let urls = vec!["https://results.test/a.png".to_string()]; + + let first = + finalize_terminal_outputs(&store, &FixtureDownloader, "job-once", &placeholders, &urls) + .expect("first terminal callback succeeds"); + let duplicate = + finalize_terminal_outputs(&store, &FixtureDownloader, "job-once", &placeholders, &urls) + .expect("duplicate terminal callback is idempotent"); + + assert!(first.claimed); + assert_eq!((first.succeeded, first.failed), (1, 1)); + assert!(!duplicate.claimed); + assert_eq!((duplicate.succeeded, duplicate.failed), (0, 0)); + assert_eq!(store.finalized.lock().unwrap().len(), 1); + assert_eq!( + store + .failed + .lock() + .unwrap() + .get("asset-b") + .map(String::as_str), + Some("GENERATION_RESULT_MISSING") + ); + assert_eq!(store.completions.lock().unwrap().len(), 1); + + let retryable = RecordingStore { + output_write_failures_remaining: Mutex::new(2), + ..Default::default() + }; + let first_attempt = finalize_terminal_outputs( + &retryable, + &FixtureDownloader, + "job-retry", + &["asset-retry".to_string()], + &["https://results.test/retry.png".to_string()], + ); + assert!(first_attempt.is_err()); + assert!(retryable.claimed.lock().unwrap().is_empty()); + + let recovered = finalize_terminal_outputs( + &retryable, + &FixtureDownloader, + "job-retry", + &["asset-retry".to_string()], + &["https://results.test/retry.png".to_string()], + ) + .expect("restart recovery can reacquire the released terminal lease"); + assert!(recovered.claimed); + assert_eq!((recovered.succeeded, recovered.failed), (1, 0)); + assert_eq!(retryable.completions.lock().unwrap().len(), 1); +} + +#[test] +fn configured_capability_and_cost_authorization_gate_dispatch() { + let bridge = Arc::new(RecordingGenerationBridge::new(false)); + let dispatcher = dispatcher_with_generation_bridge(bridge.clone()); + + let timeline = dispatcher.dispatch("get_timeline", json!({})); + assert!(!timeline.is_error); + assert!(timeline.text_joined().contains("\"canGenerate\":false")); + + let unavailable = dispatcher.dispatch( + "generate_image", + json!({"costAuthorized": true, "prompt": "fixture"}), + ); + assert!(unavailable.is_error); + assert_eq!(bridge.submissions.load(Ordering::Acquire), 0); + + bridge.available.store(true, Ordering::Release); + let timeline = dispatcher.dispatch("get_timeline", json!({})); + assert!(timeline.text_joined().contains("\"canGenerate\":true")); + + let unauthorized = dispatcher.dispatch( + "generate_image", + json!({"costAuthorized": false, "prompt": "fixture"}), + ); + assert!(unauthorized.is_error); + assert_eq!(bridge.submissions.load(Ordering::Acquire), 0); + + let accepted = dispatcher.dispatch( + "generate_image", + json!({"costAuthorized": true, "prompt": "fixture"}), + ); + assert!(!accepted.is_error, "{}", accepted.text_joined()); + assert!(accepted.text_joined().contains("job-dispatch")); + assert!(accepted.text_joined().contains("asset-placeholder")); + assert_eq!(bridge.submissions.load(Ordering::Acquire), 1); +} diff --git a/crates/opentake-agent/tests/get_media_redaction.rs b/crates/opentake-agent/tests/get_media_redaction.rs new file mode 100644 index 00000000..fc736604 --- /dev/null +++ b/crates/opentake-agent/tests/get_media_redaction.rs @@ -0,0 +1,203 @@ +use std::path::PathBuf; +use std::sync::{Arc, RwLock}; + +use opentake_agent::mcp::core_handle::CoreHandle; +use opentake_agent::mcp::dispatch::Dispatcher; +use opentake_agent::plugin::registry::PluginRegistry; +use opentake_agent::tools::result::Block; +use opentake_domain::{ + ClipType, GenerationInput, GenerationJobStatus, MediaColorMetadata, MediaFolder, MediaManifest, + MediaManifestEntry, MediaProxy, MediaSource, Timeline, +}; +use opentake_ops::{EditCommand, EditResult}; +use serde_json::{json, Value}; + +struct MediaHandle { + manifest: MediaManifest, +} + +impl CoreHandle for MediaHandle { + fn timeline(&self) -> Timeline { + Timeline::new() + } + + fn media(&self) -> MediaManifest { + self.manifest.clone() + } + + fn apply(&self, _command: EditCommand) -> anyhow::Result { + anyhow::bail!("read-only fixture") + } + + fn project_dir(&self) -> Option { + None + } +} + +fn get_media(manifest: MediaManifest) -> (opentake_agent::tools::result::ToolResult, Value) { + let dispatcher = Dispatcher::new( + Arc::new(MediaHandle { manifest }), + Arc::new(RwLock::new(PluginRegistry::new())), + ); + let result = dispatcher.dispatch("get_media", json!({})); + assert!(!result.is_error, "{}", result.text_joined()); + let text = match result.content.first() { + Some(Block::Text { text }) => text, + other => panic!("expected JSON text block, got {other:?}"), + }; + let payload = serde_json::from_str(text).expect("get_media JSON"); + (result, payload) +} + +#[test] +fn get_media_tool_result_allowlists_model_safe_metadata() { + let mut manifest = MediaManifest::new(); + manifest.folders.push(MediaFolder { + id: "folder-safe".into(), + name: "References".into(), + parent_folder_id: None, + }); + manifest.entries.push(MediaManifestEntry { + id: "asset-safe-1".into(), + name: "Hero shot".into(), + kind: ClipType::Video, + source: MediaSource::External { + absolute_path: "/Users/private/secret.mov".into(), + }, + duration: 12.34567, + generation_input: Some(GenerationInput { + prompt: "PROVIDER_INPUT_SECRET".into(), + model: "private-provider-model".into(), + duration: 12, + aspect_ratio: "16:9".into(), + image_urls: Some(vec![ + "https://reference.invalid/input?token=IMAGE_SECRET".into() + ]), + reference_image_urls: Some(vec![ + "https://reference.invalid/image?token=REFERENCE_IMAGE_SECRET".into(), + ]), + reference_video_urls: Some(vec![ + "https://reference.invalid/video?token=REFERENCE_VIDEO_SECRET".into(), + ]), + reference_audio_urls: Some(vec![ + "https://reference.invalid/audio?token=REFERENCE_AUDIO_SECRET".into(), + ]), + provider: Some("private-provider".into()), + provider_job_id: Some("PROVIDER_JOB_SECRET".into()), + status: Some(GenerationJobStatus::Generating), + progress: Some(0.45678), + error_code: Some("PROVIDER_DIAGNOSTIC_SECRET".into()), + ..GenerationInput::default() + }), + source_width: Some(3840), + source_height: Some(2160), + source_fps: Some(23.97654), + has_audio: Some(true), + color: Some(MediaColorMetadata { + primaries: Some("bt2020".into()), + transfer: Some("smpte2084".into()), + matrix: Some("bt2020nc".into()), + range: Some("SOURCE_COLOR_DIAGNOSTIC_SECRET".into()), + }), + proxy: Some(MediaProxy { + relative_path: "proxy/private.mov?token=PROXY_SECRET".into(), + source_sha256: "SOURCE_DIGEST_SECRET".into(), + width: 960, + height: 540, + }), + folder_id: Some("folder-safe".into()), + cached_remote_url: Some("https://host/file?token=SECRET".into()), + cached_remote_url_expires_at: Some(999_999_999.0), + }); + manifest.favorite_library_ids.insert( + "asset-safe-1".into(), + "GLOBAL_LIBRARY_INTERNAL_SECRET".into(), + ); + + let (result, payload) = get_media(manifest); + let serialized_result = serde_json::to_string(&result).expect("serialize tool result"); + + for secret in [ + "https://host/file?token=SECRET", + "IMAGE_SECRET", + "REFERENCE_IMAGE_SECRET", + "REFERENCE_VIDEO_SECRET", + "REFERENCE_AUDIO_SECRET", + "/Users/private/secret.mov", + "PROVIDER_INPUT_SECRET", + "PROVIDER_JOB_SECRET", + "PROVIDER_DIAGNOSTIC_SECRET", + "SOURCE_COLOR_DIAGNOSTIC_SECRET", + "PROXY_SECRET", + "SOURCE_DIGEST_SECRET", + "GLOBAL_LIBRARY_INTERNAL_SECRET", + ] { + assert!( + !serialized_result.contains(secret), + "tool result leaked {secret}: {serialized_result}" + ); + } + + let entry = &payload["entries"][0]; + assert_eq!(entry["id"], json!("asset-safe-1")); + assert_eq!(entry["name"], json!("Hero shot")); + assert_eq!(entry["type"], json!("video")); + assert_eq!(entry["folderId"], json!("folder-safe")); + assert_eq!(entry["duration"], json!(12.346)); + assert_eq!(entry["sourceWidth"], json!(3840)); + assert_eq!(entry["sourceHeight"], json!(2160)); + assert_eq!(entry["sourceFPS"], json!(23.977)); + assert_eq!(entry["hasAudio"], json!(true)); + assert_eq!(entry["hasProxy"], json!(true)); + assert_eq!(entry["isHdr"], json!(true)); + assert_eq!(entry["generationStatus"], json!("generating")); + assert_eq!(entry["generationProgress"], json!(0.457)); + assert_eq!(payload["folders"][0]["name"], json!("References")); + + for forbidden_key in [ + "source", + "generationInput", + "generationErrorCode", + "cachedRemoteURL", + "cachedRemoteURLExpiresAt", + "color", + "proxy", + "favoriteLibraryIds", + ] { + assert!( + !serialized_result.contains(&format!("\"{forbidden_key}\"")), + "tool result exposed forbidden key {forbidden_key}: {serialized_result}" + ); + } +} + +#[test] +fn get_media_keeps_empty_and_legacy_entries_usable() { + let (_, empty) = get_media(MediaManifest::new()); + assert_eq!(empty["version"], json!(2)); + assert_eq!(empty["entries"], json!([])); + assert_eq!(empty["folders"], json!([])); + + let legacy_manifest: MediaManifest = serde_json::from_value(json!({ + "entries": [{ + "id": "legacy-asset", + "name": "Legacy clip", + "type": "audio", + "source": {"project": {"relativePath": "media/legacy.wav"}}, + "duration": 0.0 + }], + "folders": [] + })) + .expect("legacy manifest"); + let (legacy_result, legacy) = get_media(legacy_manifest); + assert!(!legacy_result.text_joined().contains("media/legacy.wav")); + assert_eq!(legacy["version"], json!(1)); + assert_eq!(legacy["entries"][0]["id"], json!("legacy-asset")); + assert_eq!(legacy["entries"][0]["name"], json!("Legacy clip")); + assert_eq!(legacy["entries"][0]["type"], json!("audio")); + assert_eq!(legacy["entries"][0]["duration"], json!(0.0)); + assert_eq!(legacy["entries"][0]["generationStatus"], json!("none")); + assert_eq!(legacy["entries"][0]["hasProxy"], json!(false)); + assert_eq!(legacy["entries"][0]["isHdr"], json!(false)); + assert!(legacy["entries"][0].get("source").is_none()); +} diff --git a/crates/opentake-agent/tests/mcp_http.rs b/crates/opentake-agent/tests/mcp_http.rs index ffed5c24..ce9dbf1d 100644 --- a/crates/opentake-agent/tests/mcp_http.rs +++ b/crates/opentake-agent/tests/mcp_http.rs @@ -507,13 +507,10 @@ async fn transport_rejects_nonfinite_numbers_before_dispatch() { .expect("raw non-finite request sent"); let status = response.status(); let text = response.text().await.expect("parser response body"); - assert!( - status.is_client_error() - && (text.contains("deserialize") - || text.contains("expected value") - || text.contains("number out of range") - || text.contains("\"error\"")), - "{number} was not rejected by the JSON/MCP parser: {status} {text}" + assert_eq!(status, reqwest::StatusCode::BAD_REQUEST, "{number}: {text}"); + assert_eq!( + text, "entries[3].startFrame: value must be finite", + "{number} path/message drifted" ); assert_eq!( calls.load(Ordering::Acquire), diff --git a/crates/opentake-agent/tests/tool_argument_contract.rs b/crates/opentake-agent/tests/tool_argument_contract.rs index 4b5f9afe..cfd4fec1 100644 --- a/crates/opentake-agent/tests/tool_argument_contract.rs +++ b/crates/opentake-agent/tests/tool_argument_contract.rs @@ -6,6 +6,7 @@ use opentake_agent::mcp::core_handle::CoreHandle; use opentake_agent::mcp::dispatch::Dispatcher; use opentake_agent::plugin::registry::PluginRegistry; use opentake_agent::tools::descriptions::input_schema; +use opentake_agent::tools::errors::first_non_finite_json_number_path; use opentake_agent::tools::names::ToolName; use opentake_domain::{MediaManifest, Timeline}; use opentake_ops::{EditCommand, EditResult}; @@ -227,6 +228,17 @@ fn all_tool_schemas_reject_unknown_missing_wrong_type() { ); } + let raw_nonfinite = br#"{"params":{"arguments":{"entries":[ + {"mediaRef":"asset","startFrame":0,"durationFrames":1}, + {"mediaRef":"asset","startFrame":0,"durationFrames":1}, + {"mediaRef":"asset","startFrame":0,"durationFrames":1}, + {"mediaRef":"asset","startFrame":1e400,"durationFrames":1} + ]}}}"#; + assert_eq!( + first_non_finite_json_number_path(raw_nonfinite).as_deref(), + Some("entries[3].startFrame") + ); + let add_texts_schema = input_schema(ToolName::AddTexts); let text_transform_schema = add_texts_schema .pointer("/properties/entries/items/properties/transform") @@ -295,7 +307,7 @@ fn all_tool_schemas_reject_unknown_missing_wrong_type() { }), ); assert!( - motion.text_joined().contains("not yet implemented"), + motion.text_joined().contains("not advertised"), "dynamic motion params must remain open: {}", motion.text_joined() ); diff --git a/crates/opentake-core/Cargo.toml b/crates/opentake-core/Cargo.toml index b8eee2ca..446fd06a 100644 --- a/crates/opentake-core/Cargo.toml +++ b/crates/opentake-core/Cargo.toml @@ -14,3 +14,6 @@ opentake-ops = { workspace = true } opentake-project = { workspace = true } thiserror = "2" same-file = "1.0.6" + +[dev-dependencies] +tempfile = "3" diff --git a/crates/opentake-core/src/core.rs b/crates/opentake-core/src/core.rs index 49651edc..3824f553 100644 --- a/crates/opentake-core/src/core.rs +++ b/crates/opentake-core/src/core.rs @@ -27,20 +27,26 @@ //! reached through the session. use std::collections::BTreeMap; +use std::fs; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex, RwLock, RwLockReadGuard}; -use opentake_domain::{MediaManifest, MediaManifestEntry, Timeline}; -use opentake_ops::command::{EditCommand, EditResult}; +use opentake_domain::{ + ClipType, GenerationInput, MediaAsset, MediaManifest, MediaManifestEntry, MediaProxy, Timeline, +}; +use opentake_ops::command::{ClipEntry, EditCommand, EditResult}; use opentake_ops::IdGen; -use opentake_project::{GenerationLog, ProjectCompatibility}; +use opentake_project::{GenerationLog, ProjectCompatibility, ProjectRootIdentity}; use same_file::Handle; use crate::deps::CoreDeps; use crate::error::{CoreError, Result}; use crate::events::{CoreEvent, EventBus, SubscriptionId}; -use crate::session::{EditorSession, ProbedMedia}; +use crate::session::{ + DerivedStemProvenance, EditorSession, GenerationJobCommit, GenerationStateUpdate, + PreparedGenerationJob, PreparedGenerationOutput, ProbedMedia, +}; type ProjectIdentityTransitionListener = Arc; @@ -108,6 +114,44 @@ pub struct ProjectRevision { pub version: u64, } +/// Retained bundle authority for project-local asset reads. Consumers must +/// compare all fields again after any isolated I/O before exposing bytes. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ProjectAssetAuthority { + pub project_epoch: u64, + pub project_path: PathBuf, + pub root_identity: ProjectRootIdentity, +} + +/// Outcome of an assistant-owned conditional undo. The project identity, +/// document version, history transaction id, action label, and Undo application +/// are checked under one session lock. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum OwnedUndoResult { + Undone(EditResult), + NoHistory, + Conflict { + actual_action_name: Option, + actual_transaction_version: Option, + }, +} + +/// One-lock snapshot of project revision plus the exact top undo transaction. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ProjectUndoSnapshot { + pub revision: ProjectRevision, + pub project_path: Option, + pub action_name: String, + pub transaction_version: u64, +} + +#[derive(Clone, Copy)] +struct EditExpectation<'a> { + revision: ProjectRevision, + project_path: Option<&'a Path>, + check_project_path: bool, +} + /// One-lock snapshot of the state consumed by runtime media operations. #[derive(Clone, Debug)] pub struct ProjectRuntimeSnapshot { @@ -123,6 +167,31 @@ pub struct ProjectRuntimeSnapshot { pub version: u64, } +/// Placement half of a project-managed motion render commit. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum MotionPlacement { + Add { + start_frame: i32, + duration_frames: i32, + track_index: Option, + }, + Replace { + clip_id: String, + }, + /// Replace a clip with a derivative that already contains the result of + /// its masks, then clear those editable masks in the same undo snapshot. + ReplaceAndClearMasks { + clip_id: String, + }, +} + +/// Result of atomically registering a rendered video and placing/replacing it. +#[derive(Clone, Debug)] +pub struct MotionMediaCommit { + pub media: MediaManifestEntry, + pub edit: EditResult, +} + /// A folder target in a prepared media-import plan. Planned folders are keyed /// by the scanner without consuming application ids; existing folders retain /// their authoritative project id. @@ -147,6 +216,12 @@ pub enum PreparedMediaImportOp { probe: ProbedMedia, folder: Option, }, + ImportDerivedStem { + path: PathBuf, + name: String, + probe: ProbedMedia, + provenance: DerivedStemProvenance, + }, } /// One file admitted by a successful durable batch import. @@ -201,6 +276,22 @@ pub struct PreparedProjectOpen { editor: EditorSession, } +impl PreparedProjectOpen { + /// Confirm that the ambient bundle name still denotes the retained root + /// that was prepared. This closes the prepare→scope/commit replacement + /// window; asset reads additionally compare the retained root identity. + pub fn is_current_namespace(&self) -> Result { + self.editor.project_root_is_current_namespace() + } + + pub fn project_asset_authority(&self) -> Option<(PathBuf, ProjectRootIdentity)> { + Some(( + self.editor.project_dir()?.to_path_buf(), + self.editor.project_root_identity()?, + )) + } +} + impl CoreSessionSlot { fn timeline_snapshot(&self) -> TimelineSnapshot { TimelineSnapshot { @@ -369,6 +460,33 @@ impl AppCore { } } + /// Snapshot the exact retained bundle authority under the same session + /// lock as its epoch/path. Isolated readers must compare this tuple again + /// before publishing bytes so a project switch or namespace rebind fails + /// closed. + pub fn project_asset_authority(&self) -> Option { + let session = self.lock(); + Some(ProjectAssetAuthority { + project_epoch: session.project_epoch, + project_path: session.editor.project_dir()?.to_path_buf(), + root_identity: session.editor.project_root_identity()?, + }) + } + + pub fn project_asset_authority_matches(&self, expected: &ProjectAssetAuthority) -> bool { + self.project_asset_authority().as_ref() == Some(expected) + } + + /// Open a project-local asset through the retained no-follow bundle + /// authority, under the same session lock that snapshots the authority. + /// Callers that publish derived bytes should compare + /// [`Self::project_asset_authority`] again before exposing them so a + /// project switch or namespace rebind fails closed. + pub fn open_project_asset(&self, relative: &Path) -> Result { + let session = self.lock(); + session.editor.open_asset_file(relative) + } + /// Return a mutable-project runtime snapshot only when the caller's IPC /// identity still names the current project. This is the authorization gate /// for workflows that perform global I/O before their final project commit. @@ -472,6 +590,87 @@ impl AppCore { self.lock().editor.can_undo() } + /// Label of the most recent undoable transaction, if any. Callers that act + /// on this value must still use a revision-bound apply for the final Undo; + /// the version comparison closes the check/commit race. + pub fn undo_action_name(&self) -> Option { + self.lock().editor.undo_action_name().map(str::to_owned) + } + + /// Stable version identity of the transaction currently at the top of the + /// undo stack. + pub fn undo_transaction_version(&self) -> Option { + self.lock().editor.undo_transaction_version() + } + + /// Read the full undo ownership tuple under one session lock. + pub fn project_undo_snapshot(&self) -> Option { + let session = self.lock(); + Some(ProjectUndoSnapshot { + revision: ProjectRevision { + project_epoch: session.project_epoch, + version: session.editor.version(), + }, + project_path: session.editor.project_dir().map(PathBuf::from), + action_name: session.editor.undo_action_name()?.to_owned(), + transaction_version: session.editor.undo_transaction_version()?, + }) + } + + /// Undo only when the exact assistant-owned history transaction is still at + /// the top of the same project revision. Equal action labels are insufficient: + /// `expected_transaction_version` distinguishes a later user edit with the + /// same label. All comparisons and the Undo share one session lock. + pub fn undo_if_owned( + &self, + expected: ProjectRevision, + expected_project_path: Option<&Path>, + expected_action_name: &str, + expected_transaction_version: u64, + ) -> Result { + let outcome = { + let mut session = self.lock(); + if session.project_epoch != expected.project_epoch + || session.editor.version() != expected.version + || session.editor.project_dir() != expected_project_path + { + return Err(CoreError::StaleProject); + } + if !session.editor.can_undo() { + return Ok(OwnedUndoResult::NoHistory); + } + let actual_action_name = session.editor.undo_action_name().map(str::to_owned); + let actual_transaction_version = session.editor.undo_transaction_version(); + if actual_action_name.as_deref() != Some(expected_action_name) + || actual_transaction_version != Some(expected_transaction_version) + { + return Ok(OwnedUndoResult::Conflict { + actual_action_name, + actual_transaction_version, + }); + } + let edit = session.editor.apply(EditCommand::Undo, self.ids.as_ref())?; + let media_count = edit + .manifest_changed + .then(|| session.editor.media().entries.len()); + (edit, session.project_epoch, media_count) + }; + let (edit, project_epoch, media_count) = outcome; + if edit.changed { + self.events.emit(&CoreEvent::TimelineChanged { + project_epoch, + version: edit.timeline_version, + }); + } + if let Some(count) = media_count { + self.events.emit(&CoreEvent::MediaChanged { + project_epoch, + count, + }); + } + Ok(OwnedUndoResult::Undone(edit)) + } + /// Whether a redo is currently available. pub fn can_redo(&self) -> bool { self.lock().editor.can_redo() @@ -503,23 +702,104 @@ impl AppCore { expected: ProjectRevision, command: EditCommand, ) -> Result { - self.apply_with_revision(command, Some(expected)) + self.apply_with_revision( + command, + Some(EditExpectation { + revision: expected, + project_path: None, + check_project_path: false, + }), + ) + } + + /// Apply an IPC edit only when the project epoch, saved bundle path, and + /// timeline version all still match the read-only mirror that produced the + /// gesture. The complete identity check and edit transaction share the + /// same session lock, so a delayed request can never fall through to a + /// replacement project that happens to contain the same clip/media ids. + pub fn apply_at_project_revision( + &self, + expected: ProjectRevision, + expected_project_path: Option<&Path>, + command: EditCommand, + ) -> Result { + self.apply_with_revision( + command, + Some(EditExpectation { + revision: expected, + project_path: expected_project_path, + check_project_path: true, + }), + ) + } + + /// Apply one revision-bound edit and durably save the project under the + /// same session lock. Persistence failure restores document, history, and + /// version exactly before returning. + pub fn apply_at_revision_persisted( + &self, + expected: ProjectRevision, + command: EditCommand, + ) -> Result { + let (result, project_epoch, media_count, written) = { + let mut session = self.lock(); + if session.project_epoch != expected.project_epoch + || session.editor.version() != expected.version + { + return Err(CoreError::StaleProject); + } + let before = session.editor.checkpoint_editor_state(); + let outcome = (|| { + let result = session.editor.apply(command, self.ids.as_ref())?; + let media_count = result + .manifest_changed + .then(|| session.editor.media().entries.len()); + let written = session.editor.save_project(None)?; + Ok((result, media_count, written)) + })(); + match outcome { + Ok((result, media_count, written)) => { + (result, session.project_epoch, media_count, written) + } + Err(error) => { + session.editor.restore_editor_state(before); + return Err(error); + } + } + }; + if result.changed { + self.events.emit(&CoreEvent::TimelineChanged { + project_epoch, + version: result.timeline_version, + }); + } + if let Some(count) = media_count { + self.events.emit(&CoreEvent::MediaChanged { + project_epoch, + count, + }); + } + self.events.emit(&CoreEvent::ProjectSaved { + path: written.to_string_lossy().into_owned(), + project_epoch, + }); + Ok(result) } fn apply_with_revision( &self, command: EditCommand, - expected: Option, + expected: Option>, ) -> Result { let (result, project_epoch, media_count) = { let mut session = self.lock(); if expected.is_some_and(|expected| { - session.project_epoch != expected.project_epoch - || session.editor.version() != expected.version + session.project_epoch != expected.revision.project_epoch + || session.editor.version() != expected.revision.version + || (expected.check_project_path + && session.editor.project_dir() != expected.project_path) }) { - return Err(CoreError::Media( - "project changed while preparing a deferred edit".to_string(), - )); + return Err(CoreError::StaleProject); } let result = session.editor.apply(command, self.ids.as_ref())?; let media_count = result @@ -631,6 +911,35 @@ impl AppCore { &self, path: Option, thumbnail: Option>, + ) -> Result { + self.save_project_with_thumbnail_at_identity(None, None, path, thumbnail) + } + + /// Save only if the project session still has the caller's exact identity. + /// This binds deferred thumbnail generation and stale IPC requests to the + /// project that initiated them; a concurrently opened replacement can never + /// be written to the old request's Save As destination. + pub fn save_project_with_thumbnail_for_project( + &self, + expected_project_epoch: u64, + expected_project_path: Option<&Path>, + path: Option, + thumbnail: Option>, + ) -> Result { + self.save_project_with_thumbnail_at_identity( + Some(expected_project_epoch), + expected_project_path, + path, + thumbnail, + ) + } + + fn save_project_with_thumbnail_at_identity( + &self, + expected_project_epoch: Option, + expected_project_path: Option<&Path>, + path: Option, + thumbnail: Option>, ) -> Result { let changes_identity = path.is_some(); if changes_identity { @@ -642,10 +951,17 @@ impl AppCore { .unwrap_or_else(|poisoned| poisoned.into_inner()); let result = { let mut session = self.lock(); - session - .editor - .save_project_with_thumbnail(path, thumbnail) - .map(|written| (written, session.project_epoch)) + if expected_project_epoch.is_some_and(|epoch| { + session.project_epoch != epoch + || session.editor.project_dir() != expected_project_path + }) { + Err(CoreError::StaleProject) + } else { + session + .editor + .save_project_with_thumbnail(path, thumbnail) + .map(|written| (written, session.project_epoch)) + } }; drop(_identity); if changes_identity { @@ -676,6 +992,152 @@ impl AppCore { self.lock().editor.generation_log().clone() } + /// Persist placeholder assets and the queued audit event before a paid + /// provider request is submitted. No ids are returned unless the project + /// snapshot is durable. + pub fn begin_generation_job_for_project( + &self, + expected_project_epoch: u64, + expected_project_dir: &Path, + plan: PreparedGenerationJob, + ) -> Result { + self.persist_generation_mutation( + expected_project_epoch, + expected_project_dir, + |editor, ids| editor.begin_generation_job(plan, ids), + ) + } + + pub fn update_generation_job_for_project( + &self, + expected_project_epoch: u64, + expected_project_dir: &Path, + job_id: &str, + update: GenerationStateUpdate, + ) -> Result { + self.persist_generation_mutation( + expected_project_epoch, + expected_project_dir, + |editor, ids| editor.update_generation_job(job_id, update, ids), + ) + } + + pub fn finalize_generation_output_for_project( + &self, + expected_project_epoch: u64, + expected_project_dir: &Path, + output: PreparedGenerationOutput, + ) -> Result<()> { + self.persist_generation_mutation( + expected_project_epoch, + expected_project_dir, + |editor, ids| editor.finalize_generation_output(output, ids), + ) + } + + /// Finalize a generated output and stream its media bytes into the same + /// complete-bundle publication as the manifest and generation log. + pub fn finalize_generation_output_with_media_for_project( + &self, + expected_project_epoch: u64, + expected_project_dir: &Path, + output: PreparedGenerationOutput, + media_leaf: &str, + media_byte_size: u64, + media: &mut dyn std::io::Read, + ) -> Result<()> { + let expected_relative_path = format!("media/{media_leaf}"); + if output.relative_path != expected_relative_path { + return Err(CoreError::Media( + "generation output path does not match its media leaf".to_string(), + )); + } + self.persist_generation_mutation_using( + expected_project_epoch, + expected_project_dir, + |editor, ids| editor.finalize_generation_output(output, ids), + |editor| editor.save_generation_state_with_media(media_leaf, media_byte_size, media), + ) + } + + pub fn fail_generation_output_for_project( + &self, + expected_project_epoch: u64, + expected_project_dir: &Path, + asset_id: &str, + error_code: &str, + created_at: Option, + ) -> Result<()> { + self.persist_generation_mutation( + expected_project_epoch, + expected_project_dir, + |editor, ids| editor.fail_generation_output(asset_id, error_code, created_at, ids), + ) + } + + pub fn cancel_generation_output_for_project( + &self, + expected_project_epoch: u64, + expected_project_dir: &Path, + asset_id: &str, + created_at: Option, + ) -> Result<()> { + self.persist_generation_mutation( + expected_project_epoch, + expected_project_dir, + |editor, ids| editor.cancel_generation_output(asset_id, created_at, ids), + ) + } + + fn persist_generation_mutation( + &self, + expected_project_epoch: u64, + expected_project_dir: &Path, + mutate: impl FnOnce(&mut EditorSession, &dyn IdGen) -> Result, + ) -> Result { + self.persist_generation_mutation_using( + expected_project_epoch, + expected_project_dir, + mutate, + |editor| editor.save_generation_state(), + ) + } + + fn persist_generation_mutation_using( + &self, + expected_project_epoch: u64, + expected_project_dir: &Path, + mutate: impl FnOnce(&mut EditorSession, &dyn IdGen) -> Result, + persist: impl FnOnce(&mut EditorSession) -> Result, + ) -> Result { + let (value, count, written) = { + let mut session = self.lock(); + ensure_project_identity(&session, expected_project_epoch, expected_project_dir)?; + let checkpoint = session.editor.checkpoint_generation_state(); + let result = (|| { + let value = mutate(&mut session.editor, self.ids.as_ref())?; + let written = persist(&mut session.editor)?; + Ok((value, written)) + })(); + match result { + Ok((value, written)) => (value, session.editor.media().entries.len(), written), + Err(error) => { + session.editor.restore_generation_state(checkpoint); + return Err(error); + } + } + }; + self.events.emit(&CoreEvent::MediaChanged { + project_epoch: expected_project_epoch, + count, + }); + self.events.emit(&CoreEvent::ProjectSaved { + path: written.to_string_lossy().into_owned(), + project_epoch: expected_project_epoch, + }); + Ok(value) + } + /// The open project's `.opentake` bundle directory, or `None` for an unsaved /// project. Needed to resolve [`MediaSource::Project`](opentake_domain::MediaSource) /// relative paths to on-disk files (preview/composite read the original media). @@ -712,6 +1174,22 @@ impl AppCore { Ok(entry) } + /// Prepare a local media manifest entry without registering it or emitting + /// any event. The returned entry carries a fresh id, but the authoritative + /// project remains byte-for-byte unchanged until a later edit command + /// commits it. This is the safe preparation half of deferred render edits. + pub fn prepare_media_file_entry( + &self, + path: impl AsRef, + name: impl Into, + probe: &ProbedMedia, + ) -> Result { + let id = self.ids.next_id(); + self.lock() + .editor + .prepare_media_file_entry(path, id, name, probe) + } + /// Import media only if the expected project still owns the session lock. /// /// Save-as-media renders without holding the core lock. Its final identity @@ -773,6 +1251,178 @@ impl AppCore { Ok(entry) } + /// Atomically register a completed project-managed motion render and place + /// or replace its timeline clip. + #[allow(clippy::too_many_arguments)] + pub fn commit_motion_media_for_project( + &self, + expected_project_epoch: u64, + expected_version: u64, + expected_project_dir: &Path, + path: impl AsRef, + name: impl Into, + probe: &ProbedMedia, + provenance: GenerationInput, + placement: MotionPlacement, + ) -> Result { + self.commit_generated_media_for_project( + expected_project_epoch, + expected_version, + expected_project_dir, + path, + name, + ClipType::Video, + probe, + provenance, + placement, + "Add Motion Graphic", + ) + } + + /// Atomically register a completed generated audio/video file and place or + /// replace its timeline clip. The generated file must already be a + /// regular, non-symlink child of the active bundle's `media/` directory. + /// The document command and project save share the session lock; any command + /// or persistence failure restores timeline, manifest, undo/redo, and + /// version exactly. A stale document version is rejected under that same + /// lock. Events are emitted only after the durable save succeeds. + #[allow(clippy::too_many_arguments)] + pub fn commit_generated_media_for_project( + &self, + expected_project_epoch: u64, + expected_version: u64, + expected_project_dir: &Path, + path: impl AsRef, + name: impl Into, + kind: ClipType, + probe: &ProbedMedia, + provenance: GenerationInput, + placement: MotionPlacement, + action_name: &str, + ) -> Result { + let path = path.as_ref(); + let media_dir = expected_project_dir.join(opentake_project::layout::MEDIA_DIR); + if path.parent() != Some(media_dir.as_path()) + || path.file_name().is_none() + || path.components().any(|component| { + matches!( + component, + std::path::Component::ParentDir | std::path::Component::CurDir + ) + }) + { + return Err(CoreError::Media( + "generated output must be one direct child of the active project media directory" + .into(), + )); + } + let metadata = std::fs::symlink_metadata(path) + .map_err(|error| CoreError::Media(format!("motion output metadata failed: {error}")))?; + if !metadata.file_type().is_file() || metadata.file_type().is_symlink() { + return Err(CoreError::Media( + "generated output must be a regular non-symlink file".into(), + )); + } + + let (commit, count, written) = { + let mut session = self.lock(); + ensure_project_identity(&session, expected_project_epoch, expected_project_dir)?; + if session.editor.version() != expected_version { + return Err(CoreError::Media( + "project changed while preparing a generated-media edit".into(), + )); + } + session.editor.ensure_mutable()?; + let before = session.editor.checkpoint_editor_state(); + let id = loop { + let candidate = self.ids.next_id(); + if session.editor.media_entry(&candidate).is_none() { + break candidate; + } + }; + + let result = (|| { + if !matches!(kind, ClipType::Audio | ClipType::Video) { + return Err(CoreError::Media( + "generated placement supports audio or video".into(), + )); + } + let mut asset = MediaAsset::new(id, path, kind, name, probe.duration_secs); + asset.source_width = probe.width; + asset.source_height = probe.height; + asset.source_fps = probe.fps; + asset.color = probe.color.clone(); + asset.has_audio = probe.has_audio; + asset.generation_input = Some(provenance); + let media = asset.to_manifest_entry(Some(expected_project_dir), 0.0); + + let command = match placement { + MotionPlacement::Add { + start_frame, + duration_frames, + track_index, + } => EditCommand::RegisterMediaAndAddClip { + entry: ClipEntry { + media_ref: media.id.clone(), + media_type: kind, + source_clip_type: kind, + track_index: track_index.unwrap_or(0), + start_frame, + duration_frames, + trim_start_frame: None, + trim_end_frame: None, + has_audio: probe.has_audio, + add_linked_audio: false, + transform: None, + }, + media: media.clone(), + auto_track: track_index.is_none(), + }, + MotionPlacement::Replace { clip_id } => EditCommand::RegisterMediaAndSwapClip { + media: media.clone(), + clip_id, + }, + MotionPlacement::ReplaceAndClearMasks { clip_id } => { + EditCommand::RegisterMediaAndSwapClipClearingMasks { + media: media.clone(), + clip_id, + } + } + }; + let mut edit = session.editor.apply(command, self.ids.as_ref())?; + edit.action_name = action_name.to_string(); + edit.summary = format!("{} generated media clip(s)", edit.affected_clip_ids.len()); + let written = session.editor.save_project(None)?; + Ok((MotionMediaCommit { media, edit }, written)) + })(); + + match result { + Ok((commit, written)) => { + let count = session.editor.media().entries.len(); + (commit, count, written) + } + Err(error) => { + session.editor.restore_editor_state(before); + return Err(error); + } + } + }; + + self.events.emit(&CoreEvent::TimelineChanged { + project_epoch: expected_project_epoch, + version: commit.edit.timeline_version, + }); + self.events.emit(&CoreEvent::MediaChanged { + project_epoch: expected_project_epoch, + count, + }); + self.events.emit(&CoreEvent::ProjectSaved { + path: written.to_string_lossy().into_owned(), + project_epoch: expected_project_epoch, + }); + Ok(commit) + } + /// Commit a fully probed media-import plan as one project-bound durable /// transaction. The session lock covers identity validation, all manifest /// edits, and the atomic `media.json` write. Any failure restores the exact @@ -788,24 +1438,46 @@ impl AppCore { expected_project_epoch, expected_project_dir, plan, + || Ok(()), + |editor| editor.save_media_manifest(), + ) + } + + /// Cancellable/project-bound batch import. `precondition` runs under the + /// session lock immediately before the first manifest/history mutation. + pub fn import_media_batch_for_project_persisted_checked( + &self, + expected_project_epoch: u64, + expected_project_dir: &Path, + plan: Vec, + precondition: impl FnOnce() -> Result<()>, + ) -> Result> { + self.import_media_batch_for_project_with_writer( + expected_project_epoch, + expected_project_dir, + plan, + precondition, |editor| editor.save_media_manifest(), ) } - fn import_media_batch_for_project_with_writer( + fn import_media_batch_for_project_with_writer( &self, expected_project_epoch: u64, expected_project_dir: &Path, plan: Vec, + precondition: P, persist: F, ) -> Result> where + P: FnOnce() -> Result<()>, F: FnOnce(&mut EditorSession) -> Result, { let (imports, count, initial_version, final_version, written) = { let mut session = self.lock(); ensure_project_identity(&session, expected_project_epoch, expected_project_dir)?; session.editor.ensure_mutable()?; + precondition()?; let before = session.editor.checkpoint_editor_state(); let initial_version = session.editor.version(); let result = (|| { @@ -859,6 +1531,18 @@ impl AppCore { } imports.push(CommittedMediaImport { path, entry }); } + PreparedMediaImportOp::ImportDerivedStem { + path, + name, + probe, + provenance, + } => { + let id = self.ids.next_id(); + let entry = session + .editor + .import_derived_stem_file(&path, id, name, &probe, provenance)?; + imports.push(CommittedMediaImport { path, entry }); + } } } @@ -1181,6 +1865,41 @@ impl AppCore { Ok(changed) } + /// Persist one asset's playback proxy as an atomic manifest mutation. + /// Failure to write restores the in-memory manifest before the lock is + /// released, so UI and disk never disagree about proxy availability. + pub fn set_media_proxy_for_project( + &self, + expected_project_epoch: u64, + expected_project_dir: &Path, + asset_id: &str, + proxy: Option, + ) -> Result { + let (entry, count, written) = { + let mut session = self.lock(); + ensure_project_identity(&session, expected_project_epoch, expected_project_dir)?; + let before = session.editor.media(); + let entry = session.editor.set_media_proxy(asset_id, proxy)?; + let count = session.editor.media().entries.len(); + match session.editor.save_media_manifest() { + Ok(written) => (entry, count, written), + Err(error) => { + session.editor.restore_media(before); + return Err(error); + } + } + }; + self.events.emit(&CoreEvent::MediaChanged { + project_epoch: expected_project_epoch, + count, + }); + self.events.emit(&CoreEvent::ProjectSaved { + path: written.to_string_lossy().into_owned(), + project_epoch: expected_project_epoch, + }); + Ok(entry) + } + /// Set or clear one project's global-favorite mapping, emitting the same /// media-change signal used by other manifest mutations. pub fn set_media_global_favorite( @@ -1398,16 +2117,34 @@ impl AppCore { path: impl AsRef, probe: &ProbedMedia, ) -> Result { - let (entry, count, project_epoch) = { + let (entry, count, project_epoch, saved) = { let mut session = self.lock(); + let before = session.editor.media(); let entry = session.editor.relink_media_file(asset_id, path, probe)?; let count = session.editor.media().entries.len(); - (entry, count, session.project_epoch) + let saved = if session.editor.project_dir().is_some() { + match session.editor.save_media_manifest() { + Ok(path) => Some(path), + Err(error) => { + session.editor.restore_media(before); + return Err(error); + } + } + } else { + None + }; + (entry, count, session.project_epoch, saved) }; self.events.emit(&CoreEvent::MediaChanged { project_epoch, count, }); + if let Some(path) = saved { + self.events.emit(&CoreEvent::ProjectSaved { + path: path.to_string_lossy().into_owned(), + project_epoch, + }); + } Ok(entry) } @@ -1427,7 +2164,7 @@ impl AppCore { #[cfg(test)] mod tests { use super::*; - use opentake_domain::{ClipType, Timeline, Track}; + use opentake_domain::{Clip, ClipType, MediaColorMetadata, MediaProxy, Timeline, Track}; use opentake_ops::command::ClipEntry; use std::sync::Mutex; @@ -1443,6 +2180,189 @@ mod tests { core } + #[test] + fn motion_media_commit_is_durable_atomic_and_one_step_undoable() { + let bundle = project_bundle("motion-commit"); + let core = AppCore::new(); + core.open_project(&bundle).unwrap(); + let media_dir = opentake_project::layout::media_dir(&bundle); + std::fs::create_dir_all(&media_dir).unwrap(); + let rendered = media_dir.join("motion-a.mp4"); + std::fs::write(&rendered, b"validated-render-fixture").unwrap(); + let snapshot = core.runtime_snapshot(); + let probe = ProbedMedia { + duration_secs: 1.0, + width: Some(64), + height: Some(36), + fps: Some(30.0), + has_audio: false, + color: None, + }; + + let committed = core + .commit_motion_media_for_project( + snapshot.project_epoch, + snapshot.version, + &bundle, + &rendered, + "Motion A", + &probe, + GenerationInput { + prompt: "{\"templateId\":\"title-card\"}".into(), + model: "opentake.motion-canvas".into(), + duration: 30, + aspect_ratio: "64:36".into(), + provider: Some("opentake-motion".into()), + status: Some(opentake_domain::GenerationJobStatus::Ready), + ..GenerationInput::default() + }, + MotionPlacement::Add { + start_frame: 0, + duration_frames: 30, + track_index: Some(0), + }, + ) + .unwrap(); + assert_eq!(committed.edit.action_name, "Add Motion Graphic"); + assert_eq!(core.media().entries.len(), 2); + assert_eq!(core.get_timeline().timeline.tracks[0].clips.len(), 1); + + let reopened = AppCore::new(); + reopened.open_project(&bundle).unwrap(); + assert_eq!(reopened.media().entries.len(), 2); + assert_eq!(reopened.get_timeline().timeline.tracks[0].clips.len(), 1); + + core.undo().unwrap(); + assert_eq!(core.media().entries.len(), 1); + assert!(core.get_timeline().timeline.tracks[0].clips.is_empty()); + let _ = std::fs::remove_dir_all(bundle); + } + + #[test] + fn generated_media_commit_refuses_version_drift_without_mutation() { + let bundle = project_bundle("generated-version-drift"); + let core = AppCore::new(); + core.open_project(&bundle).unwrap(); + let media_dir = opentake_project::layout::media_dir(&bundle); + std::fs::create_dir_all(&media_dir).unwrap(); + let rendered = media_dir.join("stale-render.mp4"); + std::fs::write(&rendered, b"validated-render-fixture").unwrap(); + let stale = core.runtime_snapshot(); + core.apply(EditCommand::SetTimelineSettings { + fps: 24, + width: 1280, + height: 720, + }) + .unwrap(); + let before_commit = core.runtime_snapshot(); + + let error = core + .commit_motion_media_for_project( + stale.project_epoch, + stale.version, + &bundle, + &rendered, + "Stale Render", + &ProbedMedia::default(), + GenerationInput::default(), + MotionPlacement::Add { + start_frame: 0, + duration_frames: 1, + track_index: Some(0), + }, + ) + .unwrap_err(); + assert!(error + .to_string() + .contains("project changed while preparing a generated-media edit")); + let after_commit = core.runtime_snapshot(); + assert_eq!(after_commit.timeline, before_commit.timeline); + assert_eq!(after_commit.media, before_commit.media); + assert_eq!(after_commit.version, before_commit.version); + + let _ = std::fs::remove_dir_all(bundle); + } + + #[test] + fn motion_media_commit_rejects_output_outside_active_bundle_without_mutation() { + let bundle = project_bundle("motion-outside"); + let core = AppCore::new(); + core.open_project(&bundle).unwrap(); + let outside = std::env::temp_dir().join(format!( + "opentake-motion-outside-{}-{}.mp4", + std::process::id(), + core.runtime_snapshot().project_epoch + )); + std::fs::write(&outside, b"outside").unwrap(); + let before = core.runtime_snapshot(); + + let error = core + .commit_motion_media_for_project( + before.project_epoch, + before.version, + &bundle, + &outside, + "Outside", + &ProbedMedia::default(), + GenerationInput::default(), + MotionPlacement::Add { + start_frame: 0, + duration_frames: 1, + track_index: Some(0), + }, + ) + .unwrap_err(); + assert!(error.to_string().contains("active project media")); + let after = core.runtime_snapshot(); + assert_eq!(after.timeline, before.timeline); + assert_eq!(after.media, before.media); + assert_eq!(after.version, before.version); + + let _ = std::fs::remove_file(outside); + let _ = std::fs::remove_dir_all(bundle); + } + + #[cfg(unix)] + #[test] + fn motion_media_commit_rejects_symlink_without_mutation() { + use std::os::unix::fs::symlink; + + let bundle = project_bundle("motion-symlink"); + let core = AppCore::new(); + core.open_project(&bundle).unwrap(); + let media_dir = opentake_project::layout::media_dir(&bundle); + std::fs::create_dir_all(&media_dir).unwrap(); + let target = media_dir.join("motion-target.mp4"); + let linked = media_dir.join("motion-linked.mp4"); + std::fs::write(&target, b"validated-render-fixture").unwrap(); + symlink(&target, &linked).unwrap(); + let before = core.runtime_snapshot(); + + let error = core + .commit_motion_media_for_project( + before.project_epoch, + before.version, + &bundle, + &linked, + "Linked", + &ProbedMedia::default(), + GenerationInput::default(), + MotionPlacement::Add { + start_frame: 0, + duration_frames: 1, + track_index: Some(0), + }, + ) + .unwrap_err(); + assert!(error.to_string().contains("regular non-symlink")); + let after = core.runtime_snapshot(); + assert_eq!(after.timeline, before.timeline); + assert_eq!(after.media, before.media); + assert_eq!(after.version, before.version); + + let _ = std::fs::remove_dir_all(bundle); + } + #[test] fn project_identity_workflow_blocks_project_replacement_until_release() { let core = AppCore::new(); @@ -1464,6 +2384,45 @@ mod tests { worker.join().unwrap(); } + #[test] + fn identity_bound_save_never_writes_a_replacement_project_to_the_old_request_target() { + let first = project_bundle("save-identity-first"); + let second = project_bundle("save-identity-second"); + let destination = std::env::temp_dir().join(format!( + "opentake-core-stale-save-{}-{}.opentake", + std::process::id(), + first + .file_stem() + .and_then(std::ffi::OsStr::to_str) + .unwrap_or("fixture") + )); + let _ = std::fs::remove_dir_all(&destination); + let core = AppCore::new(); + core.open_project(&first).unwrap(); + let expected = core.runtime_snapshot(); + core.open_project(&second).unwrap(); + + let error = core + .save_project_with_thumbnail_for_project( + expected.project_epoch, + expected.project_dir.as_deref(), + Some(destination.clone()), + Some(b"stale thumbnail".to_vec()), + ) + .expect_err("replacement project must reject the stale save"); + + assert!(matches!(error, CoreError::StaleProject)); + assert!(!destination.exists()); + assert_eq!( + core.runtime_snapshot().project_dir.as_deref(), + Some(second.as_path()) + ); + + let _ = std::fs::remove_dir_all(first); + let _ = std::fs::remove_dir_all(second); + let _ = std::fs::remove_dir_all(destination); + } + fn project_bundle(label: &str) -> PathBuf { static SEQ: AtomicU64 = AtomicU64::new(0); let sequence = SEQ.fetch_add(1, Ordering::Relaxed); @@ -1533,6 +2492,26 @@ mod tests { } } + fn seed_same_video_clip(core: &AppCore, clip_id: &str) { + let mut slot = core.session.lock().unwrap(); + let mut timeline = Timeline::new(); + let mut track = Track::new("same-track", ClipType::Video); + track + .clips + .push(Clip::new(clip_id, "source-asset", 100, 60)); + timeline.tracks.push(track); + slot.editor.seed_from_timeline(timeline); + } + + fn registered_freeze(media: MediaManifestEntry, clip_id: &str) -> EditCommand { + EditCommand::RegisterMediaAndFreezeFrame { + media, + clip_id: clip_id.to_string(), + at_frame: 130, + duration_frames: 30, + } + } + #[test] fn app_core_is_send_and_sync() { fn assert_send_sync() {} @@ -1623,6 +2602,102 @@ mod tests { assert_eq!(after.media, before.media); } + #[test] + fn ipc_edit_revision_rejects_save_as_path_drift_without_mutation() { + let temp = tempfile::tempdir().unwrap(); + let first = temp.path().join("first.opentake"); + let second = temp.path().join("second.opentake"); + let core = core_with_track(); + core.save_project(Some(first.clone())).unwrap(); + let expected = core.project_revision(); + + core.save_project(Some(second.clone())).unwrap(); + assert_eq!( + core.project_revision(), + expected, + "Save As keeps the session revision" + ); + let before = core.runtime_snapshot(); + let error = core + .apply_at_project_revision(expected, Some(&first), add_one_clip()) + .expect_err("an edit captured before Save As must not target the new bundle path"); + + assert!(matches!(error, CoreError::StaleProject)); + let after = core.runtime_snapshot(); + assert_eq!(after.project_dir.as_deref(), Some(second.as_path())); + assert_eq!(after.project_epoch, before.project_epoch); + assert_eq!(after.version, before.version); + assert_eq!(after.timeline, before.timeline); + assert_eq!(after.media, before.media); + } + + #[test] + fn registered_freeze_rejects_version_drift_without_manifest_or_events() { + let core = core_with_track(); + let added = core.apply(add_one_clip()).unwrap(); + let clip_id = added.affected_clip_ids[0].clone(); + let expected = core.project_revision(); + let media = core + .prepare_media_file_entry( + std::env::temp_dir().join("freeze-version-drift.png"), + "Freeze", + &ProbedMedia::default(), + ) + .unwrap(); + core.apply(EditCommand::CreateFolder { + name: "version drift".into(), + parent_folder_id: None, + }) + .unwrap(); + let before = core.runtime_snapshot(); + let seen: Arc>> = Arc::new(Mutex::new(Vec::new())); + let sink = Arc::clone(&seen); + core.subscribe(move |event| sink.lock().unwrap().push(event.clone())); + + let error = core + .apply_at_project_revision(expected, None, registered_freeze(media, &clip_id)) + .expect_err("a capture prepared before another edit must be rejected"); + + assert!(matches!(error, CoreError::StaleProject)); + let after = core.runtime_snapshot(); + assert_eq!(after.timeline, before.timeline); + assert_eq!(after.media, before.media); + assert_eq!(after.version, before.version); + assert!(seen.lock().unwrap().is_empty()); + } + + #[test] + fn registered_freeze_rejects_project_drift_even_with_same_clip_id() { + let core = AppCore::new(); + seed_same_video_clip(&core, "same-clip"); + let expected = core.project_revision(); + let media = core + .prepare_media_file_entry( + std::env::temp_dir().join("freeze-project-drift.png"), + "Freeze", + &ProbedMedia::default(), + ) + .unwrap(); + + core.new_project(); + seed_same_video_clip(&core, "same-clip"); + let before = core.runtime_snapshot(); + let seen: Arc>> = Arc::new(Mutex::new(Vec::new())); + let sink = Arc::clone(&seen); + core.subscribe(move |event| sink.lock().unwrap().push(event.clone())); + + let error = core + .apply_at_project_revision(expected, None, registered_freeze(media, "same-clip")) + .expect_err("the replacement project must not accept an old capture"); + + assert!(matches!(error, CoreError::StaleProject)); + let after = core.runtime_snapshot(); + assert_eq!(after.timeline, before.timeline); + assert_eq!(after.media, before.media); + assert_eq!(after.version, before.version); + assert!(seen.lock().unwrap().is_empty()); + } + #[test] fn manifest_edit_and_undo_emit_media_changed() { let core = core_with_track(); @@ -1872,6 +2947,7 @@ mod tests { height: Some(480), fps: Some(24.0), has_audio: false, + color: None, }; let entry = core.import_media_file("/abs/a.mp4", "a", &probe).unwrap(); @@ -1889,6 +2965,58 @@ mod tests { ); } + #[test] + fn hdr_and_proxy_metadata_persist_across_project_reopen() { + let temp = tempfile::tempdir().unwrap(); + let bundle = temp.path().join("ColorProxy.opentake"); + let source = temp.path().join("source.mp4"); + std::fs::write(&source, b"source").unwrap(); + let core = AppCore::new(); + core.save_project(Some(bundle.clone())).unwrap(); + let entry = core + .import_media_file( + &source, + "source", + &ProbedMedia { + duration_secs: 1.0, + width: Some(1920), + height: Some(1080), + fps: Some(24.0), + has_audio: false, + color: Some(MediaColorMetadata { + primaries: Some("bt2020".into()), + transfer: Some("smpte2084".into()), + matrix: Some("bt2020nc".into()), + range: Some("tv".into()), + }), + }, + ) + .unwrap(); + core.save_project(None).unwrap(); + let proxy_relative = "media/proxies/source.mp4"; + std::fs::create_dir_all(bundle.join("media/proxies")).unwrap(); + std::fs::write(bundle.join(proxy_relative), b"proxy").unwrap(); + let revision = core.runtime_snapshot(); + core.set_media_proxy_for_project( + revision.project_epoch, + &bundle, + &entry.id, + Some(MediaProxy { + relative_path: proxy_relative.into(), + source_sha256: "a".repeat(64), + width: 640, + height: 360, + }), + ) + .unwrap(); + + let reopened = AppCore::new(); + reopened.open_project(&bundle).unwrap(); + let restored = reopened.media().entries.into_iter().next().unwrap(); + assert!(restored.color.as_ref().is_some_and(|color| color.is_hdr())); + assert_eq!(restored.proxy.unwrap().relative_path, proxy_relative); + } + #[test] fn import_media_unsupported_errors_and_emits_nothing() { let core = AppCore::new(); @@ -2090,6 +3218,7 @@ mod tests { folder: Some(PreparedMediaFolderRef::Planned(0)), }, ], + || Ok(()), |_| { Err(CoreError::Media( "injected manifest write failure".to_string(), @@ -2145,4 +3274,18 @@ mod tests { ] ); } + + /// Composite acceptance entry tracked by the data-safety implementation + /// plan. These child slices cover authoritative versions/events, stale edit + /// refusal, manifest undo, coherent concurrent snapshots, and save/reopen. + #[test] + fn cross_cutting_runtime_acceptance() { + apply_bumps_version_and_emits_once(); + deferred_apply_rejects_version_and_project_drift_without_mutation(); + manifest_edit_and_undo_emit_media_changed(); + undo_redo_through_core_bumps_version_and_emits(); + runtime_snapshot_never_mixes_timeline_media_and_project_dir(); + open_save_roundtrip_through_core_emits_lifecycle_events(); + prepared_media_batch_writer_failure_restores_full_editor_state(); + } } diff --git a/crates/opentake-core/src/dto.rs b/crates/opentake-core/src/dto.rs index 8bb1b6a2..4aa7e31d 100644 --- a/crates/opentake-core/src/dto.rs +++ b/crates/opentake-core/src/dto.rs @@ -18,7 +18,7 @@ use serde::{Deserialize, Serialize}; use opentake_domain::Timeline; use opentake_ops::command::{EditCommand, EditResult}; -use crate::core::{AppCore, TimelineSnapshot}; +use crate::core::{AppCore, ProjectRevision, TimelineSnapshot}; use crate::error::{CoreError, Result}; /// Machine + human readable error for the Tauri boundary (`core-SPEC.md` §6.3). @@ -35,9 +35,24 @@ pub struct CmdError { impl From for CmdError { fn from(err: CoreError) -> Self { + let code = err.code(); + let message = match &err { + CoreError::Edit(_) + | CoreError::Media(_) + | CoreError::StaleProject + | CoreError::Project(opentake_project::ProjectError::CompatibilityReadOnly { + .. + }) + | CoreError::NoProjectOpen + | CoreError::Unsupported(_) => err.to_string(), + CoreError::Project(_) => { + eprintln!("project command failed: {err}"); + "Project operation failed".to_string() + } + }; CmdError { - code: err.code().to_string(), - message: err.to_string(), + code: code.to_string(), + message, } } } @@ -123,6 +138,20 @@ pub fn handle_edit_apply( map(core.apply(command).map(EditResultDto::from)) } +/// Revision- and path-bound editing entry point for untrusted IPC clients. +/// Unlike [`handle_edit_apply`], a delayed request is rejected after any +/// project switch, Save As, or intervening timeline edit. +pub fn handle_edit_apply_at_project_revision( + core: &AppCore, + expected: ProjectRevision, + expected_project_path: Option<&std::path::Path>, + command: EditCommand, +) -> std::result::Result { + map(core + .apply_at_project_revision(expected, expected_project_path, command) + .map(EditResultDto::from)) +} + /// `undo`: global undo (Cmd+Z). pub fn handle_undo(core: &AppCore) -> std::result::Result { map(core.undo().map(EditResultDto::from)) @@ -239,9 +268,71 @@ mod tests { #[test] fn edit_apply_handler_maps_validation_error() { let core = core_with_track(); + let before = core.get_timeline(); + let events = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let observed = events.clone(); + core.subscribe(move |_| { + observed.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + }); + let err = handle_edit_apply(&core, EditCommand::AddClips { entries: vec![] }).unwrap_err(); + assert_eq!(err.code, "validation"); assert!(!err.message.is_empty()); + let after = core.get_timeline(); + assert_eq!( + after.timeline, before.timeline, + "failed edit mutated timeline" + ); + assert_eq!(after.version, before.version, "failed edit mutated version"); + assert_eq!( + after.project_epoch, before.project_epoch, + "failed edit mutated project identity" + ); + assert_eq!(events.load(std::sync::atomic::Ordering::SeqCst), 0); + } + + #[test] + fn revision_bound_edit_reports_stale_project_without_mutation() { + let core = core_with_track(); + let snapshot = core.get_timeline(); + core.apply(add_one_clip()).unwrap(); + let before = core.get_timeline(); + + let error = handle_edit_apply_at_project_revision( + &core, + ProjectRevision { + project_epoch: snapshot.project_epoch, + version: snapshot.version, + }, + snapshot.project_path.as_deref(), + add_one_clip(), + ) + .expect_err("a stale IPC mirror must be rejected"); + + assert_eq!(error.code, "staleProject"); + assert_eq!( + error.message, + "project changed while preparing a deferred edit" + ); + let after = core.get_timeline(); + assert_eq!(after.timeline, before.timeline); + assert_eq!(after.version, before.version); + assert_eq!(after.project_epoch, before.project_epoch); + } + + #[test] + fn internal_command_error_does_not_expose_project_paths() { + let error = CmdError::from(CoreError::Project( + opentake_project::ProjectError::MissingTimeline { + file: "project.json", + bundle: "/private/customer/secret.opentake".into(), + }, + )); + + assert_eq!(error.code, "internal"); + assert_eq!(error.message, "Project operation failed"); + assert!(!error.message.contains("/private")); } #[test] diff --git a/crates/opentake-core/src/error.rs b/crates/opentake-core/src/error.rs index ea6aa9e9..119c479d 100644 --- a/crates/opentake-core/src/error.rs +++ b/crates/opentake-core/src/error.rs @@ -47,6 +47,13 @@ pub enum CoreError { /// unchanged. Maps to the `validation` error class. #[error("{0}")] Media(String), + + /// A caller tried to commit an edit against a project identity or timeline + /// revision that is no longer current. This is distinct from ordinary + /// command validation so IPC clients can refresh instead of retrying the + /// same stale mutation against a replacement project. + #[error("project changed while preparing a deferred edit")] + StaleProject, } /// Convenience alias for fallible assembly-layer operations. @@ -58,6 +65,7 @@ impl CoreError { pub fn code(&self) -> &'static str { match self { CoreError::Edit(_) | CoreError::Media(_) => "validation", + CoreError::StaleProject => "staleProject", CoreError::Project(ProjectError::CompatibilityReadOnly { .. }) => "validation", CoreError::Project(_) | CoreError::NoProjectOpen | CoreError::Unsupported(_) => { "internal" diff --git a/crates/opentake-core/src/events.rs b/crates/opentake-core/src/events.rs index f7c0a42e..fa318957 100644 --- a/crates/opentake-core/src/events.rs +++ b/crates/opentake-core/src/events.rs @@ -225,15 +225,41 @@ mod tests { #[test] fn core_event_serializes_with_kind_tag() { - let json = serde_json::to_string(&CoreEvent::TimelineChanged { - project_epoch: 3, - version: 7, - }) - .unwrap(); - assert_eq!( - json, - r#"{"kind":"timeline_changed","projectEpoch":3,"version":7}"# - ); + let cases = [ + ( + CoreEvent::TimelineChanged { + project_epoch: 3, + version: 7, + }, + r#"{"kind":"timeline_changed","projectEpoch":3,"version":7}"#, + ), + ( + CoreEvent::ProjectOpened { + path: "/project.otk".into(), + project_epoch: 4, + version: 0, + }, + r#"{"kind":"project_opened","path":"/project.otk","projectEpoch":4,"version":0}"#, + ), + ( + CoreEvent::ProjectSaved { + path: "/project.otk".into(), + project_epoch: 4, + }, + r#"{"kind":"project_saved","path":"/project.otk","projectEpoch":4}"#, + ), + ( + CoreEvent::MediaChanged { + project_epoch: 4, + count: 2, + }, + r#"{"kind":"media_changed","projectEpoch":4,"count":2}"#, + ), + ]; + + for (event, expected) in cases { + assert_eq!(serde_json::to_string(&event).unwrap(), expected); + } } #[test] diff --git a/crates/opentake-core/src/lib.rs b/crates/opentake-core/src/lib.rs index ad4632b9..b9eb7f20 100644 --- a/crates/opentake-core/src/lib.rs +++ b/crates/opentake-core/src/lib.rs @@ -45,12 +45,14 @@ pub mod session; // --- Assembly façade --- pub use crate::core::{ AppCore, BundleExportSnapshot, CapabilityImportCommit, CommittedMediaImport, - DeferredCoreEvents, ImportCommitWarning, PreparedMediaFolderRef, PreparedMediaImportOp, - ProjectRevision, ProjectRuntimeSnapshot, TimelineSnapshot, + DeferredCoreEvents, ImportCommitWarning, MotionMediaCommit, MotionPlacement, OwnedUndoResult, + PreparedMediaFolderRef, PreparedMediaImportOp, ProjectAssetAuthority, ProjectRevision, + ProjectRuntimeSnapshot, ProjectUndoSnapshot, TimelineSnapshot, }; pub use session::{ - importable_clip_type, EditorSession, ProbedMedia, SUPPORTED_AUDIO_EXTENSIONS, - SUPPORTED_IMAGE_EXTENSIONS, SUPPORTED_VIDEO_EXTENSIONS, + importable_clip_type, DerivedStemProvenance, EditorSession, GenerationJobCommit, + GenerationStateUpdate, PreparedGenerationJob, PreparedGenerationOutput, ProbedMedia, + SUPPORTED_AUDIO_EXTENSIONS, SUPPORTED_IMAGE_EXTENSIONS, SUPPORTED_VIDEO_EXTENSIONS, }; // --- Events --- diff --git a/crates/opentake-core/src/session.rs b/crates/opentake-core/src/session.rs index 7bcc2113..6e0faf5e 100644 --- a/crates/opentake-core/src/session.rs +++ b/crates/opentake-core/src/session.rs @@ -32,12 +32,19 @@ //! are a media-layer concern injected via [`crate::deps`] and are not performed //! here. +use std::fs; use std::path::{Path, PathBuf}; -use opentake_domain::{ClipType, MediaAsset, MediaManifest, MediaManifestEntry, Timeline}; +use opentake_domain::{ + ClipType, GenerationInput, GenerationJobStatus, MediaAsset, MediaColorMetadata, MediaManifest, + MediaManifestEntry, MediaProxy, MediaSource, Timeline, +}; use opentake_ops::command::{self, EditCommand, EditResult}; use opentake_ops::{EditorState, IdGen}; -use opentake_project::{GenerationLog, Project, ProjectCompatibility, ProjectRoot}; +use opentake_project::{ + GenerationLog, GenerationLogEntry, Project, ProjectCompatibility, ProjectRoot, + ProjectRootIdentity, +}; use same_file::Handle; use crate::error::{CoreError, Result}; @@ -63,6 +70,69 @@ pub struct ProbedMedia { pub fps: Option, /// Whether the file carries an audio track. pub has_audio: bool, + /// Source color signalling for HDR-aware decode and durable project state. + pub color: Option, +} + +/// Non-secret provenance attached when a separated audio stem re-enters the +/// ordinary media manifest. Content/model hashes make the derivation auditable +/// without persisting provider credentials or result URLs. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct DerivedStemProvenance { + pub source_asset_id: String, + pub source_sha256: String, + pub execution: String, + pub model_sha256: Option, + pub stem: String, +} + +/// Validated provider-neutral generation job prepared by the Agent/Tauri host. +/// Credentials, signed URLs, and provider diagnostics are deliberately absent. +#[derive(Clone, Debug)] +pub struct PreparedGenerationJob { + pub name: String, + pub kind: ClipType, + pub folder_id: Option, + pub provider: String, + pub input: GenerationInput, + pub output_count: usize, + pub source_asset_id: Option, + pub source_clip_id: Option, + pub estimated_cost_credits: Option, + pub created_at: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct GenerationJobCommit { + pub job_id: String, + pub placeholder_asset_ids: Vec, +} + +/// One durable lifecycle update. Provider messages are reduced to a fixed +/// application-owned `error_code` before reaching this boundary. +#[derive(Clone, Debug)] +pub struct GenerationStateUpdate { + pub status: GenerationJobStatus, + pub progress: Option, + pub error_code: Option, + pub provider_job_id: Option, + pub cost_credits: Option, + pub created_at: Option, +} + +#[derive(Clone, Debug)] +pub struct PreparedGenerationOutput { + pub asset_id: String, + pub relative_path: String, + pub probe: ProbedMedia, + pub created_at: Option, +} + +#[derive(Clone)] +pub(crate) struct GenerationStateCheckpoint { + manifest: MediaManifest, + log: GenerationLog, + component_present: bool, } /// File extensions the importer accepts, grouped by the [`ClipType`] they map to. @@ -105,6 +175,108 @@ pub fn importable_clip_type(path: &Path) -> Option { } } +fn safe_provider_prefix(value: &str) -> bool { + !value.is_empty() + && value.len() <= 32 + && value + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') +} + +fn valid_sha256(value: &str) -> bool { + value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_hexdigit()) +} + +fn safe_generation_error_code(value: &str) -> bool { + !value.is_empty() + && value.len() <= 80 + && value + .bytes() + .all(|byte| byte.is_ascii_uppercase() || byte.is_ascii_digit() || byte == b'_') +} + +fn validate_generation_update(update: &GenerationStateUpdate) -> Result<()> { + if update.cost_credits.is_some_and(|credits| credits < 0) { + return Err(CoreError::Media( + "generation cost must not be negative".to_string(), + )); + } + if let Some(progress) = update.progress { + if !progress.is_finite() || !(0.0..=1.0).contains(&progress) { + return Err(CoreError::Media( + "generation progress must be finite and between 0 and 1".to_string(), + )); + } + } + if let Some(error_code) = update.error_code.as_deref() { + if !safe_generation_error_code(error_code) { + return Err(CoreError::Media( + "generation failure code is invalid".to_string(), + )); + } + } + if update.status == GenerationJobStatus::Failed && update.error_code.is_none() { + return Err(CoreError::Media( + "failed generation status requires an error code".to_string(), + )); + } + if update.status != GenerationJobStatus::Failed && update.error_code.is_some() { + return Err(CoreError::Media( + "generation error code is only valid for failed status".to_string(), + )); + } + if let Some(provider_job_id) = update.provider_job_id.as_deref() { + if provider_job_id.is_empty() + || provider_job_id.len() > 512 + || provider_job_id.chars().any(char::is_control) + || provider_job_id.contains("://") + { + return Err(CoreError::Media( + "provider job identity is invalid".to_string(), + )); + } + } + Ok(()) +} + +fn valid_generation_transition( + current: Option, + next: GenerationJobStatus, +) -> bool { + use GenerationJobStatus as Status; + match (current, next) { + (None, Status::Queued) => true, + (Some(current), next) if current == next => true, + (Some(Status::Queued), Status::Generating | Status::Failed | Status::Cancelled) => true, + (Some(Status::Generating), Status::Downloading | Status::Failed | Status::Cancelled) => { + true + } + ( + Some(Status::Downloading), + Status::Finalizing | Status::Ready | Status::Failed | Status::Cancelled, + ) => true, + (Some(Status::Finalizing), Status::Ready | Status::Failed | Status::Cancelled) => true, + (Some(Status::Failed | Status::Cancelled), Status::Queued) => true, + (Some(Status::Ready), Status::Ready) => true, + _ => false, + } +} + +fn validate_project_media_relative_path(value: &str) -> Result<()> { + let path = Path::new(value); + let mut components = path.components(); + if components.next() != Some(std::path::Component::Normal("media".as_ref())) + || components.clone().next().is_none() + || path.is_absolute() + || components.any(|component| !matches!(component, std::path::Component::Normal(_))) + { + return Err(CoreError::Media( + "generated output path must be a safe media-relative path".to_string(), + )); + } + Ok(()) +} + /// The open document plus its project-level metadata. pub struct EditorSession { /// Authoritative editable state: timeline, manifest, undo/redo, version. @@ -325,6 +497,98 @@ impl EditorSession { self.import_media_file_checked(path, id, name, probe, || Ok(())) } + /// Import a ready vocals/accompaniment file through the shared media path + /// and attach durable provenance. The original asset remains immutable. + pub fn import_derived_stem_file( + &mut self, + path: impl AsRef, + id: impl Into, + name: impl Into, + probe: &ProbedMedia, + provenance: DerivedStemProvenance, + ) -> Result { + self.ensure_mutable()?; + let source = self + .state + .manifest + .entries + .iter() + .find(|entry| entry.id == provenance.source_asset_id) + .ok_or_else(|| { + CoreError::Media(format!( + "stem source asset does not exist: {}", + provenance.source_asset_id + )) + })?; + if !matches!(source.kind, ClipType::Audio | ClipType::Video) + || !source.has_audio.unwrap_or(source.kind == ClipType::Audio) + { + return Err(CoreError::Media( + "stem source asset has no audio".to_string(), + )); + } + if !valid_sha256(&provenance.source_sha256) + || provenance + .model_sha256 + .as_deref() + .is_some_and(|digest| !valid_sha256(digest)) + { + return Err(CoreError::Media( + "stem provenance checksum is invalid".to_string(), + )); + } + let (provider, model) = provenance.execution.split_once(':').ok_or_else(|| { + CoreError::Media("stem execution must be ':'".to_string()) + })?; + if !safe_provider_prefix(provider) || model.trim().is_empty() { + return Err(CoreError::Media( + "stem execution provider or model is invalid".to_string(), + )); + } + let output_index = match provenance.stem.as_str() { + "vocals" => 0, + "accompaniment" => 1, + _ => { + return Err(CoreError::Media( + "stem kind must be vocals or accompaniment".to_string(), + )) + } + }; + + let before = self.state.manifest.clone(); + let result = (|| { + let entry = self.import_media_file(path, id, name, probe)?; + let target = self + .state + .manifest + .entries + .iter_mut() + .find(|candidate| candidate.id == entry.id) + .ok_or_else(|| CoreError::Media("imported stem disappeared".to_string()))?; + target.generation_input = Some(GenerationInput { + prompt: format!("stem:{}", provenance.stem), + model: model.to_string(), + duration: probe.duration_secs.max(0.0).round() as i32, + aspect_ratio: "audio".to_string(), + quality: provenance + .model_sha256 + .map(|digest| format!("model-sha256:{digest}")), + reference_audio_urls: Some(vec![format!("sha256:{}", provenance.source_sha256)]), + provider: Some(provider.to_string()), + status: Some(GenerationJobStatus::Ready), + progress: Some(1.0), + output_index: Some(output_index), + source_asset_id: Some(provenance.source_asset_id), + ..GenerationInput::default() + }); + Ok(target.clone()) + })(); + if result.is_err() { + self.state.manifest = before; + } + result + } + /// Import one file and roll the manifest back if `postcondition` fails. /// Save-as-media uses this to bind its final retained-file identity check to /// the live manifest mutation: an attacker-triggered swap can never leave a @@ -340,23 +604,7 @@ impl EditorSession { self.ensure_mutable()?; let manifest_before = self.state.manifest.clone(); let path = path.as_ref(); - let kind = importable_clip_type(path).ok_or(CoreError::Unsupported("media"))?; - - let mut asset = MediaAsset::new(id, path, kind, name, probe.duration_secs); - asset.source_width = probe.width; - asset.source_height = probe.height; - asset.source_fps = probe.fps; - // Video defaults to having audio (MediaAsset::new); refine from the probe. - // Non-video never carries a video-track-linked audio flag upstream. - asset.has_audio = match kind { - ClipType::Audio => true, - ClipType::Video => probe.has_audio, - _ => false, - }; - - // `now = 0`: a freshly imported local file has no cached remote URL, so - // the freshness clock is irrelevant to the produced entry. - let entry = asset.to_manifest_entry(self.project_dir.as_deref(), 0.0); + let entry = self.prepare_media_file_entry(path, id, name, probe)?; // Dedup (#91 "素材重复出现"): importing a file that is already in the // manifest reuses the existing entry — keeping its id so any clip that // references it stays valid — instead of appending a second entry for the @@ -381,6 +629,39 @@ impl EditorSession { Ok(entry) } + /// Build the manifest representation of a validated local file without + /// mutating the manifest, history, or version. Deferred render workflows + /// use this to prepare a command that will later register the entry and + /// edit the timeline in one revision-bound transaction. + pub fn prepare_media_file_entry( + &self, + path: impl AsRef, + id: impl Into, + name: impl Into, + probe: &ProbedMedia, + ) -> Result { + self.ensure_mutable()?; + let path = path.as_ref(); + let kind = importable_clip_type(path).ok_or(CoreError::Unsupported("media"))?; + + let mut asset = MediaAsset::new(id, path, kind, name, probe.duration_secs); + asset.source_width = probe.width; + asset.source_height = probe.height; + asset.source_fps = probe.fps; + asset.color = probe.color.clone(); + // Video defaults to having audio (MediaAsset::new); refine from the probe. + // Non-video never carries a video-track-linked audio flag upstream. + asset.has_audio = match kind { + ClipType::Audio => true, + ClipType::Video => probe.has_audio, + _ => false, + }; + + // `now = 0`: a freshly prepared local file has no cached remote URL, so + // the freshness clock is irrelevant to the produced entry. + Ok(asset.to_manifest_entry(self.project_dir.as_deref(), 0.0)) + } + /// Relink an existing asset to a new on-disk file, **keeping the same id** so /// every clip that references it recovers in place (mirrors upstream /// `EditorViewModel+Relink.applyRelink`: same asset, swapped url + refreshed @@ -420,6 +701,8 @@ impl EditorSession { entry.source_width = probe.width; entry.source_height = probe.height; entry.source_fps = probe.fps; + entry.color = probe.color.clone(); + entry.proxy = None; entry.has_audio = Some(match kind { ClipType::Audio => true, ClipType::Video => probe.has_audio, @@ -438,6 +721,47 @@ impl EditorSession { Ok(self.state.manifest.set_favorites(asset_ids, favorite)) } + /// Attach or clear a project-local playback proxy without changing the + /// authoritative source used by export. The proxy path is deliberately + /// constrained to `media/proxies/` and the source digest is fixed-width so + /// corrupt or externally-authored manifests cannot redirect playback. + pub fn set_media_proxy( + &mut self, + asset_id: &str, + proxy: Option, + ) -> Result { + self.ensure_mutable()?; + if let Some(proxy) = proxy.as_ref() { + let path = Path::new(&proxy.relative_path); + let components: Vec<_> = path.components().collect(); + if path.is_absolute() + || components.len() != 3 + || components[0] != std::path::Component::Normal("media".as_ref()) + || components[1] != std::path::Component::Normal("proxies".as_ref()) + || !matches!(components[2], std::path::Component::Normal(_)) + || path.extension().and_then(|extension| extension.to_str()) != Some("mp4") + || proxy.width == 0 + || proxy.height == 0 + || proxy.source_sha256.len() != 64 + || !proxy + .source_sha256 + .bytes() + .all(|byte| byte.is_ascii_hexdigit()) + { + return Err(CoreError::Media("invalid media proxy metadata".to_string())); + } + } + let entry = self + .state + .manifest + .entries + .iter_mut() + .find(|entry| entry.id == asset_id) + .ok_or_else(|| CoreError::Media(format!("unknown media asset: {asset_id}")))?; + entry.proxy = proxy; + Ok(entry.clone()) + } + /// Set or clear one asset's content-addressed global favorite id. This is a /// manifest mutation outside undo, matching [`Self::set_media_favorite`]. pub fn set_media_global_favorite( @@ -511,6 +835,16 @@ impl EditorSession { self.state.can_undo() } + /// Label of the current top-level undo transaction. + pub fn undo_action_name(&self) -> Option<&str> { + self.state.undo_action_name() + } + + /// Stable version identity of the undo transaction at the top of history. + pub fn undo_transaction_version(&self) -> Option { + self.state.undo_transaction_version() + } + /// Whether a redo is available. pub fn can_redo(&self) -> bool { self.state.can_redo() @@ -521,6 +855,16 @@ impl EditorSession { self.project_dir.as_deref() } + /// Open a project-local asset through the retained no-follow bundle + /// authority. Reads never re-resolve [`Self::project_dir`], so an ambient + /// rename or replacement of the bundle pathname cannot redirect the open + /// to a rebound bundle. The returned file is read-only and opened with + /// no-follow semantics on every directory component and the final leaf. + pub fn open_asset_file(&self, relative: &Path) -> Result { + let root = self.project_root.as_ref().ok_or(CoreError::NoProjectOpen)?; + Ok(root.open_asset_file(relative)?) + } + /// Compare a caller's retained no-follow bundle handle with the handle /// retained when this exact session opened or saved the project. pub(crate) fn matches_project_root_identity(&self, current: &Handle) -> Result { @@ -529,11 +873,448 @@ impl EditorSession { Ok(expected.matches_identity(current)) } + pub(crate) fn project_root_identity(&self) -> Option { + self.project_root.as_ref().map(ProjectRoot::stable_identity) + } + + pub(crate) fn project_root_is_current_namespace(&self) -> Result { + self.project_root + .as_ref() + .ok_or(CoreError::NoProjectOpen)? + .is_current_namespace() + .map_err(CoreError::from) + } + /// Read-only access to the generation log. pub fn generation_log(&self) -> &GenerationLog { &self.generation_log } + /// Create durable manifest placeholders and append the corresponding + /// queued audit events. The caller persists the returned in-memory state in + /// the same critical section before exposing the placeholder ids. + pub(crate) fn begin_generation_job( + &mut self, + mut plan: PreparedGenerationJob, + ids: &dyn IdGen, + ) -> Result { + self.ensure_mutable()?; + if self.project_dir.is_none() { + return Err(CoreError::NoProjectOpen); + } + if !(1..=4).contains(&plan.output_count) { + return Err(CoreError::Media( + "generation output count must be between 1 and 4".to_string(), + )); + } + if plan.input.model.trim().is_empty() || plan.provider.trim().is_empty() { + return Err(CoreError::Media( + "generation model and provider are required".to_string(), + )); + } + if !safe_provider_prefix(&plan.provider) { + return Err(CoreError::Media( + "generation provider prefix is invalid".to_string(), + )); + } + if let Some(folder_id) = plan.folder_id.as_deref() { + if !self + .state + .manifest + .folders + .iter() + .any(|folder| folder.id == folder_id) + { + return Err(CoreError::Media(format!( + "generation folder does not exist: {folder_id}" + ))); + } + } + if let Some(source_asset_id) = plan.source_asset_id.as_deref() { + if !self + .state + .manifest + .entries + .iter() + .any(|entry| entry.id == source_asset_id) + { + return Err(CoreError::Media(format!( + "generation source asset does not exist: {source_asset_id}" + ))); + } + } + + let job_id = ids.next_id(); + plan.input.job_id = Some(job_id.clone()); + plan.input.provider = Some(plan.provider.clone()); + plan.input.provider_job_id = None; + plan.input.status = Some(GenerationJobStatus::Queued); + plan.input.progress = Some(0.0); + plan.input.error_code = None; + plan.input.source_asset_id = plan.source_asset_id.clone(); + plan.input.source_clip_id = plan.source_clip_id.clone(); + plan.input.estimated_cost_credits = plan.estimated_cost_credits; + plan.input.created_at = plan.created_at; + + let mut placeholder_asset_ids = Vec::with_capacity(plan.output_count); + for output_index in 0..plan.output_count { + let asset_id = ids.next_id(); + let mut input = plan.input.clone(); + input.output_index = Some(output_index); + let display_name = if plan.output_count == 1 { + plan.name.clone() + } else { + format!("{} {}", plan.name, output_index + 1) + }; + self.state.manifest.entries.push(MediaManifestEntry { + id: asset_id.clone(), + name: display_name, + kind: plan.kind, + source: MediaSource::Project { + relative_path: format!("media/{asset_id}.pending"), + }, + duration: (input.duration.max(0)) as f64, + generation_input: Some(input.clone()), + source_width: None, + source_height: None, + source_fps: None, + has_audio: Some( + plan.kind == ClipType::Audio + || (plan.kind == ClipType::Video && input.generate_audio.unwrap_or(true)), + ), + color: None, + proxy: None, + folder_id: plan.folder_id.clone(), + cached_remote_url: None, + cached_remote_url_expires_at: None, + }); + self.generation_log + .entries + .push(GenerationLogEntry::job_event( + ids.next_id(), + job_id.clone(), + input.model.clone(), + None, + plan.provider.clone(), + None, + asset_id.clone(), + GenerationJobStatus::Queued, + Some(0.0), + None, + plan.created_at, + plan.source_asset_id.clone(), + plan.source_clip_id.clone(), + )); + placeholder_asset_ids.push(asset_id); + } + self.generation_log_component_present = true; + Ok(GenerationJobCommit { + job_id, + placeholder_asset_ids, + }) + } + + pub(crate) fn update_generation_job( + &mut self, + job_id: &str, + update: GenerationStateUpdate, + ids: &dyn IdGen, + ) -> Result { + self.ensure_mutable()?; + validate_generation_update(&update)?; + let mut events = Vec::new(); + for entry in &mut self.state.manifest.entries { + let Some(input) = entry.generation_input.as_mut() else { + continue; + }; + if input.job_id.as_deref() != Some(job_id) { + continue; + } + if !valid_generation_transition(input.status, update.status) { + return Err(CoreError::Media(format!( + "invalid generation transition from {:?} to {:?}", + input.status, update.status + ))); + } + input.status = Some(update.status); + input.progress = update.progress; + input.error_code = update.error_code.clone(); + if update.provider_job_id.is_some() { + input.provider_job_id = update.provider_job_id.clone(); + } + events.push((entry.id.clone(), input.clone())); + } + if events.is_empty() { + return Err(CoreError::Media(format!( + "generation job does not exist: {job_id}" + ))); + } + for (event_index, (asset_id, input)) in events.iter().enumerate() { + self.append_generation_event( + ids, + asset_id, + input, + update.status, + update.progress, + update.error_code.clone(), + (event_index == 0).then_some(update.cost_credits).flatten(), + update.created_at, + ); + } + Ok(events.len()) + } + + pub(crate) fn finalize_generation_output( + &mut self, + output: PreparedGenerationOutput, + ids: &dyn IdGen, + ) -> Result<()> { + self.ensure_mutable()?; + validate_project_media_relative_path(&output.relative_path)?; + let entry = self + .state + .manifest + .entries + .iter_mut() + .find(|entry| entry.id == output.asset_id) + .ok_or_else(|| { + CoreError::Media(format!( + "generation placeholder does not exist: {}", + output.asset_id + )) + })?; + let input = entry.generation_input.as_mut().ok_or_else(|| { + CoreError::Media("generation placeholder has no provenance".to_string()) + })?; + if !valid_generation_transition(input.status, GenerationJobStatus::Ready) { + return Err(CoreError::Media(format!( + "generation placeholder cannot finalize from {:?}", + input.status + ))); + } + entry.source = MediaSource::Project { + relative_path: output.relative_path, + }; + entry.duration = output.probe.duration_secs; + entry.source_width = output.probe.width; + entry.source_height = output.probe.height; + entry.source_fps = output.probe.fps; + entry.has_audio = Some(output.probe.has_audio); + entry.color = output.probe.color.clone(); + entry.proxy = None; + input.status = Some(GenerationJobStatus::Ready); + input.progress = Some(1.0); + input.error_code = None; + let asset_id = entry.id.clone(); + let input = input.clone(); + self.append_generation_event( + ids, + &asset_id, + &input, + GenerationJobStatus::Ready, + Some(1.0), + None, + None, + output.created_at, + ); + Ok(()) + } + + pub(crate) fn fail_generation_output( + &mut self, + asset_id: &str, + error_code: &str, + created_at: Option, + ids: &dyn IdGen, + ) -> Result<()> { + if !safe_generation_error_code(error_code) { + return Err(CoreError::Media( + "generation failure code is invalid".to_string(), + )); + } + let entry = self + .state + .manifest + .entries + .iter_mut() + .find(|entry| entry.id == asset_id) + .ok_or_else(|| { + CoreError::Media(format!("generation placeholder does not exist: {asset_id}")) + })?; + let input = entry.generation_input.as_mut().ok_or_else(|| { + CoreError::Media("generation placeholder has no provenance".to_string()) + })?; + if matches!(input.status, Some(GenerationJobStatus::Ready)) { + return Ok(()); + } + input.status = Some(GenerationJobStatus::Failed); + input.progress = None; + input.error_code = Some(error_code.to_string()); + let input = input.clone(); + self.append_generation_event( + ids, + asset_id, + &input, + GenerationJobStatus::Failed, + None, + Some(error_code.to_string()), + None, + created_at, + ); + Ok(()) + } + + pub(crate) fn cancel_generation_output( + &mut self, + asset_id: &str, + created_at: Option, + ids: &dyn IdGen, + ) -> Result<()> { + let entry = self + .state + .manifest + .entries + .iter_mut() + .find(|entry| entry.id == asset_id) + .ok_or_else(|| { + CoreError::Media(format!("generation placeholder does not exist: {asset_id}")) + })?; + let input = entry.generation_input.as_mut().ok_or_else(|| { + CoreError::Media("generation placeholder has no provenance".to_string()) + })?; + if matches!( + input.status, + Some( + GenerationJobStatus::Ready + | GenerationJobStatus::Failed + | GenerationJobStatus::Cancelled + ) + ) { + return Ok(()); + } + input.status = Some(GenerationJobStatus::Cancelled); + input.progress = None; + input.error_code = None; + let input = input.clone(); + self.append_generation_event( + ids, + asset_id, + &input, + GenerationJobStatus::Cancelled, + None, + None, + None, + created_at, + ); + Ok(()) + } + + pub(crate) fn save_generation_state(&mut self) -> Result { + self.ensure_mutable()?; + let target = self.project_dir.clone().ok_or(CoreError::NoProjectOpen)?; + let mut project = + Project::new_with_compatibility(target.clone(), self.compatibility.clone()); + project.timeline = self.state.timeline.clone(); + project.manifest = self.state.manifest.clone(); + project.generation_log = Some(self.generation_log.clone()); + // Generation spans media.json + generation-log.json. Publish a complete + // sibling bundle so both become visible at one rename commit point. + // The source root carries media/chat/thumbnail into the fresh stage. + let source_root = self.project_root.take().ok_or(CoreError::NoProjectOpen)?; + let new_root = match project.publish_complete_replacing_root(&target, source_root) { + Ok(root) => root, + Err(error) => { + // A pre-commit failure restores the original target; recover + // retained authority when possible while preserving the exact + // publication error for the caller. Post-commit ambiguity stays + // fail-closed if the target cannot be reopened. + self.project_root = ProjectRoot::open(&target).ok(); + return Err(error.into()); + } + }; + self.project_root = Some(new_root); + self.generation_log_component_present = true; + Ok(target) + } + + pub(crate) fn save_generation_state_with_media( + &mut self, + media_leaf: &str, + media_byte_size: u64, + media: &mut dyn std::io::Read, + ) -> Result { + self.ensure_mutable()?; + let target = self.project_dir.clone().ok_or(CoreError::NoProjectOpen)?; + let mut project = + Project::new_with_compatibility(target.clone(), self.compatibility.clone()); + project.timeline = self.state.timeline.clone(); + project.manifest = self.state.manifest.clone(); + project.generation_log = Some(self.generation_log.clone()); + let source_root = self.project_root.take().ok_or(CoreError::NoProjectOpen)?; + let new_root = match project.publish_complete_replacing_root_with_media( + &target, + source_root, + media_leaf, + media_byte_size, + media, + ) { + Ok(root) => root, + Err(error) => { + self.project_root = ProjectRoot::open(&target).ok(); + return Err(error.into()); + } + }; + self.project_root = Some(new_root); + self.generation_log_component_present = true; + Ok(target) + } + + pub(crate) fn checkpoint_generation_state(&self) -> GenerationStateCheckpoint { + GenerationStateCheckpoint { + manifest: self.state.manifest.clone(), + log: self.generation_log.clone(), + component_present: self.generation_log_component_present, + } + } + + pub(crate) fn restore_generation_state(&mut self, checkpoint: GenerationStateCheckpoint) { + self.state.manifest = checkpoint.manifest; + self.generation_log = checkpoint.log; + self.generation_log_component_present = checkpoint.component_present; + } + + #[allow(clippy::too_many_arguments)] + fn append_generation_event( + &mut self, + ids: &dyn IdGen, + asset_id: &str, + input: &GenerationInput, + status: GenerationJobStatus, + progress: Option, + error_code: Option, + cost_credits: Option, + created_at: Option, + ) { + self.generation_log + .entries + .push(GenerationLogEntry::job_event( + ids.next_id(), + input.job_id.clone().unwrap_or_default(), + input.model.clone(), + cost_credits, + input.provider.clone().unwrap_or_default(), + input.provider_job_id.clone(), + asset_id.to_string(), + status, + progress, + error_code, + created_at, + input.source_asset_id.clone(), + input.source_clip_id.clone(), + )); + self.generation_log_component_present = true; + } + /// Compatibility state inherited from the opened project. pub fn compatibility(&self) -> &ProjectCompatibility { &self.compatibility @@ -793,6 +1574,7 @@ mod tests { height: Some(1080), fps: Some(30.0), has_audio: true, + color: None, }; let entry = s .import_media_file("/abs/clip.mp4", "asset-1", "clip", &probe) @@ -824,6 +1606,48 @@ mod tests { assert_eq!(s.version(), 0); } + #[test] + fn derived_stem_import_reuses_media_path_and_persists_provenance() { + let mut session = EditorSession::new_project(); + let source_probe = ProbedMedia { + duration_secs: 5.0, + has_audio: true, + ..ProbedMedia::default() + }; + session + .import_media_file("/abs/source.wav", "source-asset", "Source", &source_probe) + .unwrap(); + let stem = session + .import_derived_stem_file( + "/abs/source-vocals.wav", + "stem-asset", + "Source Vocals", + &source_probe, + DerivedStemProvenance { + source_asset_id: "source-asset".into(), + source_sha256: "a".repeat(64), + execution: "local:opentake-center-v1".into(), + model_sha256: Some("b".repeat(64)), + stem: "vocals".into(), + }, + ) + .unwrap(); + let provenance = stem.generation_input.expect("derived provenance"); + assert_eq!(provenance.prompt, "stem:vocals"); + assert_eq!(provenance.provider.as_deref(), Some("local")); + assert_eq!(provenance.model, "opentake-center-v1"); + assert_eq!(provenance.source_asset_id.as_deref(), Some("source-asset")); + assert_eq!( + provenance.reference_audio_urls, + Some(vec![format!("sha256:{}", "a".repeat(64))]) + ); + assert_eq!( + provenance.quality, + Some(format!("model-sha256:{}", "b".repeat(64))) + ); + assert_eq!(provenance.status, Some(GenerationJobStatus::Ready)); + } + #[test] fn reimporting_the_same_file_reuses_the_entry_instead_of_duplicating() { // #91: importing a file already in the manifest must not append a second @@ -836,6 +1660,7 @@ mod tests { height: Some(480), fps: Some(24.0), has_audio: true, + color: None, }; let first = s .import_media_file("/abs/clip.mp4", "asset-1", "clip", &probe) @@ -851,6 +1676,55 @@ mod tests { assert_eq!(second.source, first.source); } + #[test] + fn media_proxy_metadata_is_confined_and_never_replaces_source() { + let mut session = EditorSession::new_project(); + let source = "/abs/source.mp4"; + let entry = session + .import_media_file(source, "asset", "source", &ProbedMedia::default()) + .unwrap(); + let original_source = entry.source; + let proxy = MediaProxy { + relative_path: "media/proxies/asset.mp4".into(), + source_sha256: "a".repeat(64), + width: 640, + height: 360, + }; + let updated = session + .set_media_proxy("asset", Some(proxy.clone())) + .unwrap(); + assert_eq!(updated.source, original_source); + assert_eq!(updated.proxy, Some(proxy)); + + assert!(session + .set_media_proxy( + "asset", + Some(MediaProxy { + relative_path: "../outside.mp4".into(), + source_sha256: "a".repeat(64), + width: 640, + height: 360, + }), + ) + .is_err()); + assert!(session + .set_media_proxy( + "asset", + Some(MediaProxy { + relative_path: "media/proxies/nested/asset.mp4".into(), + source_sha256: "a".repeat(64), + width: 640, + height: 360, + }), + ) + .is_err()); + assert!(session + .set_media_proxy("asset", None) + .unwrap() + .proxy + .is_none()); + } + #[test] fn import_image_has_no_audio_regardless_of_probe() { let mut s = EditorSession::new_project(); @@ -860,6 +1734,7 @@ mod tests { height: Some(600), fps: None, has_audio: true, // probe lies; an image never has audio + color: None, }; let entry = s .import_media_file("/abs/pic.png", "img-1", "pic", &probe) @@ -961,6 +1836,8 @@ mod tests { source_height: Some(2), source_fps: None, has_audio: None, + color: None, + proxy: None, folder_id: None, cached_remote_url: None, cached_remote_url_expires_at: None, diff --git a/crates/opentake-core/tests/generation_persistence.rs b/crates/opentake-core/tests/generation_persistence.rs new file mode 100644 index 00000000..6db20e06 --- /dev/null +++ b/crates/opentake-core/tests/generation_persistence.rs @@ -0,0 +1,347 @@ +use std::fs; + +use opentake_core::{ + AppCore, GenerationStateUpdate, PreparedGenerationJob, PreparedGenerationOutput, ProbedMedia, +}; +use opentake_domain::{ + ClipType, GenerationInput, GenerationJobStatus, MediaManifestEntry, MediaSource, +}; +use opentake_project::Project; + +fn saved_project() -> (tempfile::TempDir, std::path::PathBuf) { + let temp = tempfile::tempdir().unwrap(); + let bundle = temp.path().join("Generation.opentake"); + let mut project = Project::new(&bundle); + project.manifest.entries.push(MediaManifestEntry { + id: "source-image".to_string(), + name: "source.png".to_string(), + kind: ClipType::Image, + source: MediaSource::Project { + relative_path: "media/source.png".to_string(), + }, + duration: 0.0, + generation_input: None, + source_width: Some(4), + source_height: Some(3), + source_fps: None, + has_audio: Some(false), + color: None, + proxy: None, + folder_id: None, + cached_remote_url: None, + cached_remote_url_expires_at: None, + }); + project.save().unwrap(); + fs::create_dir_all(bundle.join("media")).unwrap(); + fs::write(bundle.join("media/source.png"), b"source-bytes").unwrap(); + fs::write(bundle.join("thumbnail.jpg"), b"cover").unwrap(); + (temp, bundle) +} + +fn upscale_plan() -> PreparedGenerationJob { + PreparedGenerationJob { + name: "Upscaled source".to_string(), + kind: ClipType::Image, + folder_id: None, + provider: "fal".to_string(), + input: GenerationInput { + prompt: String::new(), + model: "fal:fixture-upscaler".to_string(), + duration: 0, + aspect_ratio: String::new(), + ..Default::default() + }, + output_count: 1, + source_asset_id: Some("source-image".to_string()), + source_clip_id: Some("source-clip".to_string()), + estimated_cost_credits: Some(12), + created_at: Some(800_000_000.0), + } +} + +fn update(status: GenerationJobStatus, progress: Option) -> GenerationStateUpdate { + GenerationStateUpdate { + status, + progress, + error_code: None, + provider_job_id: None, + cost_credits: None, + created_at: Some(800_000_001.0), + } +} + +#[test] +fn placeholders_job_events_and_finalized_output_survive_restart() { + let (_temp, bundle) = saved_project(); + let core = AppCore::new(); + core.open_project(&bundle).unwrap(); + let runtime = core.runtime_snapshot(); + + let committed = core + .begin_generation_job_for_project(runtime.project_epoch, &bundle, upscale_plan()) + .unwrap(); + assert_eq!(committed.placeholder_asset_ids.len(), 1); + let asset_id = committed.placeholder_asset_ids[0].clone(); + + let queued = Project::open(&bundle).unwrap(); + let placeholder = queued + .manifest + .entries + .iter() + .find(|entry| entry.id == asset_id) + .unwrap(); + let input = placeholder.generation_input.as_ref().unwrap(); + assert_eq!(input.status, Some(GenerationJobStatus::Queued)); + assert_eq!(input.source_asset_id.as_deref(), Some("source-image")); + assert_eq!(input.source_clip_id.as_deref(), Some("source-clip")); + assert_eq!(input.estimated_cost_credits, Some(12)); + assert_eq!(fs::read(bundle.join("thumbnail.jpg")).unwrap(), b"cover"); + assert_eq!( + queued.generation_log.as_ref().unwrap().entries[0].status, + Some(GenerationJobStatus::Queued) + ); + + let mut running = update(GenerationJobStatus::Generating, Some(0.2)); + running.provider_job_id = Some("fal::fixture-job".to_string()); + core.update_generation_job_for_project( + runtime.project_epoch, + &bundle, + &committed.job_id, + running, + ) + .unwrap(); + let mut downloading = update(GenerationJobStatus::Downloading, Some(0.8)); + downloading.cost_credits = Some(11); + core.update_generation_job_for_project( + runtime.project_epoch, + &bundle, + &committed.job_id, + downloading, + ) + .unwrap(); + core.update_generation_job_for_project( + runtime.project_epoch, + &bundle, + &committed.job_id, + update(GenerationJobStatus::Finalizing, Some(0.9)), + ) + .unwrap(); + + let media_leaf = format!("{asset_id}.png"); + let relative_path = format!("media/{media_leaf}"); + let mut generated_media = std::io::Cursor::new(b"upscaled-bytes"); + core.finalize_generation_output_with_media_for_project( + runtime.project_epoch, + &bundle, + PreparedGenerationOutput { + asset_id: asset_id.clone(), + relative_path: relative_path.clone(), + probe: ProbedMedia { + duration_secs: 0.0, + width: Some(8), + height: Some(6), + fps: None, + has_audio: false, + color: None, + }, + created_at: Some(800_000_002.0), + }, + &media_leaf, + 14, + &mut generated_media, + ) + .unwrap(); + + let reopened = AppCore::new(); + reopened.open_project(&bundle).unwrap(); + let media = reopened.media(); + let source = media + .entries + .iter() + .find(|entry| entry.id == "source-image") + .unwrap(); + assert_eq!(source.source_width, Some(4)); + assert_eq!(source.source_height, Some(3)); + assert_eq!( + fs::read(bundle.join("media/source.png")).unwrap(), + b"source-bytes" + ); + + let output = media + .entries + .iter() + .find(|entry| entry.id == asset_id) + .unwrap(); + assert_eq!(output.source_width, Some(8)); + assert_eq!(output.source_height, Some(6)); + assert_eq!( + output.source, + MediaSource::Project { + relative_path: relative_path.clone() + } + ); + assert_eq!( + output.generation_input.as_ref().unwrap().status, + Some(GenerationJobStatus::Ready) + ); + assert_eq!( + fs::read(bundle.join(&relative_path)).unwrap(), + b"upscaled-bytes" + ); + assert_eq!(fs::read(bundle.join("thumbnail.jpg")).unwrap(), b"cover"); + + let log = reopened.generation_log(); + assert_eq!(log.entries.len(), 5); + assert_eq!(log.total_credits(), 11); + assert_eq!( + log.entries.last().and_then(|entry| entry.status), + Some(GenerationJobStatus::Ready) + ); + assert!(log.entries.iter().all(|entry| { + let json = serde_json::to_string(entry).unwrap(); + !json.contains("source-bytes") && !json.contains("https://") + })); +} + +#[test] +fn invalid_progress_and_error_codes_do_not_mutate_the_durable_job() { + let (_temp, bundle) = saved_project(); + let core = AppCore::new(); + core.open_project(&bundle).unwrap(); + let runtime = core.runtime_snapshot(); + let committed = core + .begin_generation_job_for_project(runtime.project_epoch, &bundle, upscale_plan()) + .unwrap(); + let before = fs::read(bundle.join("media.json")).unwrap(); + + let invalid = GenerationStateUpdate { + status: GenerationJobStatus::Generating, + progress: Some(f64::NAN), + error_code: None, + provider_job_id: None, + cost_credits: None, + created_at: None, + }; + assert!(core + .update_generation_job_for_project( + runtime.project_epoch, + &bundle, + &committed.job_id, + invalid, + ) + .is_err()); + assert_eq!(fs::read(bundle.join("media.json")).unwrap(), before); + + assert!(core + .fail_generation_output_for_project( + runtime.project_epoch, + &bundle, + &committed.placeholder_asset_ids[0], + "provider leaked /private/path", + None, + ) + .is_err()); + assert_eq!(fs::read(bundle.join("media.json")).unwrap(), before); +} + +#[test] +fn cancelling_a_partially_finalized_job_preserves_ready_outputs() { + let (_temp, bundle) = saved_project(); + let core = AppCore::new(); + core.open_project(&bundle).unwrap(); + let runtime = core.runtime_snapshot(); + let mut plan = upscale_plan(); + plan.output_count = 2; + let committed = core + .begin_generation_job_for_project(runtime.project_epoch, &bundle, plan) + .unwrap(); + let ready_id = committed.placeholder_asset_ids[0].clone(); + let cancelled_id = committed.placeholder_asset_ids[1].clone(); + + for (status, progress) in [ + (GenerationJobStatus::Generating, Some(0.2)), + (GenerationJobStatus::Downloading, Some(0.8)), + (GenerationJobStatus::Finalizing, Some(0.9)), + ] { + core.update_generation_job_for_project( + runtime.project_epoch, + &bundle, + &committed.job_id, + update(status, progress), + ) + .unwrap(); + } + + let relative_path = format!("media/{ready_id}.png"); + fs::write(bundle.join(&relative_path), b"ready-output").unwrap(); + core.finalize_generation_output_for_project( + runtime.project_epoch, + &bundle, + PreparedGenerationOutput { + asset_id: ready_id.clone(), + relative_path: relative_path.clone(), + probe: ProbedMedia { + duration_secs: 0.0, + width: Some(8), + height: Some(6), + fps: None, + has_audio: false, + color: None, + }, + created_at: Some(800_000_002.0), + }, + ) + .unwrap(); + core.cancel_generation_output_for_project( + runtime.project_epoch, + &bundle, + &ready_id, + Some(800_000_003.0), + ) + .unwrap(); + core.cancel_generation_output_for_project( + runtime.project_epoch, + &bundle, + &cancelled_id, + Some(800_000_003.0), + ) + .unwrap(); + + let reopened = Project::open(&bundle).unwrap(); + let ready = reopened + .manifest + .entries + .iter() + .find(|entry| entry.id == ready_id) + .unwrap(); + assert_eq!( + ready.generation_input.as_ref().unwrap().status, + Some(GenerationJobStatus::Ready) + ); + assert_eq!( + ready.source, + MediaSource::Project { + relative_path: relative_path.clone(), + } + ); + assert_eq!( + fs::read(bundle.join(relative_path)).unwrap(), + b"ready-output" + ); + + let cancelled = reopened + .manifest + .entries + .iter() + .find(|entry| entry.id == cancelled_id) + .unwrap(); + assert_eq!( + cancelled.generation_input.as_ref().unwrap().status, + Some(GenerationJobStatus::Cancelled) + ); + assert!(matches!( + cancelled.source, + MediaSource::Project { ref relative_path } if relative_path.ends_with(".pending") + )); + assert!(!bundle.join(format!("media/{cancelled_id}.png")).exists()); +} diff --git a/crates/opentake-core/tests/project_open.rs b/crates/opentake-core/tests/project_open.rs index 6d1903e5..449ca8c9 100644 --- a/crates/opentake-core/tests/project_open.rs +++ b/crates/opentake-core/tests/project_open.rs @@ -60,6 +60,8 @@ fn manifest_entry(id: &str, generation_input: Option) -> MediaM source_height: None, source_fps: None, has_audio: Some(true), + color: None, + proxy: None, folder_id: None, cached_remote_url: None, cached_remote_url_expires_at: None, diff --git a/crates/opentake-core/tests/schema_compat.rs b/crates/opentake-core/tests/schema_compat.rs index 07901630..694d97b0 100644 --- a/crates/opentake-core/tests/schema_compat.rs +++ b/crates/opentake-core/tests/schema_compat.rs @@ -47,6 +47,8 @@ fn external_entry(id: &str, name: &str, source: &Path) -> MediaManifestEntry { source_height: Some(240), source_fps: Some(30.0), has_audio: Some(false), + color: None, + proxy: None, folder_id: None, cached_remote_url: None, cached_remote_url_expires_at: None, diff --git a/crates/opentake-domain/src/audio.rs b/crates/opentake-domain/src/audio.rs new file mode 100644 index 00000000..91e98344 --- /dev/null +++ b/crates/opentake-domain/src/audio.rs @@ -0,0 +1,86 @@ +use serde::{Deserialize, Serialize}; + +/// Deterministic local denoise profiles. Both use the same spectral processing +/// owner; `Voice` applies stronger subtraction for spoken-word material. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum DenoiseMode { + #[default] + Adaptive, + Voice, +} + +/// Non-destructive denoise parameters persisted on one clip. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AudioDenoise { + pub mode: DenoiseMode, + pub strength: f64, + /// Inspector A/B toggle. Export always applies the configured operation; + /// native preview applies it only while this flag is true. + pub preview_enabled: bool, +} + +impl AudioDenoise { + pub fn validate(&self) -> Result<(), &'static str> { + if !self.strength.is_finite() || !(0.0..=1.0).contains(&self.strength) { + return Err("denoise strength must be finite and between 0 and 1"); + } + Ok(()) + } +} + +/// Persisted result of one clip loudness analysis. The measured values make the +/// operation reproducible; playback/export consume only `gain_db` and never +/// need to re-read the source. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LoudnessNormalization { + pub target_lufs: f64, + pub true_peak_ceiling_dbtp: f64, + pub input_integrated_lufs: f64, + pub input_true_peak_dbtp: f64, + pub gain_db: f64, + pub output_integrated_lufs: f64, + pub output_true_peak_dbtp: f64, +} + +impl LoudnessNormalization { + pub fn validate(&self) -> Result<(), &'static str> { + let values = [ + self.target_lufs, + self.true_peak_ceiling_dbtp, + self.input_integrated_lufs, + self.input_true_peak_dbtp, + self.gain_db, + self.output_integrated_lufs, + self.output_true_peak_dbtp, + ]; + if values.iter().any(|value| !value.is_finite()) { + return Err("loudness values must be finite"); + } + if self.true_peak_ceiling_dbtp > 0.0 { + return Err("true-peak ceiling must be at most 0 dBTP"); + } + if !(-70.0..=0.0).contains(&self.target_lufs) + || !(-20.0..=0.0).contains(&self.true_peak_ceiling_dbtp) + || !(-120.0..=60.0).contains(&self.gain_db) + { + return Err("loudness target, ceiling, or gain is outside the supported range"); + } + if (self.output_integrated_lufs - self.target_lufs).abs() > 1.0 { + return Err("normalized loudness does not reach its target"); + } + if self.output_true_peak_dbtp > self.true_peak_ceiling_dbtp + 0.05 { + return Err("normalized true peak exceeds its ceiling"); + } + Ok(()) + } + + pub fn linear_gain(self) -> f64 { + if !self.gain_db.is_finite() { + return 1.0; + } + 10.0_f64.powf(self.gain_db.clamp(-120.0, 60.0) / 20.0) + } +} diff --git a/crates/opentake-domain/src/clip.rs b/crates/opentake-domain/src/clip.rs index d8570097..be8c495f 100644 --- a/crates/opentake-domain/src/clip.rs +++ b/crates/opentake-domain/src/clip.rs @@ -17,10 +17,26 @@ use crate::clip_wire::{ deserialize_one_on_error, deserialize_optional_crop_track_on_error, deserialize_optional_f64_track_on_error, deserialize_optional_pair_track_on_error, }; -use crate::grade::{ChromaKey, ColorGrade, Effect, Mask}; +use crate::grade::{ChromaKey, ColorGrade, ColorMatchInput, Effect, Mask}; use crate::keyframe::{AnimPair, AnimatableProperty, Interpolation, KeyframeTrack}; +use crate::lut::LutReference; +use crate::stabilization::StabilizationTrack; use crate::text::TextStyle; use crate::transform::{Crop, Point, Transform}; +use crate::transition::Transition; + +/// Persisted provenance for one accepted caption translation. The original +/// text remains available for review/recovery and manual text edits clear this +/// record so the project never attributes authored text to a provider. +#[derive(Clone, PartialEq, Eq, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CaptionTranslationInput { + pub source_text: String, + pub source_locale: String, + pub target_locale: String, + pub provider: String, + pub model: String, +} /// Linear amplitude <-> dB mapping for the volume slider. 1:1 port of /// upstream `VolumeScale`. Below the floor we snap to true 0 (hard mute). @@ -147,6 +163,15 @@ pub struct Clip { skip_serializing_if = "Option::is_none" )] pub caption_group_id: Option, + /// ID of an editable child timeline in the root timeline's + /// `nestedSequences` registry. Nested clips never overload `mediaRef` with + /// a sentinel value, so media and sequence identity remain unambiguous. + #[serde( + default, + deserialize_with = "deserialize_default_on_error", + skip_serializing_if = "Option::is_none" + )] + pub nested_sequence_id: Option, // Text clips only. #[serde( @@ -161,6 +186,8 @@ pub struct Clip { skip_serializing_if = "Option::is_none" )] pub text_style: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub caption_translation_input: Option, // Keyframe tracks for each animatable property. None when no animation exists. #[serde( @@ -200,12 +227,29 @@ pub struct Clip { )] pub volume_track: Option>, + /// Source analysis plus the static gain used identically by preview and + /// export. `None` leaves authored volume behavior unchanged. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub loudness_normalization: Option, + + /// Local non-destructive denoise configuration. Source PCM is never + /// rewritten; native preview and export process decoded copies. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub audio_denoise: Option, + // Advanced pixel-effect fields (A-tier; `docs/ADVANCED-FEATURES.md`). All // `#[serde(default)]` + Option/Vec, so older projects (without these keys) // decode unchanged, and an all-default clip omits them on the way out. /// High-end floating-point color grade (linear-light chain). `None` = no grade. #[serde(default, skip_serializing_if = "Option::is_none")] pub color_grade: Option, + /// Sampling inputs and measured result for an automatically matched grade. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub color_match_input: Option, + /// Project-managed 3D LUT reference. The persisted id maps only to the + /// bundle's canonical `media/luts/.cube` location. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub lut: Option, /// Green/blue-screen chroma key. `None` = no keying. #[serde(default, skip_serializing_if = "Option::is_none")] pub chroma_key: Option, @@ -215,6 +259,17 @@ pub struct Clip { /// Generic named-effect chain. Empty = no effects. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub effects: Vec, + /// Non-destructive camera compensation composed with authored transform tracks. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub stabilization: Option, + /// Optional visual transition into one exact adjacent successor. Pair + /// identity prevents stale transitions from rebinding after moves/deletes. + #[serde( + default, + deserialize_with = "deserialize_default_on_error", + skip_serializing_if = "Option::is_none" + )] + pub transition_out: Option, /// Reverse playback. When true, video clips sample their referenced source /// window in reverse order. Non-video sources ignore this flag. #[serde(default, skip_serializing_if = "is_false")] @@ -245,18 +300,25 @@ impl Clip { "crop", "linkGroupId", "captionGroupId", + "nestedSequenceId", "textContent", "textStyle", + "captionTranslationInput", "opacityTrack", "positionTrack", "scaleTrack", "rotationTrack", "cropTrack", "volumeTrack", + "loudnessNormalization", + "audioDenoise", "colorGrade", + "lut", "chromaKey", "masks", "effects", + "stabilization", + "transitionOut", "reversed", ]; pub const TOLERANT_SCALAR_WIRE_FIELDS: &'static [&'static str] = &[ @@ -334,22 +396,42 @@ impl Clip { crop: Crop::default(), link_group_id: None, caption_group_id: None, + nested_sequence_id: None, text_content: None, text_style: None, + caption_translation_input: None, opacity_track: None, position_track: None, scale_track: None, rotation_track: None, crop_track: None, volume_track: None, + loudness_normalization: None, + audio_denoise: None, color_grade: None, + color_match_input: None, + lut: None, chroma_key: None, masks: Vec::new(), effects: Vec::new(), + stabilization: None, + transition_out: None, reversed: false, } } + /// Construct a clip that references one editable nested sequence. + pub fn new_nested( + id: impl Into, + sequence_id: impl Into, + start_frame: i32, + duration_frames: i32, + ) -> Self { + let mut clip = Self::new(id, "", start_frame, duration_frames); + clip.nested_sequence_id = Some(sequence_id.into()); + clip + } + /// Frame where this clip ends on the timeline (exclusive end). pub fn end_frame(&self) -> i32 { self.start_frame + self.duration_frames @@ -486,7 +568,7 @@ impl Clip { } _ => 1.0, }; - self.volume * kf_gain * self.fade_multiplier(frame) + self.volume * kf_gain * self.loudness_gain() * self.fade_multiplier(frame) } /// Linear volume without the fade envelope. @@ -497,7 +579,13 @@ impl Clip { } _ => 1.0, }; - self.volume * kf_gain + self.volume * kf_gain * self.loudness_gain() + } + + pub fn loudness_gain(&self) -> f64 { + self.loudness_normalization + .map(crate::LoudnessNormalization::linear_gain) + .unwrap_or(1.0) } /// 0..=1 envelope from the fade head/tail ramps. `min(in, out)`. Returns 0 @@ -903,6 +991,27 @@ mod tests { approx(c.raw_volume_at(105), 1.0); } + #[test] + fn persisted_loudness_gain_is_shared_by_raw_and_effective_volume() { + let mut c = base_clip(); + c.volume = 0.5; + c.loudness_normalization = Some(crate::LoudnessNormalization { + target_lufs: -16.0, + true_peak_ceiling_dbtp: -1.0, + input_integrated_lufs: -22.0, + input_true_peak_dbtp: -8.0, + gain_db: 6.0, + output_integrated_lufs: -16.0, + output_true_peak_dbtp: -2.0, + }); + let expected = 0.5 * 10.0_f64.powf(6.0 / 20.0); + approx(c.raw_volume_at(110), expected); + approx(c.volume_at(110), expected); + let json = serde_json::to_string(&c).unwrap(); + let roundtrip: Clip = serde_json::from_str(&json).unwrap(); + assert_eq!(roundtrip.loudness_normalization, c.loudness_normalization); + } + #[test] fn live_volume_kf_db_requires_active_track_and_membership() { let mut c = base_clip(); @@ -1149,6 +1258,42 @@ mod tests { assert_eq!(c, back); } + #[test] + fn clip_transition_roundtrips_and_legacy_projects_default_to_none() { + let mut c = base_clip(); + c.transition_out = Some(crate::transition::Transition { + from_clip_id: "c1".into(), + to_clip_id: "c2".into(), + kind: crate::transition::TransitionKind::CrossDissolve, + duration_frames: 12, + }); + let json = serde_json::to_string(&c).unwrap(); + assert!(json.contains( + r#""transitionOut":{"fromClipId":"c1","toClipId":"c2","kind":"crossDissolve","durationFrames":12}"# + )); + let back: Clip = serde_json::from_str(&json).unwrap(); + assert_eq!(c, back); + + let legacy: Clip = serde_json::from_str( + r#"{"id":"old","mediaRef":"m","startFrame":0,"durationFrames":12}"#, + ) + .unwrap(); + assert!(legacy.transition_out.is_none()); + + let legacy_transition: Clip = serde_json::from_str( + r#"{"id":"c1","mediaRef":"m","startFrame":0,"durationFrames":12,"transitionOut":{"toClipId":"c2","kind":"crossDissolve","durationFrames":4}}"#, + ) + .unwrap(); + assert_eq!( + legacy_transition + .transition_out + .as_ref() + .unwrap() + .from_clip_id, + "" + ); + } + #[test] fn clip_decodes_with_missing_optional_fields() { // Only the required keys present; everything else falls back to defaults. @@ -1221,8 +1366,9 @@ mod tests { }, feather: 0.05, invert: false, + ..Mask::default() }]; - c.effects = vec![Effect::new("gaussianBlur").with_param("radius", 4.0)]; + c.effects = vec![Effect::new("grayscale").with_param("amount", 0.4)]; let json = serde_json::to_string(&c).unwrap(); assert!(json.contains("\"colorGrade\"")); assert!(json.contains("\"chromaKey\"")); diff --git a/crates/opentake-domain/src/grade.rs b/crates/opentake-domain/src/grade.rs index bac2593b..408c0658 100644 --- a/crates/opentake-domain/src/grade.rs +++ b/crates/opentake-domain/src/grade.rs @@ -22,6 +22,11 @@ use serde::{Deserialize, Serialize}; +/// Fixed GPU contract shared by editor validation and the compositor uniform. +pub const MAX_MASKS_PER_CLIP: usize = 4; +/// Maximum authored vertices in one pen/polygon mask. +pub const MAX_POLYGON_MASK_POINTS: usize = 16; + // =========================================================================== // Small numeric helpers (shared by the reference pixel math). // =========================================================================== @@ -134,23 +139,132 @@ impl LiftGammaGain { self.lift.is_zero() && self.gamma.is_one() && self.gain.is_one() } - /// Apply one channel: `gain * (x + lift)` then `^(1/gamma)`. Matches the - /// classic lift/gamma/gain operator (gamma applied last, as a display power). + /// Apply one channel: `gain * (x + lift * (1 - x))^(1/gamma)`. + /// Lift rolls off toward highlights, gamma shapes mid-tones, and gain remains + /// an independent highlight multiplier. #[inline] fn apply_channel(x: f64, lift: f64, gamma: f64, gain: f64) -> f64 { - let v = gain * (x + lift); + let shaped = x + lift * (1.0 - x); if gamma > 0.0 && (gamma - 1.0).abs() > f64::EPSILON { - // `v` can be negative after lift; guard the power. - v.max(0.0).powf(1.0 / gamma) + // `shaped` can be negative after lift; guard fractional powers. + gain * shaped.max(0.0).powf(1.0 / gamma) } else { - v + gain * shaped + } + } +} + +/// One feathered HSL qualifier applied after the primary color controls. +/// Hue values are normalized turns, so the range wraps continuously across +/// red (`0 == 1`) without a seam. +#[derive(Clone, Copy, PartialEq, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct HslSecondary { + /// Center of the selected hue range in normalized turns (`0..=1`). + pub hue_center: f64, + /// Full width of the selected hue range (`0 < width <= 1`). + pub hue_width: f64, + /// Soft edge width measured inward from both range boundaries (`0..=0.5`). + pub feather: f64, + /// Hue rotation in normalized turns (`-0.5..=0.5`). + pub hue_shift: f64, + /// Relative saturation adjustment (`-1..=1`). + pub saturation: f64, + /// Additive lightness adjustment (`-1..=1`). + pub lightness: f64, +} + +impl Default for HslSecondary { + fn default() -> Self { + Self { + hue_center: 0.0, + hue_width: 0.24, + feather: 0.08, + hue_shift: 0.0, + saturation: 0.0, + lightness: 0.0, + } + } +} + +impl HslSecondary { + fn is_identity(&self) -> bool { + self.hue_shift == 0.0 && self.saturation == 0.0 && self.lightness == 0.0 + } + + fn weight(&self, hue: f64, saturation: f64) -> f64 { + // Achromatic pixels have no stable hue and must not be selected. + if saturation <= f64::EPSILON { + return 0.0; + } + let distance = ((hue - self.hue_center + 0.5).rem_euclid(1.0) - 0.5).abs(); + let outer = self.hue_width * 0.5; + if distance > outer { + return 0.0; + } + if self.feather <= f64::EPSILON { + return 1.0; + } + let inner = (outer - self.feather).max(0.0); + 1.0 - smoothstep01(inner, outer, distance) + } + + fn apply(&self, r: f64, g: f64, b: f64) -> (f64, f64, f64) { + let (mut hue, mut saturation, mut lightness) = rgb_to_hsl(r, g, b); + let weight = self.weight(hue, saturation); + if weight <= f64::EPSILON { + return (r, g, b); } + hue = (hue + self.hue_shift * weight).rem_euclid(1.0); + saturation = clamp01(saturation * (1.0 + self.saturation * weight)); + lightness = clamp01(lightness + self.lightness * weight); + hsl_to_rgb(hue, saturation, lightness) } } +fn rgb_to_hsl(r: f64, g: f64, b: f64) -> (f64, f64, f64) { + let max = r.max(g).max(b); + let min = r.min(g).min(b); + let delta = max - min; + let lightness = (max + min) * 0.5; + if delta <= f64::EPSILON { + return (0.0, 0.0, lightness); + } + let saturation = delta / (1.0 - (2.0 * lightness - 1.0).abs()).max(f64::EPSILON); + let sector = if max == r { + ((g - b) / delta).rem_euclid(6.0) + } else if max == g { + (b - r) / delta + 2.0 + } else { + (r - g) / delta + 4.0 + }; + (sector / 6.0, saturation, lightness) +} + +fn hsl_to_rgb(hue: f64, saturation: f64, lightness: f64) -> (f64, f64, f64) { + let chroma = (1.0 - (2.0 * lightness - 1.0).abs()) * saturation; + let sector = hue.rem_euclid(1.0) * 6.0; + let x = chroma * (1.0 - (sector.rem_euclid(2.0) - 1.0).abs()); + let (r1, g1, b1) = if sector < 1.0 { + (chroma, x, 0.0) + } else if sector < 2.0 { + (x, chroma, 0.0) + } else if sector < 3.0 { + (0.0, chroma, x) + } else if sector < 4.0 { + (0.0, x, chroma) + } else if sector < 5.0 { + (x, 0.0, chroma) + } else { + (chroma, 0.0, x) + }; + let m = lightness - chroma * 0.5; + (r1 + m, g1 + m, b1 + m) +} + /// High-end floating-point color grade, applied in **linear light** in the order /// locked by the spec: -/// `exposure -> white balance -> lift/gamma/gain -> contrast -> saturation`. +/// `exposure -> white balance -> lift/gamma/gain -> contrast -> saturation -> HSL secondary`. /// /// Every field defaults to a no-op, so `ColorGrade::default()` is the identity /// transform (verified by [`ColorGrade::is_identity`] and a unit test). @@ -176,8 +290,48 @@ pub struct ColorGrade { /// Saturation multiplier (identity `1`; `0` = greyscale, `>1` = boosted). #[serde(default = "default_one")] pub saturation: f64, + /// Optional feathered hue qualifier. Absence preserves legacy projects and + /// avoids uploading an active secondary block for identity grades. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub hsl_secondary: Option, } +/// Persisted provenance for an automatically generated reference color match. +/// The grade remains fully editable; a later manual grade change clears this +/// record so projects never claim an edited grade is still the sampled match. +#[derive(Clone, PartialEq, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ColorMatchInput { + pub reference_media_ref: String, + pub reference_frame: i32, + pub target_frame: i32, + pub algorithm: String, + pub algorithm_version: u32, + pub target_mean_linear: Rgb, + pub reference_mean_linear: Rgb, + pub delta_e_before: f64, + pub delta_e_after: f64, + pub target_luma_before: f64, + pub target_luma_after: f64, +} + +/// Stable validation failure for authored color-grade parameters. The bounds +/// mirror the Inspector controls and keep malformed persisted data out of the +/// command and GPU paths. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub struct ColorGradeValidationError { + pub field: &'static str, + pub rule: &'static str, +} + +impl std::fmt::Display for ColorGradeValidationError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{} must be {}", self.field, self.rule) + } +} + +impl std::error::Error for ColorGradeValidationError {} + impl Default for ColorGrade { fn default() -> Self { ColorGrade { @@ -187,6 +341,7 @@ impl Default for ColorGrade { lift_gamma_gain: LiftGammaGain::default(), contrast: 0.0, saturation: 1.0, + hsl_secondary: None, } } } @@ -204,6 +359,120 @@ impl ColorGrade { && self.lift_gamma_gain.is_identity() && self.contrast == 0.0 && self.saturation == 1.0 + && self + .hsl_secondary + .is_none_or(|secondary| secondary.is_identity()) + } + + /// Validate the persisted grade against the finite parameter ranges exposed + /// by the editor. Gamma is strictly positive because it is used as a power + /// denominator in both the CPU reference and WGSL shader. + pub fn validate(&self) -> Result<(), ColorGradeValidationError> { + fn inclusive( + field: &'static str, + value: f64, + range: std::ops::RangeInclusive, + rule: &'static str, + ) -> Result<(), ColorGradeValidationError> { + if !value.is_finite() || !range.contains(&value) { + return Err(ColorGradeValidationError { field, rule }); + } + Ok(()) + } + + inclusive( + "exposure", + self.exposure, + -5.0..=5.0, + "finite and within [-5, 5]", + )?; + inclusive( + "temperature", + self.temperature, + -1.0..=1.0, + "finite and within [-1, 1]", + )?; + inclusive("tint", self.tint, -1.0..=1.0, "finite and within [-1, 1]")?; + for (field, value) in [ + ("liftGammaGain.lift.r", self.lift_gamma_gain.lift.r), + ("liftGammaGain.lift.g", self.lift_gamma_gain.lift.g), + ("liftGammaGain.lift.b", self.lift_gamma_gain.lift.b), + ] { + inclusive(field, value, -1.0..=1.0, "finite and within [-1, 1]")?; + } + for (field, value) in [ + ("liftGammaGain.gamma.r", self.lift_gamma_gain.gamma.r), + ("liftGammaGain.gamma.g", self.lift_gamma_gain.gamma.g), + ("liftGammaGain.gamma.b", self.lift_gamma_gain.gamma.b), + ] { + if !value.is_finite() || value <= 0.0 || value > 4.0 { + return Err(ColorGradeValidationError { + field, + rule: "finite and within (0, 4]", + }); + } + } + for (field, value) in [ + ("liftGammaGain.gain.r", self.lift_gamma_gain.gain.r), + ("liftGammaGain.gain.g", self.lift_gamma_gain.gain.g), + ("liftGammaGain.gain.b", self.lift_gamma_gain.gain.b), + ] { + inclusive(field, value, 0.0..=4.0, "finite and within [0, 4]")?; + } + inclusive( + "contrast", + self.contrast, + -1.0..=2.0, + "finite and within [-1, 2]", + )?; + inclusive( + "saturation", + self.saturation, + 0.0..=3.0, + "finite and within [0, 3]", + )?; + if let Some(secondary) = self.hsl_secondary { + inclusive( + "hslSecondary.hueCenter", + secondary.hue_center, + 0.0..=1.0, + "finite and within [0, 1]", + )?; + if !secondary.hue_width.is_finite() + || secondary.hue_width <= 0.0 + || secondary.hue_width > 1.0 + { + return Err(ColorGradeValidationError { + field: "hslSecondary.hueWidth", + rule: "finite and within (0, 1]", + }); + } + inclusive( + "hslSecondary.feather", + secondary.feather, + 0.0..=0.5, + "finite and within [0, 0.5]", + )?; + inclusive( + "hslSecondary.hueShift", + secondary.hue_shift, + -0.5..=0.5, + "finite and within [-0.5, 0.5]", + )?; + inclusive( + "hslSecondary.saturation", + secondary.saturation, + -1.0..=1.0, + "finite and within [-1, 1]", + )?; + inclusive( + "hslSecondary.lightness", + secondary.lightness, + -1.0..=1.0, + "finite and within [-1, 1]", + )?; + } + Ok(()) } /// Per-channel white-balance gain derived from `temperature` / `tint`. A @@ -266,6 +535,14 @@ impl ColorGrade { bb = l + (bb - l) * self.saturation; } + // 6. Feathered HSL secondary qualifier. + if let Some(secondary) = self + .hsl_secondary + .filter(|secondary| !secondary.is_identity()) + { + (rr, gg, bb) = secondary.apply(rr, gg, bb); + } + (clamp01(rr), clamp01(gg), clamp01(bb)) } } @@ -421,7 +698,7 @@ pub enum MaskShape { /// [`crate::transform::Point`] only to keep mask serialization self-contained and /// `Serialize`/`Deserialize`-derivable (the transform `Point` has hand-written /// (de)serialization elsewhere; here a plain derive is what we want). -#[derive(Clone, Copy, PartialEq, Debug, Serialize, Deserialize)] +#[derive(Clone, Copy, PartialEq, Debug, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Point2 { #[serde(default)] @@ -436,6 +713,56 @@ impl Point2 { } } +/// Optional whole-mask transform applied around the canvas center after the +/// shape's own geometry. Shape coordinates remain editable and portable while +/// offset/scale/rotation can move the complete mask non-destructively. +#[derive(Clone, Copy, PartialEq, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MaskTransform { + #[serde(default)] + pub offset: Point2, + #[serde(default = "default_mask_scale")] + pub scale: Point2, + #[serde(default)] + pub rotation_degrees: f64, +} + +fn default_mask_scale() -> Point2 { + Point2::new(1.0, 1.0) +} + +impl Default for MaskTransform { + fn default() -> Self { + MaskTransform { + offset: Point2::new(0.0, 0.0), + scale: default_mask_scale(), + rotation_degrees: 0.0, + } + } +} + +impl MaskTransform { + pub fn is_identity(&self) -> bool { + self.offset.x == 0.0 + && self.offset.y == 0.0 + && self.scale.x == 1.0 + && self.scale.y == 1.0 + && self.rotation_degrees == 0.0 + } + + fn inverse_point(&self, x: f64, y: f64) -> (f64, f64) { + let sx = self.scale.x.abs().max(f64::EPSILON); + let sy = self.scale.y.abs().max(f64::EPSILON); + let radians = self.rotation_degrees.to_radians(); + let (sin, cos) = radians.sin_cos(); + let dx = x - 0.5 - self.offset.x; + let dy = y - 0.5 - self.offset.y; + let unrotated_x = cos * dx + sin * dy; + let unrotated_y = -sin * dx + cos * dy; + (unrotated_x / sx + 0.5, unrotated_y / sy + 0.5) + } +} + /// A vector mask that generates a per-pixel alpha coverage. `feather` softens the /// edge; `invert` flips inside/outside. /// @@ -452,6 +779,9 @@ pub struct Mask { /// Invert coverage (mask out the inside instead of the outside). #[serde(default)] pub invert: bool, + /// Non-destructive whole-mask translation, scale, and rotation. + #[serde(default, skip_serializing_if = "MaskTransform::is_identity")] + pub transform: MaskTransform, } fn default_mask_shape() -> MaskShape { @@ -468,6 +798,7 @@ impl Default for Mask { shape: default_mask_shape(), feather: 0.0, invert: false, + transform: MaskTransform::default(), } } } @@ -478,6 +809,7 @@ impl Mask { /// polygon variant returns the unsigned distance with an inside/outside sign /// from an even-odd test (an exact polygon SDF is overkill for feathering). pub fn signed_distance(&self, x: f64, y: f64) -> f64 { + let (x, y) = self.transform.inverse_point(x, y); match &self.shape { MaskShape::Linear { point, normal } => { // Signed distance along the (assumed unit-ish) normal. We @@ -590,20 +922,129 @@ fn point_segment_dist2(px: f64, py: f64, ax: f64, ay: f64, bx: f64, by: f64) -> // Effect (generic named-parameter effect chain) // =========================================================================== -/// A generic named pixel effect with a flat parameter map — the extensible chain -/// the spec calls for (`Clip.effects: Vec`, each = one wgpu pass). The -/// `name` selects a shader/kernel; `params` are its named scalar inputs and -/// `enabled` lets a clip carry a disabled effect without removing it. -/// -/// Concrete effects (blur, glow, sharpen, ...) are deferred (see module TODO); -/// this type and its serde/round-trip are the stable contract that ops + agent -/// tools target now, and the render layer can grow per-name handling -/// incrementally without further domain changes. +/// Maximum authored effects evaluated for one clip. A fixed bound keeps the +/// persisted contract aligned with the portable GPU uniform layout. +pub const MAX_EFFECTS_PER_CLIP: usize = 8; + +/// One persisted scalar in an advertised effect's closed schema. +#[derive(Clone, Copy, PartialEq, Debug)] +pub struct EffectParameterDescriptor { + pub name: &'static str, + pub default: f64, + pub min: f64, + pub max: f64, +} + +/// An effect available to the editor, agent tools, preview, and export. +#[derive(Clone, Copy, PartialEq, Debug)] +pub struct EffectDescriptor { + pub name: &'static str, + pub parameters: &'static [EffectParameterDescriptor], +} + +const AMOUNT_PARAMETER: [EffectParameterDescriptor; 1] = [EffectParameterDescriptor { + name: "amount", + default: 1.0, + min: 0.0, + max: 1.0, +}]; + +const EFFECT_REGISTRY: [EffectDescriptor; 3] = [ + EffectDescriptor { + name: "grayscale", + parameters: &AMOUNT_PARAMETER, + }, + EffectDescriptor { + name: "sepia", + parameters: &AMOUNT_PARAMETER, + }, + EffectDescriptor { + name: "invert", + parameters: &AMOUNT_PARAMETER, + }, +]; + +/// The complete effect list advertised by every product surface. +pub fn effect_registry() -> &'static [EffectDescriptor] { + &EFFECT_REGISTRY +} + +/// Typed rejection for invalid persisted effect data. +#[derive(Clone, PartialEq, Debug)] +pub enum EffectValidationError { + TooManyEffects { + count: usize, + limit: usize, + }, + UnknownEffect { + name: String, + }, + UnknownParameter { + effect: String, + parameter: String, + }, + NonFiniteParameter { + effect: String, + parameter: String, + }, + ParameterOutOfRange { + effect: String, + parameter: String, + value: f64, + min: f64, + max: f64, + }, +} + +impl std::fmt::Display for EffectValidationError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::TooManyEffects { count, limit } => { + write!(f, "effect chain contains {count} entries; limit is {limit}") + } + Self::UnknownEffect { name } => write!(f, "unknown effect `{name}`"), + Self::UnknownParameter { effect, parameter } => { + write!(f, "unknown parameter `{parameter}` for effect `{effect}`") + } + Self::NonFiniteParameter { effect, parameter } => write!( + f, + "parameter `{parameter}` for effect `{effect}` must be finite" + ), + Self::ParameterOutOfRange { + effect, + parameter, + value, + min, + max, + } => write!( + f, + "parameter `{parameter}` for effect `{effect}` is {value}; expected {min}..={max}" + ), + } + } +} + +impl std::error::Error for EffectValidationError {} + +/// Validate a complete authored chain before an edit or render boundary. +pub fn validate_effect_chain(effects: &[Effect]) -> Result<(), EffectValidationError> { + if effects.len() > MAX_EFFECTS_PER_CLIP { + return Err(EffectValidationError::TooManyEffects { + count: effects.len(), + limit: MAX_EFFECTS_PER_CLIP, + }); + } + effects.iter().try_for_each(Effect::validate) +} + +/// A named pixel effect with a flat parameter map. Names and parameters remain +/// string-keyed for stable JSON compatibility, but every edit and render path +/// validates them against [`effect_registry`] rather than silently ignoring an +/// unknown value. #[derive(Clone, PartialEq, Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Effect { - /// Effect identifier (e.g. `"gaussianBlur"`). Free-form; the render layer maps - /// known names to passes and ignores unknown ones. + /// Identifier from [`effect_registry`]. pub name: String, /// Named scalar parameters. Insertion-stable ordering is not required; the /// render layer reads by key. @@ -638,6 +1079,60 @@ impl Effect { pub fn param(&self, key: &str, default: f64) -> f64 { self.params.get(key).copied().unwrap_or(default) } + + /// Validate this persisted value against the advertised closed schema. + pub fn validate(&self) -> Result<(), EffectValidationError> { + let descriptor = effect_registry() + .iter() + .find(|candidate| candidate.name == self.name) + .ok_or_else(|| EffectValidationError::UnknownEffect { + name: self.name.clone(), + })?; + for (name, value) in &self.params { + let parameter = descriptor + .parameters + .iter() + .find(|candidate| candidate.name == name) + .ok_or_else(|| EffectValidationError::UnknownParameter { + effect: self.name.clone(), + parameter: name.clone(), + })?; + if !value.is_finite() { + return Err(EffectValidationError::NonFiniteParameter { + effect: self.name.clone(), + parameter: name.clone(), + }); + } + if *value < parameter.min || *value > parameter.max { + return Err(EffectValidationError::ParameterOutOfRange { + effect: self.name.clone(), + parameter: name.clone(), + value: *value, + min: parameter.min, + max: parameter.max, + }); + } + } + Ok(()) + } + + /// Read a registered scalar using its schema default. + pub fn registered_param(&self, key: &str) -> Result { + self.validate()?; + let descriptor = effect_registry() + .iter() + .find(|candidate| candidate.name == self.name) + .expect("validated effect is registered"); + let parameter = descriptor + .parameters + .iter() + .find(|candidate| candidate.name == key) + .ok_or_else(|| EffectValidationError::UnknownParameter { + effect: self.name.clone(), + parameter: key.to_owned(), + })?; + Ok(self.param(key, parameter.default)) + } } #[cfg(test)] @@ -748,6 +1243,21 @@ mod tests { let (r, gg, _) = g.apply_linear(0.4, 0.4, 0.0); approx(r, 0.2); // 0.4 * 0.5 approx(gg, 0.4); // unchanged + + // The authored color-wheel contract is: + // gain * (x + lift * (1 - x)) ^ (1 / gamma). Lift therefore rolls off + // toward the highlights instead of adding the same offset everywhere, + // and gain remains an independent highlight multiplier outside gamma. + let combined = ColorGrade { + lift_gamma_gain: LiftGammaGain { + lift: Rgb::new(0.1, 0.0, 0.0), + gamma: Rgb::new(2.0, 1.0, 1.0), + gain: Rgb::new(0.8, 1.0, 1.0), + }, + ..Default::default() + }; + let (combined_r, _, _) = combined.apply_linear(0.25, 0.0, 0.0); + approx(combined_r, 0.8 * 0.325_f64.sqrt()); } #[test] @@ -761,6 +1271,32 @@ mod tests { }; let (r, _, _) = g.apply_linear(0.0, 0.0, 0.0); approx(r, 0.1); + let (white, _, _) = g.apply_linear(1.0, 0.0, 0.0); + approx(white, 1.0); + } + + #[test] + fn color_grade_rejects_non_finite_and_zero_gamma() { + let zero_gamma = ColorGrade { + lift_gamma_gain: LiftGammaGain { + gamma: Rgb::new(0.0, 1.0, 1.0), + ..Default::default() + }, + ..Default::default() + }; + assert_eq!( + zero_gamma.validate().unwrap_err().to_string(), + "liftGammaGain.gamma.r must be finite and within (0, 4]" + ); + + let non_finite = ColorGrade { + exposure: f64::NAN, + ..Default::default() + }; + assert_eq!( + non_finite.validate().unwrap_err().to_string(), + "exposure must be finite and within [-5, 5]" + ); } // --- ColorGrade serde --- @@ -778,9 +1314,18 @@ mod tests { }, contrast: 0.3, saturation: 1.2, + hsl_secondary: Some(HslSecondary { + hue_center: 0.98, + hue_width: 0.2, + feather: 0.05, + hue_shift: 0.1, + saturation: -0.2, + lightness: 0.05, + }), }; let json = serde_json::to_string(&g).unwrap(); assert!(json.contains("\"liftGammaGain\"")); + assert!(json.contains("\"hslSecondary\"")); assert!(json.contains("\"exposure\":0.5")); let back: ColorGrade = serde_json::from_str(&json).unwrap(); assert_eq!(g, back); @@ -792,6 +1337,42 @@ mod tests { assert!(g.is_identity()); } + #[test] + fn hsl_secondary_wraps_red_and_isolates_other_hues() { + let grade = ColorGrade { + hsl_secondary: Some(HslSecondary { + hue_center: 0.98, + hue_width: 0.16, + feather: 0.04, + hue_shift: 0.2, + ..Default::default() + }), + ..Default::default() + }; + grade.validate().unwrap(); + let red = grade.apply_linear(1.0, 0.0, 0.0); + assert!( + red.1 > 0.2 || red.2 > 0.2, + "wrapped red must rotate: {red:?}" + ); + let green = grade.apply_linear(0.0, 1.0, 0.0); + approx(green.0, 0.0); + approx(green.1, 1.0); + approx(green.2, 0.0); + + let invalid = ColorGrade { + hsl_secondary: Some(HslSecondary { + hue_width: 0.0, + ..Default::default() + }), + ..Default::default() + }; + assert_eq!( + invalid.validate().unwrap_err().to_string(), + "hslSecondary.hueWidth must be finite and within (0, 1]" + ); + } + #[test] fn color_grade_partial_decode_keeps_other_defaults() { let g: ColorGrade = serde_json::from_str(r#"{"exposure":1.0}"#).unwrap(); @@ -901,6 +1482,7 @@ mod tests { }, feather: 0.0, invert: false, + ..Mask::default() }; approx(m.coverage(0.5, 0.5), 1.0); // center approx(m.coverage(0.5, 0.9), 0.0); // outside radius @@ -915,6 +1497,7 @@ mod tests { }, feather: 0.0, invert: true, + ..Mask::default() }; approx(m.coverage(0.5, 0.5), 0.0); // center now masked out approx(m.coverage(0.5, 0.9), 1.0); // outside now covered @@ -929,6 +1512,7 @@ mod tests { }, feather: 0.1, invert: false, + ..Mask::default() }; // Exactly on the boundary -> ~0.5 coverage. let c = m.coverage(0.7, 0.5); @@ -947,6 +1531,7 @@ mod tests { }, feather: 0.0, invert: false, + ..Mask::default() }; approx(m.coverage(0.8, 0.5), 1.0); // +normal side covered approx(m.coverage(0.2, 0.5), 0.0); // -normal side not @@ -964,6 +1549,7 @@ mod tests { }, feather: 0.0, invert: false, + ..Mask::default() }; approx(m.coverage(0.5, 0.3), 1.0); // inside the triangle approx(m.coverage(0.05, 0.05), 0.0); // outside (a corner region) @@ -977,6 +1563,7 @@ mod tests { }, feather: 0.0, invert: false, + ..Mask::default() }; approx(m.coverage(0.5, 0.5), 0.0); } @@ -990,6 +1577,7 @@ mod tests { }, feather: 0.05, invert: true, + ..Mask::default() }; let json = serde_json::to_string(&m).unwrap(); assert!(json.contains("\"kind\":\"circle\"")); @@ -1007,6 +1595,7 @@ mod tests { }, feather: 0.0, invert: false, + ..Mask::default() }; let json = serde_json::to_string(&m).unwrap(); assert!(json.contains("\"kind\":\"linear\"")); @@ -1026,6 +1615,7 @@ mod tests { }, feather: 0.0, invert: false, + ..Mask::default() }; let json = serde_json::to_string(&m).unwrap(); assert!(json.contains("\"kind\":\"poly\"")); @@ -1033,28 +1623,68 @@ mod tests { assert_eq!(m, back); } + #[test] + fn mask_transform_is_non_destructive_and_roundtrips() { + let base = Mask { + shape: MaskShape::Circle { + center: Point2::new(0.5, 0.5), + radius: Point2::new(0.1, 0.2), + }, + ..Mask::default() + }; + let transformed = Mask { + transform: MaskTransform { + offset: Point2::new(0.2, -0.1), + scale: Point2::new(2.0, 0.5), + rotation_degrees: 90.0, + }, + ..base.clone() + }; + + // The authored shape stays unchanged while its whole-mask transform + // moves the local center (0.5, 0.5) to display point (0.7, 0.4). + assert_eq!(transformed.shape, base.shape); + approx(transformed.coverage(0.7, 0.4), 1.0); + approx(transformed.coverage(0.5, 0.5), 0.0); + + let json = serde_json::to_string(&transformed).unwrap(); + assert!(json.contains("\"transform\"")); + assert!(json.contains("\"rotationDegrees\":90.0")); + assert_eq!(serde_json::from_str::(&json).unwrap(), transformed); + + // Legacy/default projects do not gain noisy transform payloads and + // deserialize to the identity transform. + let default_json = serde_json::to_string(&base).unwrap(); + assert!(!default_json.contains("\"transform\"")); + let legacy: Mask = serde_json::from_str( + r#"{"shape":{"kind":"circle","center":{"x":0.5,"y":0.5},"radius":{"x":0.1,"y":0.2}},"feather":0.0,"invert":false}"#, + ) + .unwrap(); + assert!(legacy.transform.is_identity()); + } + // --- Effect --- #[test] fn effect_new_is_enabled_no_params() { - let e = Effect::new("gaussianBlur"); - assert_eq!(e.name, "gaussianBlur"); + let e = Effect::new("grayscale"); + assert_eq!(e.name, "grayscale"); assert!(e.enabled); assert!(e.params.is_empty()); - approx(e.param("radius", 3.0), 3.0); // default fallback + approx(e.registered_param("amount").unwrap(), 1.0); } #[test] fn effect_with_param_and_read() { - let e = Effect::new("glow").with_param("intensity", 0.8); - approx(e.param("intensity", 0.0), 0.8); + let e = Effect::new("sepia").with_param("amount", 0.8); + approx(e.registered_param("amount").unwrap(), 0.8); } #[test] fn effect_roundtrip_with_params() { - let e = Effect::new("sharpen").with_param("amount", 0.5); + let e = Effect::new("invert").with_param("amount", 0.5); let json = serde_json::to_string(&e).unwrap(); - assert!(json.contains("\"name\":\"sharpen\"")); + assert!(json.contains("\"name\":\"invert\"")); assert!(json.contains("\"amount\":0.5")); let back: Effect = serde_json::from_str(&json).unwrap(); assert_eq!(e, back); diff --git a/crates/opentake-domain/src/lib.rs b/crates/opentake-domain/src/lib.rs index c7efa6ac..c687ea3a 100644 --- a/crates/opentake-domain/src/lib.rs +++ b/crates/opentake-domain/src/lib.rs @@ -20,43 +20,59 @@ //! Zero IO, pure logic, fully unit-testable. The only runtime dependency is //! `serde`; persistence-side UUID repair belongs to `opentake-project`. +pub mod audio; pub mod caption_sync; pub mod clip; pub mod clip_type; mod clip_wire; pub mod grade; pub mod keyframe; +pub mod lut; pub mod media; pub mod signal; pub mod split; +pub mod stabilization; pub mod subtitle_export; pub mod text; mod text_wire; pub mod timeline; pub mod transform; +pub mod transition; // Flat re-export of the public domain API for ergonomic downstream use. +pub use audio::{AudioDenoise, DenoiseMode, LoudnessNormalization}; pub use caption_sync::{caption_group_ids, clips_in_group, sync_caption_group_style}; -pub use clip::{Clip, FadeEdge, KeyframeTrackWireField, KeyframeValueWireShape, VolumeScale}; +pub use clip::{ + CaptionTranslationInput, Clip, FadeEdge, KeyframeTrackWireField, KeyframeValueWireShape, + VolumeScale, +}; pub use clip_type::ClipType; pub use grade::{ - chroma_cb_cr, luma709, smoothstep01, ChromaKey, ColorGrade, Effect, LiftGammaGain, Mask, - MaskShape, Point2, Rgb, + chroma_cb_cr, effect_registry, luma709, smoothstep01, validate_effect_chain, ChromaKey, + ColorGrade, ColorGradeValidationError, ColorMatchInput, Effect, EffectDescriptor, + EffectParameterDescriptor, EffectValidationError, HslSecondary, LiftGammaGain, Mask, MaskShape, + MaskTransform, Point2, Rgb, MAX_EFFECTS_PER_CLIP, MAX_MASKS_PER_CLIP, MAX_POLYGON_MASK_POINTS, }; pub use keyframe::{ smoothstep, split_keyframe_track, AnimPair, AnimatableProperty, Interpolation, Keyframe, KeyframeInterpolatable, KeyframeTrack, }; +pub use lut::{CubeLut, CubeLutError, LutReference, LutReferenceValidationError}; pub use media::{ - GenerationInput, GenerationStatus, MediaAsset, MediaFolder, MediaManifest, MediaManifestEntry, - MediaResolver, MediaSource, + GenerationInput, GenerationJobStatus, GenerationStatus, MediaAsset, MediaColorMetadata, + MediaFolder, MediaManifest, MediaManifestEntry, MediaProxy, MediaResolver, MediaSource, }; pub use signal::{ ContextSignal, EditingSkeleton, EditingStage, StageGuidance, TrackHint, TrackRole, TrackRoleAssignment, VideoType, }; pub use split::split_clip; +pub use stabilization::{StabilizationKeyframe, StabilizationTrack, StabilizationTransform}; pub use subtitle_export::{collect_caption_cues, export_srt, export_vtt, SubtitleCue}; pub use text::{Fill, Rgba, Shadow, TextAlignment, TextLayout, TextStyle}; -pub use timeline::{ClipLocation, Timeline, Track}; +pub use timeline::{ + ClipLocation, NestedSequence, ScriptAssemblyPlan, ScriptAssemblySegment, Timeline, Track, + VoiceModelRecord, +}; pub use transform::{Crop, CropAspectLock, Point, Transform}; +pub use transition::{Transition, TransitionKind}; diff --git a/crates/opentake-domain/src/lut.rs b/crates/opentake-domain/src/lut.rs new file mode 100644 index 00000000..b1e29f13 --- /dev/null +++ b/crates/opentake-domain/src/lut.rs @@ -0,0 +1,350 @@ +//! Pure domain model and bounded parser for project-managed 3D `.cube` LUTs. +//! +//! File I/O belongs to the desktop/project layers. This module accepts an +//! already-bounded byte slice, validates the complete table, and exposes only +//! finite data suitable for GPU upload. + +use serde::{Deserialize, Serialize}; + +/// Authored reference persisted on a clip. The content hash is also the only +/// allowed storage key; no ambient source path is retained in project JSON. +#[derive(Clone, PartialEq, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LutReference { + pub id: String, + pub name: String, + pub intensity: f64, +} + +impl LutReference { + pub fn new( + id: impl Into, + name: impl Into, + intensity: f64, + ) -> Result { + let reference = Self { + id: id.into(), + name: name.into(), + intensity, + }; + reference.validate()?; + Ok(reference) + } + + pub fn validate(&self) -> Result<(), LutReferenceValidationError> { + if self.id.len() != 64 + || !self + .id + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + return Err(LutReferenceValidationError::InvalidId); + } + if self.name.is_empty() || self.name.len() > 128 || self.name.chars().any(char::is_control) + { + return Err(LutReferenceValidationError::InvalidName); + } + if !self.intensity.is_finite() || !(0.0..=1.0).contains(&self.intensity) { + return Err(LutReferenceValidationError::InvalidIntensity); + } + Ok(()) + } + + /// Canonical bundle-relative location. It is derived, never deserialized. + pub fn relative_path(&self) -> String { + format!("media/luts/{}.cube", self.id) + } +} + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum LutReferenceValidationError { + InvalidId, + InvalidName, + InvalidIntensity, +} + +impl std::fmt::Display for LutReferenceValidationError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(match self { + Self::InvalidId => "id must be a lowercase 64-character SHA-256 digest", + Self::InvalidName => "name must contain 1..=128 bytes without control characters", + Self::InvalidIntensity => "intensity must be finite and within [0, 1]", + }) + } +} + +impl std::error::Error for LutReferenceValidationError {} + +/// Fully validated 3D table in `.cube` red-fastest order. +#[derive(Clone, PartialEq, Debug)] +pub struct CubeLut { + title: Option, + size: u32, + domain_min: [f32; 3], + domain_max: [f32; 3], + table: Vec<[f32; 3]>, +} + +impl CubeLut { + /// Hard read/parse ceiling for an untrusted input file. + pub const MAX_BYTES: usize = 4 * 1024 * 1024; + + pub fn parse(bytes: &[u8]) -> Result { + if bytes.len() > Self::MAX_BYTES { + return Err(CubeLutError::TooLarge { + actual: bytes.len(), + maximum: Self::MAX_BYTES, + }); + } + let text = std::str::from_utf8(bytes).map_err(|_| CubeLutError::InvalidUtf8)?; + let mut title = None; + let mut size = None; + let mut domain_min = None; + let mut domain_max = None; + let mut table = Vec::new(); + + for (zero_line, raw) in text.lines().enumerate() { + let line_number = zero_line + 1; + let line = raw.split('#').next().unwrap_or_default().trim(); + if line.is_empty() { + continue; + } + let fields = line.split_whitespace().collect::>(); + match fields[0] { + "TITLE" => { + if title.is_some() { + return Err(CubeLutError::DuplicateDirective { directive: "TITLE" }); + } + let value = line["TITLE".len()..].trim().trim_matches('"'); + if value.is_empty() || value.len() > 128 || value.chars().any(char::is_control) + { + return Err(CubeLutError::InvalidDirective { + line: line_number, + directive: "TITLE", + }); + } + title = Some(value.to_owned()); + } + "LUT_3D_SIZE" => { + if size.is_some() { + return Err(CubeLutError::DuplicateDirective { + directive: "LUT_3D_SIZE", + }); + } + if fields.len() != 2 { + return Err(CubeLutError::InvalidDirective { + line: line_number, + directive: "LUT_3D_SIZE", + }); + } + let parsed = + fields[1] + .parse::() + .map_err(|_| CubeLutError::InvalidDirective { + line: line_number, + directive: "LUT_3D_SIZE", + })?; + if !matches!(parsed, 17 | 33) { + return Err(CubeLutError::UnsupportedSize(parsed)); + } + size = Some(parsed); + table.reserve(parsed as usize * parsed as usize * parsed as usize); + } + "DOMAIN_MIN" => { + if domain_min.is_some() { + return Err(CubeLutError::DuplicateDirective { + directive: "DOMAIN_MIN", + }); + } + domain_min = Some(parse_triplet(&fields, line_number, "DOMAIN_MIN")?); + } + "DOMAIN_MAX" => { + if domain_max.is_some() { + return Err(CubeLutError::DuplicateDirective { + directive: "DOMAIN_MAX", + }); + } + domain_max = Some(parse_triplet(&fields, line_number, "DOMAIN_MAX")?); + } + directive if directive.as_bytes()[0].is_ascii_alphabetic() => { + return Err(CubeLutError::UnsupportedDirective { + line: line_number, + directive: directive.to_owned(), + }); + } + _ => { + if size.is_none() { + return Err(CubeLutError::TableBeforeSize { line: line_number }); + } + let value = parse_triplet(&fields, line_number, "table row")?; + if value.iter().any(|channel| channel.abs() > 16.0) { + return Err(CubeLutError::OutOfRangeValue { line: line_number }); + } + table.push(value); + } + } + } + + let size = size.ok_or(CubeLutError::MissingSize)?; + let expected = size as usize * size as usize * size as usize; + if table.len() != expected { + return Err(CubeLutError::WrongTableLength { + expected, + actual: table.len(), + }); + } + let domain_min = domain_min.unwrap_or([0.0; 3]); + let domain_max = domain_max.unwrap_or([1.0; 3]); + if (0..3).any(|channel| domain_min[channel] >= domain_max[channel]) { + return Err(CubeLutError::InvalidDomain); + } + Ok(Self { + title, + size, + domain_min, + domain_max, + table, + }) + } + + pub fn title(&self) -> Option<&str> { + self.title.as_deref() + } + + pub fn size(&self) -> u32 { + self.size + } + + pub fn domain_min(&self) -> [f32; 3] { + self.domain_min + } + + pub fn domain_max(&self) -> [f32; 3] { + self.domain_max + } + + pub fn table(&self) -> &[[f32; 3]] { + &self.table + } +} + +fn parse_triplet( + fields: &[&str], + line: usize, + directive: &'static str, +) -> Result<[f32; 3], CubeLutError> { + if fields.len() != 4 && directive != "table row" + || fields.len() != 3 && directive == "table row" + { + return Err(CubeLutError::InvalidDirective { line, directive }); + } + let offset = usize::from(directive != "table row"); + let mut value = [0.0; 3]; + for channel in 0..3 { + value[channel] = fields[channel + offset] + .parse::() + .map_err(|_| CubeLutError::InvalidNumber { line })?; + if !value[channel].is_finite() { + return Err(CubeLutError::InvalidNumber { line }); + } + } + Ok(value) +} + +#[derive(Clone, PartialEq, Eq, Debug)] +pub enum CubeLutError { + TooLarge { + actual: usize, + maximum: usize, + }, + InvalidUtf8, + DuplicateDirective { + directive: &'static str, + }, + InvalidDirective { + line: usize, + directive: &'static str, + }, + UnsupportedDirective { + line: usize, + directive: String, + }, + UnsupportedSize(u32), + TableBeforeSize { + line: usize, + }, + MissingSize, + InvalidNumber { + line: usize, + }, + OutOfRangeValue { + line: usize, + }, + InvalidDomain, + WrongTableLength { + expected: usize, + actual: usize, + }, +} + +impl std::fmt::Display for CubeLutError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::TooLarge { actual, maximum } => { + write!(formatter, "LUT is {actual} bytes; maximum is {maximum}") + } + Self::InvalidUtf8 => formatter.write_str("LUT is not valid UTF-8 text"), + Self::DuplicateDirective { directive } => { + write!(formatter, "duplicate {directive} directive") + } + Self::InvalidDirective { line, directive } => { + write!(formatter, "invalid {directive} on line {line}") + } + Self::UnsupportedDirective { line, directive } => write!( + formatter, + "unsupported directive {directive} on line {line}" + ), + Self::UnsupportedSize(size) => { + write!(formatter, "unsupported LUT size {size}; expected 17 or 33") + } + Self::TableBeforeSize { line } => { + write!(formatter, "table row before LUT_3D_SIZE on line {line}") + } + Self::MissingSize => formatter.write_str("missing LUT_3D_SIZE"), + Self::InvalidNumber { line } => { + write!(formatter, "invalid finite number on line {line}") + } + Self::OutOfRangeValue { line } => { + write!(formatter, "table value outside [-16, 16] on line {line}") + } + Self::InvalidDomain => { + formatter.write_str("each DOMAIN_MIN channel must be less than DOMAIN_MAX") + } + Self::WrongTableLength { expected, actual } => write!( + formatter, + "LUT table has {actual} rows; expected {expected}" + ), + } + } +} + +impl std::error::Error for CubeLutError {} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rejects_duplicate_metadata_and_non_finite_domain() { + let duplicate = b"LUT_3D_SIZE 17\nLUT_3D_SIZE 17\n"; + assert!(matches!( + CubeLut::parse(duplicate), + Err(CubeLutError::DuplicateDirective { .. }) + )); + let non_finite = b"LUT_3D_SIZE 17\nDOMAIN_MIN NaN 0 0\n"; + assert!(matches!( + CubeLut::parse(non_finite), + Err(CubeLutError::InvalidNumber { .. }) + )); + } +} diff --git a/crates/opentake-domain/src/media.rs b/crates/opentake-domain/src/media.rs index 3ae9ac47..26efee3b 100644 --- a/crates/opentake-domain/src/media.rs +++ b/crates/opentake-domain/src/media.rs @@ -23,6 +23,21 @@ use serde::{Deserialize, Serialize}; use crate::clip_type::ClipType; +/// Durable provider-neutral lifecycle for an asynchronous generated output. +/// Stored with `GenerationInput` so the manifest is the recovery source of +/// truth and never needs provider credentials or signed result URLs. +#[derive(Clone, Copy, PartialEq, Eq, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum GenerationJobStatus { + Queued, + Generating, + Downloading, + Finalizing, + Ready, + Failed, + Cancelled, +} + /// Where a media file lives. Encoded externally-tagged to match Swift's /// synthesized `Codable` for an enum with associated values: /// `{"external":{"absolutePath":"..."}}` / `{"project":{"relativePath":"..."}}`. @@ -35,6 +50,54 @@ pub enum MediaSource { Project { relative_path: String }, } +/// Source color signalling retained from the first playable video stream. +/// Values use FFmpeg's stable tokens (`bt709`, `bt2020`, `smpte2084`, +/// `arib-std-b67`, ...). Keeping the original tokens makes older/newer codecs +/// forward-compatible while helpers can still identify the HDR transfers that +/// require explicit tone mapping in the current SDR compositor. +#[derive(Clone, PartialEq, Eq, Debug, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MediaColorMetadata { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub primaries: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub transfer: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub matrix: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub range: Option, +} + +impl MediaColorMetadata { + pub fn is_hdr(&self) -> bool { + self.transfer.as_deref().is_some_and(|transfer| { + matches!( + transfer.to_ascii_lowercase().as_str(), + "smpte2084" | "pq" | "arib-std-b67" | "hlg" + ) + }) + } + + pub fn is_empty(&self) -> bool { + self.primaries.is_none() + && self.transfer.is_none() + && self.matrix.is_none() + && self.range.is_none() + } +} + +/// Project-local low-resolution media used only for interactive playback. +/// Export always resolves [`MediaManifestEntry::source`]. The source digest +/// prevents a stale proxy being paired with bytes that changed in place. +#[derive(Clone, PartialEq, Eq, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MediaProxy { + pub relative_path: String, + pub source_sha256: String, + pub width: u32, + pub height: u32, +} + /// Full serializable input snapshot for a generated asset. 1:1 port of /// `GenerationInput`. `prompt` / `model` / `duration` / `aspect_ratio` are /// required upstream; everything else is optional. @@ -100,6 +163,48 @@ pub struct GenerationInput { /// Apple-reference-date seconds (see module note on dates). #[serde(default, skip_serializing_if = "Option::is_none")] pub created_at: Option, + /// Local durable job identity. Provider job ids remain private to the host. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub job_id: Option, + /// Non-secret provider routing prefix (`fal`, `replicate`, ...). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider: Option, + /// Provider job identity required for restart recovery. This is not a + /// credential and must never contain a result URL. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider_job_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub status: Option, + /// Normalized 0..1 progress. Providers without progress report phase-only. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub progress: Option, + /// Fixed application-owned failure code; provider messages are never stored. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error_code: Option, + /// Stable ordered output index for N-result generation. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub output_index: Option, + /// Source asset provenance for upscale/edit flows. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source_asset_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source_clip_id: Option, + /// Timeline span provenance for video-to-audio generation. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source_start_frame: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source_end_frame: Option, + /// Client-side estimate shown before submission; actual settled cost is + /// recorded once in the generation log when a provider supplies it. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub estimated_cost_credits: Option, + /// Explicit user-consent record supplied for identity-bearing generation. + /// This is an opaque local audit id, never a credential. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub consent_id: Option, + /// SHA-256 of the canonical, non-secret provider request inputs. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub request_hash: Option, } /// Serializable manifest entry. 1:1 port of `MediaManifestEntry`. @@ -123,6 +228,10 @@ pub struct MediaManifestEntry { #[serde(default, skip_serializing_if = "Option::is_none")] pub has_audio: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + pub color: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub proxy: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub folder_id: Option, #[serde( rename = "cachedRemoteURL", @@ -138,6 +247,17 @@ pub struct MediaManifestEntry { pub cached_remote_url_expires_at: Option, } +impl MediaManifestEntry { + /// Generated local matting derivatives contain straight RGBA from FFmpeg's + /// ProRes 4444 decoder. The render adapters use this non-secret provenance + /// to request one premultiplication before blending. + pub fn carries_straight_alpha(&self) -> bool { + self.generation_input.as_ref().is_some_and(|input| { + input.provider.as_deref() == Some("opentake-matting") && input.model.starts_with("rvm-") + }) + } +} + /// A media library folder. 1:1 port of `MediaFolder`. #[derive(Clone, PartialEq, Eq, Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -292,6 +412,9 @@ impl MediaManifest { } } +/// Decode a persisted manifest without confusing the current constructor +/// version with the legacy wire fallback: an omitted version means schema 1, +/// while every explicitly stored version is retained verbatim. impl<'de> Deserialize<'de> for MediaManifest { fn deserialize(deserializer: D) -> Result where @@ -396,6 +519,10 @@ pub struct MediaAsset { #[serde(default)] pub has_audio: bool, #[serde(default, skip_serializing_if = "Option::is_none")] + pub color: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub proxy: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub generation_input: Option, #[serde(default)] pub generation_status: GenerationStatus, @@ -437,6 +564,8 @@ impl MediaAsset { source_height: None, source_fps: None, has_audio: kind == ClipType::Video, + color: None, + proxy: None, generation_input: None, generation_status: GenerationStatus::None, folder_id: None, @@ -459,8 +588,31 @@ impl MediaAsset { source_height: entry.source_height, source_fps: entry.source_fps, has_audio: entry.has_audio.unwrap_or(false), + color: entry.color.clone(), + proxy: entry.proxy.clone(), generation_input: entry.generation_input.clone(), - generation_status: GenerationStatus::None, + generation_status: match entry + .generation_input + .as_ref() + .and_then(|input| input.status) + { + Some(GenerationJobStatus::Queued | GenerationJobStatus::Generating) => { + GenerationStatus::Generating + } + Some(GenerationJobStatus::Downloading) => GenerationStatus::Downloading, + Some(GenerationJobStatus::Finalizing) => GenerationStatus::Rendering, + Some(GenerationJobStatus::Failed) => GenerationStatus::Failed( + entry + .generation_input + .as_ref() + .and_then(|input| input.error_code.clone()) + .unwrap_or_else(|| "GENERATION_FAILED".to_string()), + ), + Some(GenerationJobStatus::Cancelled) => { + GenerationStatus::Failed("GENERATION_CANCELLED".to_string()) + } + Some(GenerationJobStatus::Ready) | None => GenerationStatus::None, + }, folder_id: entry.folder_id.clone(), pending_download_url: None, cached_remote_url: entry.cached_remote_url.clone(), @@ -501,10 +653,13 @@ impl MediaAsset { pub fn to_manifest_entry(&self, project_base: Option<&Path>, now: f64) -> MediaManifestEntry { let source = match project_base { Some(base) if self.url.starts_with(base) => { + // Bundle paths must be portable between host platforms; the + // stripped path uses the host separator ('\' on Windows), + // which `path_policy` rejects — emit forward slashes everywhere. let relative = self .url .strip_prefix(base) - .map(|p| p.to_string_lossy().into_owned()) + .map(|p| p.to_string_lossy().replace('\\', "/")) .unwrap_or_default(); MediaSource::Project { relative_path: relative, @@ -531,6 +686,8 @@ impl MediaAsset { source_height: self.source_height, source_fps: self.source_fps, has_audio: Some(self.has_audio), + color: self.color.clone(), + proxy: self.proxy.clone(), folder_id: self.folder_id.clone(), cached_remote_url: fresh, cached_remote_url_expires_at: expires, @@ -648,6 +805,8 @@ mod tests { source_height: Some(1080), source_fps: Some(30.0), has_audio: Some(true), + color: None, + proxy: None, folder_id: None, cached_remote_url: Some("https://x".into()), cached_remote_url_expires_at: Some(700_000_000.0), @@ -783,6 +942,8 @@ mod tests { source_height: None, source_fps: None, has_audio: None, + color: None, + proxy: None, folder_id: None, cached_remote_url: None, cached_remote_url_expires_at: None, @@ -800,6 +961,8 @@ mod tests { source_height: None, source_fps: None, has_audio: None, + color: None, + proxy: None, folder_id: None, cached_remote_url: None, cached_remote_url_expires_at: None, @@ -909,6 +1072,19 @@ mod tests { ); } + #[cfg(target_os = "windows")] + #[test] + fn to_manifest_entry_project_relative_uses_forward_slashes_on_windows() { + let a = MediaAsset::new("a", "C:\\proj\\media\\x.mp4", ClipType::Video, "X", 2.0); + let e = a.to_manifest_entry(Some(Path::new("C:\\proj")), 0.0); + assert_eq!( + e.source, + MediaSource::Project { + relative_path: "media/x.mp4".into() + } + ); + } + #[test] fn to_manifest_entry_drops_expired_cached_url() { let mut a = MediaAsset::new("a", "/elsewhere/x.mp4", ClipType::Video, "X", 2.0); @@ -939,6 +1115,8 @@ mod tests { source_height: Some(720), source_fps: Some(24.0), has_audio: Some(true), + color: None, + proxy: None, folder_id: Some("f1".into()), cached_remote_url: None, cached_remote_url_expires_at: None, diff --git a/crates/opentake-domain/src/split.rs b/crates/opentake-domain/src/split.rs index 68577607..5b1206ba 100644 --- a/crates/opentake-domain/src/split.rs +++ b/crates/opentake-domain/src/split.rs @@ -27,34 +27,48 @@ use crate::keyframe::{split_keyframe_track, AnimPair}; /// continuous across the seam. pub fn split_clip(clip: &Clip, at_frame: i32, right_id: impl Into) -> Option<(Clip, Clip)> { // Half-open guard: endpoints do not split (matches upstream `splitSingleClip`). - if at_frame <= clip.start_frame || at_frame >= clip.end_frame() { + let end_frame = clip.start_frame.checked_add(clip.duration_frames)?; + if at_frame <= clip.start_frame || at_frame >= end_frame { return None; } - let split_offset = at_frame - clip.start_frame; - let left_source = (split_offset as f64 * clip.speed).round() as i32; - let right_source = ((clip.duration_frames - split_offset) as f64 * clip.speed).round() as i32; + if !clip.speed.is_finite() || clip.speed <= 0.0 { + return None; + } + let split_offset = at_frame.checked_sub(clip.start_frame)?; + let right_duration = clip.duration_frames.checked_sub(split_offset)?; + let left_source = (split_offset as f64 * clip.speed).round(); + let right_source = (right_duration as f64 * clip.speed).round(); + if !(0.0..=i32::MAX as f64).contains(&left_source) + || !(0.0..=i32::MAX as f64).contains(&right_source) + { + return None; + } + let left_source = left_source as i32; + let right_source = right_source as i32; let mut left = clip.clone(); left.duration_frames = split_offset; if clip.reversed { - left.trim_start_frame = clip.trim_start_frame + right_source; + left.trim_start_frame = clip.trim_start_frame.checked_add(right_source)?; } else { - left.trim_end_frame = clip.trim_end_frame + right_source; + left.trim_end_frame = clip.trim_end_frame.checked_add(right_source)?; } left.fade_out_frames = 0; + left.loudness_normalization = None; left.clamp_fades_to_duration(); let mut right = clip.clone(); right.id = right_id.into(); right.start_frame = at_frame; - right.duration_frames = clip.duration_frames - split_offset; + right.duration_frames = right_duration; if clip.reversed { - right.trim_end_frame = clip.trim_end_frame + left_source; + right.trim_end_frame = clip.trim_end_frame.checked_add(left_source)?; } else { - right.trim_start_frame = clip.trim_start_frame + left_source; + right.trim_start_frame = clip.trim_start_frame.checked_add(left_source)?; } right.fade_in_frames = 0; + right.loudness_normalization = None; right.clamp_fades_to_duration(); // Split every animatable track at the cut, inserting a boundary keyframe so diff --git a/crates/opentake-domain/src/stabilization.rs b/crates/opentake-domain/src/stabilization.rs new file mode 100644 index 00000000..bf77bc13 --- /dev/null +++ b/crates/opentake-domain/src/stabilization.rs @@ -0,0 +1,188 @@ +//! Persisted, editable video-stabilization solution. +//! +//! The track is deliberately separate from the user's authored position/scale/ +//! rotation keyframes. Renderers compose both tracks, so applying or resetting +//! stabilization never destroys manual animation or source media identity. + +use serde::{Deserialize, Serialize}; + +fn default_strength() -> f64 { + 1.0 +} + +fn default_model_version() -> u32 { + 1 +} + +#[derive(Clone, Copy, PartialEq, Debug, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct StabilizationTransform { + pub translation_x: f64, + pub translation_y: f64, + pub rotation_degrees: f64, +} + +#[derive(Clone, Copy, PartialEq, Debug, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct StabilizationKeyframe { + pub frame: i32, + pub translation_x: f64, + pub translation_y: f64, + pub rotation_degrees: f64, +} + +#[derive(Clone, PartialEq, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct StabilizationTrack { + pub model: String, + #[serde(default = "default_model_version")] + pub model_version: u32, + pub source_identity: String, + #[serde(default = "default_strength")] + pub strength: f64, + #[serde(default)] + pub crop_margin: f64, + #[serde(default)] + pub keyframes: Vec, +} + +impl StabilizationTrack { + /// Linearly sample the correction track at one clip-relative frame. + pub fn sample(&self, frame: i32) -> StabilizationTransform { + let Some(first) = self.keyframes.first() else { + return StabilizationTransform::default(); + }; + let strength = self.strength.clamp(0.0, 1.0); + let raw = if frame <= first.frame { + keyframe_transform(*first) + } else if let Some(last) = self.keyframes.last().filter(|last| frame >= last.frame) { + keyframe_transform(*last) + } else { + let pair = self + .keyframes + .windows(2) + .find(|pair| frame >= pair[0].frame && frame <= pair[1].frame) + .expect("a sorted stabilization track covers an interior sample"); + let span = (pair[1].frame - pair[0].frame).max(1) as f64; + let t = (frame - pair[0].frame) as f64 / span; + StabilizationTransform { + translation_x: lerp(pair[0].translation_x, pair[1].translation_x, t), + translation_y: lerp(pair[0].translation_y, pair[1].translation_y, t), + rotation_degrees: lerp(pair[0].rotation_degrees, pair[1].rotation_degrees, t), + } + }; + StabilizationTransform { + translation_x: raw.translation_x * strength, + translation_y: raw.translation_y * strength, + rotation_degrees: raw.rotation_degrees * strength, + } + } + + /// Conservative uniform zoom needed to keep every output corner covered. + /// `aspect_ratio` is output width / height. + pub fn crop_scale(&self, aspect_ratio: f64) -> f64 { + let aspect = aspect_ratio.max(1e-6); + let required = self + .keyframes + .iter() + .map(|keyframe| { + let correction = self.sample(keyframe.frame); + coverage_scale(correction, aspect) + }) + .fold(1.0_f64, f64::max); + required + self.crop_margin.max(0.0) * 2.0 + } + + pub fn guarantees_coverage(&self, aspect_ratio: f64) -> bool { + let scale = self.crop_scale(aspect_ratio); + self.keyframes.iter().all(|keyframe| { + scale + 1e-12 >= coverage_scale(self.sample(keyframe.frame), aspect_ratio.max(1e-6)) + }) + } + + pub fn validate(&self) -> Result<(), String> { + if self.model.trim().is_empty() || self.model_version == 0 { + return Err("stabilization model and version are required".to_string()); + } + if self.source_identity.trim().is_empty() { + return Err("stabilization source identity is required".to_string()); + } + if !(0.0..=1.0).contains(&self.strength) || !self.strength.is_finite() { + return Err("stabilization strength must be finite and within 0..=1".to_string()); + } + if !(0.0..=0.5).contains(&self.crop_margin) || !self.crop_margin.is_finite() { + return Err("stabilization crop margin must be finite and within 0..=0.5".to_string()); + } + if self.keyframes.len() < 2 { + return Err("stabilization requires at least two keyframes".to_string()); + } + let mut previous = None; + for keyframe in &self.keyframes { + if previous.is_some_and(|frame| keyframe.frame <= frame) { + return Err("stabilization keyframes must be strictly increasing".to_string()); + } + if !keyframe.translation_x.is_finite() + || !keyframe.translation_y.is_finite() + || !keyframe.rotation_degrees.is_finite() + { + return Err("stabilization keyframes must be finite".to_string()); + } + previous = Some(keyframe.frame); + } + Ok(()) + } +} + +fn keyframe_transform(keyframe: StabilizationKeyframe) -> StabilizationTransform { + StabilizationTransform { + translation_x: keyframe.translation_x, + translation_y: keyframe.translation_y, + rotation_degrees: keyframe.rotation_degrees, + } +} + +fn lerp(a: f64, b: f64, t: f64) -> f64 { + a + (b - a) * t.clamp(0.0, 1.0) +} + +fn coverage_scale(correction: StabilizationTransform, aspect: f64) -> f64 { + let radians = correction.rotation_degrees.to_radians(); + let (sin, cos) = radians.sin_cos(); + let sin = sin.abs(); + let cos = cos.abs(); + let translation_x = correction.translation_x.abs(); + let translation_y = correction.translation_y.abs(); + let cover_width = cos + sin / aspect + 2.0 * (translation_x + translation_y / aspect); + let cover_height = cos + sin * aspect + 2.0 * (translation_y + translation_x * aspect); + cover_width.max(cover_height).max(1.0) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sampling_scales_correction_by_editable_strength() { + let track = StabilizationTrack { + model: "test".into(), + model_version: 1, + source_identity: "asset".into(), + strength: 0.5, + crop_margin: 0.0, + keyframes: vec![ + StabilizationKeyframe::default(), + StabilizationKeyframe { + frame: 10, + translation_x: 0.2, + translation_y: -0.1, + rotation_degrees: 4.0, + }, + ], + }; + let sample = track.sample(5); + assert!((sample.translation_x - 0.05).abs() < 1e-12); + assert!((sample.translation_y + 0.025).abs() < 1e-12); + assert!((sample.rotation_degrees - 1.0).abs() < 1e-12); + assert!(track.guarantees_coverage(16.0 / 9.0)); + } +} diff --git a/crates/opentake-domain/src/timeline.rs b/crates/opentake-domain/src/timeline.rs index 0759c8c0..3a2ac0eb 100644 --- a/crates/opentake-domain/src/timeline.rs +++ b/crates/opentake-domain/src/timeline.rs @@ -6,12 +6,59 @@ //! boundary owns UUID repair because it retains the raw JSON needed to //! distinguish that placeholder from an explicitly encoded empty string. -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use serde::{Deserialize, Serialize}; use crate::clip::Clip; use crate::clip_type::ClipType; +use crate::transition::TransitionKind; + +/// One reviewed script-to-video segment. Exact media identities and frame +/// duration are persisted before assembly so applying never repeats creative +/// selection or frame arithmetic. +#[derive(Clone, PartialEq, Eq, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ScriptAssemblySegment { + pub script: String, + pub media_ref: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub narration_media_ref: Option, + pub duration_frames: i32, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub transition: Option, +} + +/// Persisted, reviewable assembly plan. `plan_hash` is the SHA-256 of the +/// canonical segment payload; planner provenance is deliberately non-secret. +#[derive(Clone, PartialEq, Eq, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ScriptAssemblyPlan { + pub id: String, + pub plan_hash: String, + pub planner: String, + pub planner_version: u32, + pub start_frame: i32, + pub segments: Vec, +} + +/// Durable non-secret record for a provider-hosted cloned voice. Provider +/// credentials and reference audio bytes never enter the project document. +#[derive(Clone, PartialEq, Eq, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct VoiceModelRecord { + pub id: String, + pub provider: String, + pub provider_voice_id: String, + pub model: String, + pub consent_id: String, + pub source_audio_asset_id: String, + pub source_audio_sha256: String, + pub request_hash: String, + pub voice_name: String, + #[serde(default)] + pub revoked: bool, +} /// Clip location inside track storage. #[derive(Clone, Copy, PartialEq, Eq, Debug)] @@ -50,10 +97,45 @@ pub struct Timeline { pub height: i32, #[serde(default)] pub settings_configured: bool, + /// Editable child timelines referenced by clips through + /// `Clip::nested_sequence_id`. The registry lives on the root timeline so + /// every reference has one stable identity and graph cycles are detectable. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub nested_sequences: Vec, + /// Reviewed script assembly plans. Bounded by the command layer and + /// ignored by render/export until explicitly applied. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub script_assembly_plans: Vec, + /// Consent-bearing provider voice identities. Revoked records remain for + /// audit and are rejected by every generation path. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub voice_models: Vec, #[serde(default)] pub tracks: Vec, } +#[derive(Clone, PartialEq, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct NestedSequence { + pub id: String, + pub name: String, + pub timeline: Timeline, +} + +impl NestedSequence { + pub const WIRE_FIELDS: &'static [&'static str] = &["id", "name", "timeline"]; + pub const ID_WIRE_FIELD: &'static str = "id"; + pub const TIMELINE_WIRE_FIELD: &'static str = "timeline"; + + pub fn new(id: impl Into, name: impl Into, timeline: Timeline) -> Self { + Self { + id: id.into(), + name: name.into(), + timeline, + } + } +} + impl Default for Timeline { fn default() -> Self { Timeline { @@ -61,6 +143,9 @@ impl Default for Timeline { width: 1920, height: 1080, settings_configured: false, + nested_sequences: Vec::new(), + script_assembly_plans: Vec::new(), + voice_models: Vec::new(), tracks: Vec::new(), } } @@ -68,6 +153,9 @@ impl Default for Timeline { impl Timeline { pub const TRACKS_WIRE_FIELD: &'static str = "tracks"; + pub const NESTED_SEQUENCES_WIRE_FIELD: &'static str = "nestedSequences"; + pub const SCRIPT_ASSEMBLY_PLANS_WIRE_FIELD: &'static str = "scriptAssemblyPlans"; + pub const VOICE_MODELS_WIRE_FIELD: &'static str = "voiceModels"; pub fn new() -> Self { Timeline::default() @@ -77,6 +165,121 @@ impl Timeline { pub fn total_frames(&self) -> i32 { self.tracks.iter().map(|t| t.end_frame()).max().unwrap_or(0) } + + /// Validate unique sequence identities, every reference, and graph cycles. + /// This is a pure preflight used before edits are committed or plans built. + pub fn validate_nested_sequences(&self) -> Result<(), String> { + let mut registry = HashMap::new(); + for sequence in &self.nested_sequences { + if sequence.id.is_empty() { + return Err("nested sequence id must not be empty".to_string()); + } + if registry.insert(sequence.id.as_str(), sequence).is_some() { + return Err(format!("duplicate nested sequence id: {}", sequence.id)); + } + if !sequence.timeline.nested_sequences.is_empty() { + return Err(format!( + "nested sequence {} contains a nestedSequences registry; child references must use the root registry", + sequence.id + )); + } + } + + // Several cross-cutting consumers (text resolution, selection, and + // edit commands) address clips by id without a sequence namespace. + // Once a project has nested timelines, ids therefore must be unique + // across the entire stored graph rather than only inside one track. + let uses_nested_graph = !self.nested_sequences.is_empty() + || self + .tracks + .iter() + .flat_map(|track| &track.clips) + .any(|clip| clip.nested_sequence_id.is_some()); + if uses_nested_graph { + let mut clip_ids = HashSet::new(); + for timeline in std::iter::once(self).chain( + self.nested_sequences + .iter() + .map(|sequence| &sequence.timeline), + ) { + for track in &timeline.tracks { + for clip in &track.clips { + if clip.id.is_empty() { + return Err( + "clip id must not be empty in a nested timeline graph".to_string() + ); + } + if !clip_ids.insert(clip.id.as_str()) { + return Err(format!( + "duplicate clip id in nested timeline graph: {}", + clip.id + )); + } + if clip.nested_sequence_id.is_some() + && (track.kind == ClipType::Audio + || clip.media_type != ClipType::Video + || !clip.media_ref.is_empty() + || clip.start_frame < 0 + || clip.duration_frames < 1 + || clip.trim_start_frame < 0) + { + return Err(format!( + "invalid compound clip representation: {}", + clip.id + )); + } + } + } + } + } + + fn references(timeline: &Timeline) -> impl Iterator { + timeline.tracks.iter().flat_map(|track| { + track + .clips + .iter() + .filter_map(|clip| clip.nested_sequence_id.as_deref()) + }) + } + + for reference in references(self) { + if !registry.contains_key(reference) { + return Err(format!("missing nested sequence reference: {reference}")); + } + } + + fn visit<'a>( + id: &'a str, + registry: &HashMap<&'a str, &'a NestedSequence>, + visiting: &mut Vec<&'a str>, + complete: &mut HashSet<&'a str>, + ) -> Result<(), String> { + if complete.contains(id) { + return Ok(()); + } + if let Some(index) = visiting.iter().position(|candidate| *candidate == id) { + let mut cycle = visiting[index..].to_vec(); + cycle.push(id); + return Err(format!("nested sequence cycle: {}", cycle.join(" -> "))); + } + let sequence = registry + .get(id) + .ok_or_else(|| format!("missing nested sequence reference: {id}"))?; + visiting.push(id); + for reference in references(&sequence.timeline) { + visit(reference, registry, visiting, complete)?; + } + visiting.pop(); + complete.insert(id); + Ok(()) + } + + let mut complete = HashSet::new(); + for sequence in &self.nested_sequences { + visit(&sequence.id, ®istry, &mut Vec::new(), &mut complete)?; + } + Ok(()) + } } fn default_sync_locked() -> bool { @@ -305,6 +508,55 @@ mod tests { assert!(back.settings_configured); } + #[test] + fn script_assembly_plan_roundtrips_and_legacy_timelines_default_empty() { + let mut timeline = Timeline::new(); + timeline.script_assembly_plans.push(ScriptAssemblyPlan { + id: "plan-1".into(), + plan_hash: "a".repeat(64), + planner: "opentake-script-assembly".into(), + planner_version: 1, + start_frame: 42, + segments: vec![ScriptAssemblySegment { + script: "Opening".into(), + media_ref: "visual".into(), + narration_media_ref: Some("voice".into()), + duration_frames: 30, + transition: Some(TransitionKind::CrossDissolve), + }], + }); + let json = serde_json::to_string(&timeline).unwrap(); + assert!(json.contains("\"scriptAssemblyPlans\"")); + assert_eq!(serde_json::from_str::(&json).unwrap(), timeline); + let legacy: Timeline = serde_json::from_str( + r#"{"fps":30,"width":1920,"height":1080,"settingsConfigured":true,"tracks":[]}"#, + ) + .unwrap(); + assert!(legacy.script_assembly_plans.is_empty()); + assert!(legacy.voice_models.is_empty()); + } + + #[test] + fn voice_model_record_roundtrips_without_secret_material() { + let mut timeline = Timeline::new(); + timeline.voice_models.push(VoiceModelRecord { + id: "voice-local-1".into(), + provider: "elevenlabs".into(), + provider_voice_id: "provider-voice-1".into(), + model: "eleven_multilingual_v2".into(), + consent_id: "consent-1".into(), + source_audio_asset_id: "audio-1".into(), + source_audio_sha256: "a".repeat(64), + request_hash: "b".repeat(64), + voice_name: "Narrator".into(), + revoked: false, + }); + let json = serde_json::to_string(&timeline).unwrap(); + assert!(json.contains("\"voiceModels\"")); + assert!(!json.contains("apiKey")); + assert_eq!(serde_json::from_str::(&json).unwrap(), timeline); + } + #[test] fn timeline_decode_defaults() { let tl: Timeline = serde_json::from_str("{}").unwrap(); @@ -326,6 +578,115 @@ mod tests { assert_eq!(tl, back); } + #[test] + fn nested_sequence_roundtrip_and_legacy_omission_are_stable() { + let legacy: Timeline = serde_json::from_str(r#"{"fps":24,"tracks":[]}"#).unwrap(); + assert!(legacy.nested_sequences.is_empty()); + assert!(!serde_json::to_string(&legacy) + .unwrap() + .contains("nestedSequences")); + + let mut child = Timeline::new(); + child + .tracks + .push(Track::new("child-track", ClipType::Video)); + let mut root = Timeline::new(); + root.nested_sequences + .push(NestedSequence::new("sequence-a", "Scene A", child)); + let encoded = serde_json::to_string(&root).unwrap(); + assert!(encoded.contains("\"nestedSequences\"")); + assert_eq!(serde_json::from_str::(&encoded).unwrap(), root); + } + + #[test] + fn nested_sequence_validation_is_deterministic_and_fail_closed() { + let mut root = Timeline::new(); + root.nested_sequences + .push(NestedSequence::new("", "Empty", Timeline::new())); + assert_eq!( + root.validate_nested_sequences().unwrap_err(), + "nested sequence id must not be empty" + ); + + root.nested_sequences = vec![ + NestedSequence::new("duplicate", "A", Timeline::new()), + NestedSequence::new("duplicate", "B", Timeline::new()), + ]; + assert_eq!( + root.validate_nested_sequences().unwrap_err(), + "duplicate nested sequence id: duplicate" + ); + + let mut missing_track = Track::new("root-track", ClipType::Video); + missing_track + .clips + .push(Clip::new_nested("compound", "missing", 0, 10)); + root.nested_sequences.clear(); + root.tracks = vec![missing_track]; + assert_eq!( + root.validate_nested_sequences().unwrap_err(), + "missing nested sequence reference: missing" + ); + + let mut a = Timeline::new(); + let mut a_track = Track::new("a-track", ClipType::Video); + a_track.clips.push(Clip::new_nested("a-to-b", "b", 0, 10)); + a.tracks.push(a_track); + let mut b = Timeline::new(); + let mut b_track = Track::new("b-track", ClipType::Video); + b_track.clips.push(Clip::new_nested("b-to-a", "a", 0, 10)); + b.tracks.push(b_track); + root.tracks.clear(); + root.nested_sequences = vec![ + NestedSequence::new("a", "A", a), + NestedSequence::new("b", "B", b), + ]; + assert_eq!( + root.validate_nested_sequences().unwrap_err(), + "nested sequence cycle: a -> b -> a" + ); + } + + #[test] + fn nested_sequence_validation_rejects_graph_wide_clip_id_collisions() { + let mut child = Timeline::new(); + let mut child_track = Track::new("child-track", ClipType::Video); + child_track.clips.push(clip("shared-id", 0, 10)); + child.tracks.push(child_track); + + let mut root = Timeline::new(); + let mut root_track = Track::new("root-track", ClipType::Video); + root_track.clips.push(clip("shared-id", 0, 10)); + root_track + .clips + .push(Clip::new_nested("compound", "sequence", 10, 10)); + root.tracks.push(root_track); + root.nested_sequences + .push(NestedSequence::new("sequence", "Scene", child)); + + assert_eq!( + root.validate_nested_sequences().unwrap_err(), + "duplicate clip id in nested timeline graph: shared-id" + ); + } + + #[test] + fn nested_sequence_validation_rejects_compound_on_audio_track() { + let mut root = Timeline::new(); + let mut track = Track::new("audio-track", ClipType::Audio); + track + .clips + .push(Clip::new_nested("compound", "sequence", 0, 10)); + root.tracks.push(track); + root.nested_sequences + .push(NestedSequence::new("sequence", "Scene", Timeline::new())); + + assert_eq!( + root.validate_nested_sequences().unwrap_err(), + "invalid compound clip representation: compound" + ); + } + #[test] fn clip_location_fields() { let loc = ClipLocation::new(2, 5); diff --git a/crates/opentake-domain/src/transition.rs b/crates/opentake-domain/src/transition.rs new file mode 100644 index 00000000..9281121f --- /dev/null +++ b/crates/opentake-domain/src/transition.rs @@ -0,0 +1,24 @@ +use serde::{Deserialize, Serialize}; + +/// Visual transition applied at the cut from one clip to its exact adjacent +/// successor. V1 intentionally starts with the lossless baseline required by +/// the product plan; additional shader-backed kinds can extend this enum later. +#[derive(Clone, Copy, PartialEq, Eq, Debug, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum TransitionKind { + #[default] + CrossDissolve, +} + +#[derive(Clone, PartialEq, Eq, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Transition { + /// Both sides are persisted so a transition cannot silently rebind when a + /// project is reordered. Empty is accepted only for legacy project files; + /// the next validated edit normalizes it to the containing clip id. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub from_clip_id: String, + pub to_clip_id: String, + pub kind: TransitionKind, + pub duration_frames: i32, +} diff --git a/crates/opentake-domain/tests/wire_schema.rs b/crates/opentake-domain/tests/wire_schema.rs index cfbd0d9f..ca230836 100644 --- a/crates/opentake-domain/tests/wire_schema.rs +++ b/crates/opentake-domain/tests/wire_schema.rs @@ -1,8 +1,10 @@ use std::collections::BTreeSet; use opentake_domain::{ - AnimPair, ChromaKey, Clip, ClipType, ColorGrade, Crop, Effect, Fill, Interpolation, Keyframe, - KeyframeTrack, Mask, Rgba, Shadow, TextAlignment, TextStyle, Track, Transform, + AnimPair, AudioDenoise, CaptionTranslationInput, ChromaKey, Clip, ClipType, ColorGrade, Crop, + DenoiseMode, Effect, Fill, Interpolation, Keyframe, KeyframeTrack, LoudnessNormalization, + LutReference, Mask, Rgba, Shadow, StabilizationKeyframe, StabilizationTrack, TextAlignment, + TextStyle, Track, Transform, Transition, TransitionKind, }; use serde::Serialize; @@ -73,8 +75,16 @@ fn full_clip() -> Clip { }; clip.link_group_id = Some("link".to_owned()); clip.caption_group_id = Some("caption".to_owned()); + clip.nested_sequence_id = Some("sequence".to_owned()); clip.text_content = Some("text".to_owned()); clip.text_style = Some(full_text_style()); + clip.caption_translation_input = Some(CaptionTranslationInput { + source_text: "source".into(), + source_locale: "en-US".into(), + target_locale: "zh-CN".into(), + provider: "wire".into(), + model: "wire-v1".into(), + }); clip.opacity_track = Some(KeyframeTrack::from_keyframes(vec![ Keyframe::with_interpolation(0, 0.5, Interpolation::Linear), ])); @@ -97,10 +107,50 @@ fn full_clip() -> Clip { }, )])); clip.volume_track = Some(KeyframeTrack::from_keyframes(vec![Keyframe::new(0, 0.9)])); + clip.loudness_normalization = Some(LoudnessNormalization { + target_lufs: -16.0, + true_peak_ceiling_dbtp: -1.0, + input_integrated_lufs: -24.0, + input_true_peak_dbtp: -12.0, + gain_db: 8.0, + output_integrated_lufs: -16.0, + output_true_peak_dbtp: -2.0, + }); + clip.audio_denoise = Some(AudioDenoise { + mode: DenoiseMode::Voice, + strength: 0.75, + preview_enabled: true, + }); clip.color_grade = Some(ColorGrade::default()); + clip.lut = Some( + LutReference::new("0123456789abcdef".repeat(4), "Wire schema LUT", 0.75) + .expect("wire LUT reference is valid"), + ); clip.chroma_key = Some(ChromaKey::default()); clip.masks = vec![Mask::default()]; clip.effects = vec![Effect::new("wire").with_param("amount", 1.0)]; + clip.stabilization = Some(StabilizationTrack { + model: "wire".to_owned(), + model_version: 1, + source_identity: "asset".to_owned(), + strength: 0.75, + crop_margin: 0.02, + keyframes: vec![ + StabilizationKeyframe::default(), + StabilizationKeyframe { + frame: 1, + translation_x: 0.01, + translation_y: -0.01, + rotation_degrees: 0.5, + }, + ], + }); + clip.transition_out = Some(Transition { + from_clip_id: "clip".to_owned(), + to_clip_id: "next".to_owned(), + kind: TransitionKind::CrossDissolve, + duration_frames: 5, + }); clip.reversed = true; clip } diff --git a/crates/opentake-gen/src/build_params.rs b/crates/opentake-gen/src/build_params.rs index b6f6cef7..fb80cbd2 100644 --- a/crates/opentake-gen/src/build_params.rs +++ b/crates/opentake-gen/src/build_params.rs @@ -168,21 +168,44 @@ pub fn build_params( GenerationParams::Video(build_video_edit_params(input, uploaded)) } else { // Frame count: derive from start/end presence via image_urls slot. - let frame_count = input.image_urls.as_ref().map(|v| v.len()).unwrap_or(0); + let frame_count = input + .image_url_asset_ids + .as_ref() + .map(|values| values.len()) + .or_else(|| input.image_urls.as_ref().map(|values| values.len())) + .unwrap_or(0); let image_ref_count = input - .reference_image_urls + .reference_image_asset_ids .as_ref() - .map(|v| v.len()) + .map(|values| values.len()) + .or_else(|| { + input + .reference_image_urls + .as_ref() + .map(|values| values.len()) + }) .unwrap_or(0); let video_ref_count = input - .reference_video_urls + .reference_video_asset_ids .as_ref() - .map(|v| v.len()) + .map(|values| values.len()) + .or_else(|| { + input + .reference_video_urls + .as_ref() + .map(|values| values.len()) + }) .unwrap_or(0); let audio_ref_count = input - .reference_audio_urls + .reference_audio_asset_ids .as_ref() - .map(|v| v.len()) + .map(|values| values.len()) + .or_else(|| { + input + .reference_audio_urls + .as_ref() + .map(|values| values.len()) + }) .unwrap_or(0); GenerationParams::Video(build_video_params( input, diff --git a/crates/opentake-gen/src/job.rs b/crates/opentake-gen/src/job.rs index b2747b3c..103ad8dd 100644 --- a/crates/opentake-gen/src/job.rs +++ b/crates/opentake-gen/src/job.rs @@ -113,7 +113,9 @@ mod tests { assert_eq!(job.id, "j1"); assert_eq!(job.status, JobStatus::Running); assert_eq!(job.result_urls, None); + assert_eq!(job.error_message, None); assert_eq!(job.cost_credits, None); + assert_eq!(job.completed_at, None); } #[test] diff --git a/crates/opentake-gen/src/lib.rs b/crates/opentake-gen/src/lib.rs index f7b0cdef..a6bdd820 100644 --- a/crates/opentake-gen/src/lib.rs +++ b/crates/opentake-gen/src/lib.rs @@ -21,6 +21,7 @@ pub mod job; pub mod keys; pub mod params; pub mod provider; +pub mod stems; pub mod transport; // Public API surface. @@ -43,6 +44,7 @@ pub use provider::{ content_type_for, ElevenLabsAdapter, FalAdapter, ModelRoute, OpenAiAdapter, ProviderAdapter, ProviderRegistry, ReplicateAdapter, }; +pub use stems::{resolve_stem_execution, StemExecutionPlan, StemProviderSelection}; pub use transport::{ Body, HttpRequest, HttpResponse, HttpTransport, Method, MockTransport, ReqwestTransport, }; diff --git a/crates/opentake-gen/src/stems.rs b/crates/opentake-gen/src/stems.rs new file mode 100644 index 00000000..2dc9b996 --- /dev/null +++ b/crates/opentake-gen/src/stems.rs @@ -0,0 +1,144 @@ +//! Explicit routing policy for stem separation. +//! +//! Local execution never uploads media. Hosted execution is available only +//! after the user selects a concrete provider/model, acknowledges upload, and +//! the normal generation registry proves that provider is configured. + +use crate::{GenError, ModelRoute, ProviderRegistry}; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum StemProviderSelection { + Local, + Hosted { + provider: String, + model: String, + upload_confirmed: bool, + }, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum StemExecutionPlan { + Local, + Hosted { + provider: String, + model: String, + vendor_model: String, + }, +} + +pub fn resolve_stem_execution( + selection: StemProviderSelection, + registry: &ProviderRegistry, +) -> Result { + match selection { + StemProviderSelection::Local => Ok(StemExecutionPlan::Local), + StemProviderSelection::Hosted { + provider, + model, + upload_confirmed, + } => { + if provider.trim().is_empty() || model.trim().is_empty() { + return Err(GenError::Other(anyhow::anyhow!( + "stem provider and model must be selected explicitly" + ))); + } + if !upload_confirmed { + return Err(GenError::Other(anyhow::anyhow!( + "stem upload requires explicit privacy confirmation" + ))); + } + if !registry.has_prefix(&provider) { + return Err(GenError::NotConfigured); + } + let route = ModelRoute::parse(&model)?; + if route.prefix != provider { + return Err(GenError::Other(anyhow::anyhow!( + "stem model prefix does not match selected provider" + ))); + } + Ok(StemExecutionPlan::Hosted { + provider, + model, + vendor_model: route.vendor_model, + }) + } + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use async_trait::async_trait; + + use super::*; + use crate::{GenerationJob, GenerationParams, ProviderAdapter}; + + struct StemCapableProvider; + + #[async_trait] + impl ProviderAdapter for StemCapableProvider { + fn prefix(&self) -> &'static str { + "stemhost" + } + + async fn submit( + &self, + _route: &ModelRoute, + _params: &GenerationParams, + ) -> Result { + unreachable!("routing test does not submit") + } + + async fn poll(&self, _job_id: &str) -> Result { + unreachable!("routing test does not poll") + } + + async fn upload( + &self, + _path: &std::path::Path, + _content_type: &str, + ) -> Result { + unreachable!("routing test does not upload") + } + } + + #[test] + fn local_never_requires_a_provider() { + assert_eq!( + resolve_stem_execution(StemProviderSelection::Local, &ProviderRegistry::new()).unwrap(), + StemExecutionPlan::Local + ); + } + + #[test] + fn hosted_requires_confirmation_configuration_and_matching_prefix() { + let registry = ProviderRegistry::new().with(Arc::new(StemCapableProvider)); + let unconfirmed = resolve_stem_execution( + StemProviderSelection::Hosted { + provider: "stemhost".into(), + model: "stemhost:separate-v1".into(), + upload_confirmed: false, + }, + ®istry, + ); + assert!(unconfirmed.is_err()); + let plan = resolve_stem_execution( + StemProviderSelection::Hosted { + provider: "stemhost".into(), + model: "stemhost:separate-v1".into(), + upload_confirmed: true, + }, + ®istry, + ) + .unwrap(); + assert_eq!( + plan, + StemExecutionPlan::Hosted { + provider: "stemhost".into(), + model: "stemhost:separate-v1".into(), + vendor_model: "separate-v1".into(), + } + ); + } +} diff --git a/crates/opentake-media/Cargo.toml b/crates/opentake-media/Cargo.toml index a7ba54bf..879ed494 100644 --- a/crates/opentake-media/Cargo.toml +++ b/crates/opentake-media/Cargo.toml @@ -8,6 +8,7 @@ description = "Decode/probe/thumbnail/waveform/transcribe/semantic-search (ffmpe [dependencies] opentake-domain = { workspace = true } +opentake-process-tree = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } thiserror = "2" @@ -25,12 +26,15 @@ ndarray = "0.16" tracing = "0.1" unicode-normalization = "0.1" tempfile = "3" +rustfft = "6" +tokio = { version = "1", features = ["io-util", "macros", "process", "rt", "sync", "time"] } # Media IO. We drive the system ffmpeg/ffprobe over the CLI rather than linking # libav*: the local toolchain is ffmpeg 8.1 (libavcodec 62), which the C-binding # crates (ffmpeg-next / ffmpeg-the-third) do not support, and pkg-config is not -# installed. ffmpeg-sidecar shells out to the binaries on PATH — zero native -# linkage, and it never auto-downloads here (we only use its command/parse API). +# installed. ffmpeg-sidecar shells out to OpenTake's checksum-pinned packaged +# binaries (or PATH during development) — zero native linkage, and it never +# auto-downloads here (we only use its command/parse API). # `default-features = false` drops the `download_ffmpeg` feature, whose ureq + # rustls + zip/tar/xz2 stack is the crate's only HTTP/TLS dependency and is dead # weight for us; the FfmpegCommand command/parse API lives in the always-on core. @@ -58,8 +62,12 @@ libc = "0.2" windows-sys = { version = "0.61", features = [ "Wdk_Storage_FileSystem", "Win32_Foundation", + "Win32_Security", "Win32_Storage_FileSystem", + "Win32_System_Diagnostics_ToolHelp", + "Win32_System_JobObjects", "Win32_System_IO", + "Win32_System_Threading", ] } [features] @@ -69,10 +77,11 @@ default = [] # Test-only cross-crate fault boundaries. Shipped builds do not expose these # hooks; integration tests opt in through a dev-dependency feature union. test-faults = [] -# Real SigLIP2 inference via ONNX Runtime. `download-binaries` lets ort fetch a -# prebuilt onnxruntime *when this feature is explicitly enabled* — it is off by -# default, so plain `cargo build`/`cargo test` never touch the network. -ort-backend = ["dep:ort"] +# Real SigLIP2 inference through ort. Windows uses ort's official pure-Rust +# tract backend so the installed product does not depend on Server-only +# DirectX/ONNX Runtime entry points. Other platforms keep the pinned native +# ONNX Runtime backend. Both remain off in the default, fully-offline build. +ort-backend = ["dep:ort", "dep:ort-tract"] # Real on-device transcription via whisper.cpp (compiles native C++ on enable). whisper-backend = ["dep:whisper-rs"] # Model weight download/verify/unzip (reqwest + zip + sha1). Off by default so @@ -80,10 +89,20 @@ whisper-backend = ["dep:whisper-rs"] # ggml downloads against whisper.cpp's published SHA-1 checksums. model-download = ["dep:reqwest", "dep:zip", "dep:futures-util", "dep:sha1"] -[dependencies.ort] +[target.'cfg(not(windows))'.dependencies.ort] version = "=2.0.0-rc.10" default-features = false -features = ["std", "ndarray", "download-binaries"] +features = ["std", "ndarray", "download-binaries", "copy-dylibs"] +optional = true + +[target.'cfg(windows)'.dependencies.ort] +version = "=2.0.0-rc.10" +default-features = false +features = ["std", "ndarray", "alternative-backend"] +optional = true + +[target.'cfg(windows)'.dependencies.ort-tract] +version = "=0.1.0" optional = true [dependencies.whisper-rs] diff --git a/crates/opentake-media/src/analysis/beat.rs b/crates/opentake-media/src/analysis/beat.rs index 5ae1f97b..74e471df 100644 --- a/crates/opentake-media/src/analysis/beat.rs +++ b/crates/opentake-media/src/analysis/beat.rs @@ -28,6 +28,11 @@ pub struct BeatOnset { pub strength: f32, } +// Normalized PCM below this onset-energy delta is treated as low-level speech, +// room tone, or codec noise. Relative normalization alone would otherwise turn +// an inaudible fluctuation into a full-strength "beat". +const MIN_ABSOLUTE_ONSET_ENERGY_DELTA: f32 = 0.0001; + pub fn detect_beats(samples: &[f32], config: BeatDetectionConfig) -> Vec { if samples.is_empty() || config.sample_rate == 0 || !config.fps.is_finite() || config.fps <= 0.0 { @@ -45,7 +50,7 @@ pub fn detect_beats(samples: &[f32], config: BeatDetectionConfig) -> Vec 0.0); } + + #[test] + fn low_energy_speech_is_not_overdetected() { + let samples = (0..1_000) + .map(|index| if (index / 100) % 2 == 0 { 0.005 } else { 0.006 }) + .collect::>(); + let config = BeatDetectionConfig { + sample_rate: 1_000, + fps: 10.0, + window_size_samples: 100, + hop_size_samples: 100, + min_onset_strength: 0.05, + min_gap_frames: 1, + }; + + assert!(detect_beats(&samples, config).is_empty()); + } } diff --git a/crates/opentake-media/src/analysis/denoise.rs b/crates/opentake-media/src/analysis/denoise.rs new file mode 100644 index 00000000..6a4cfed3 --- /dev/null +++ b/crates/opentake-media/src/analysis/denoise.rs @@ -0,0 +1,232 @@ +//! Deterministic local STFT noise suppression shared by preview and export. + +use std::sync::Arc; + +use opentake_domain::{AudioDenoise, DenoiseMode}; +use rustfft::{num_complex::Complex32, Fft, FftPlanner}; + +use crate::MediaCancelToken; + +pub type DenoiseProgressCallback = Arc; + +#[derive(Debug, thiserror::Error, PartialEq, Eq)] +pub enum DenoiseError { + #[error("denoise_invalid_config: {0}")] + InvalidConfig(String), + #[error("denoise_cancelled")] + Cancelled, +} + +/// Process interleaved PCM without mutating the source. A zero-strength config +/// is a bit-exact bypass. Each channel is transformed independently so stereo +/// placement is preserved, while all call sites share identical parameters and +/// math. +pub fn denoise_interleaved( + samples: &[f32], + channels: usize, + sample_rate: u32, + config: AudioDenoise, + cancel: &MediaCancelToken, + progress: Option, +) -> Result, DenoiseError> { + config + .validate() + .map_err(|error| DenoiseError::InvalidConfig(error.to_string()))?; + if channels == 0 + || channels > 8 + || sample_rate < 8_000 + || !samples.len().is_multiple_of(channels) + { + return Err(DenoiseError::InvalidConfig( + "channels, sample rate, or interleaving is unsupported".to_string(), + )); + } + if cancel.checkpoint() { + return Err(DenoiseError::Cancelled); + } + if samples.is_empty() || config.strength == 0.0 { + return Ok(samples.to_vec()); + } + + let frame_len = if sample_rate >= 32_000 { 1_024 } else { 512 }; + let hop = frame_len / 4; + let audio_frames = samples.len() / channels; + let windows = if audio_frames <= frame_len { + 1 + } else { + 1 + (audio_frames - 1) / hop + }; + let total_steps = channels.saturating_mul(windows).saturating_mul(2).max(1); + let mut completed = 0usize; + + let window = (0..frame_len) + .map(|index| { + let phase = std::f32::consts::TAU * index as f32 / frame_len as f32; + 0.5 - 0.5 * phase.cos() + }) + .collect::>(); + let mut planner = FftPlanner::::new(); + let forward = planner.plan_fft_forward(frame_len); + let inverse = planner.plan_fft_inverse(frame_len); + let mut output = vec![0.0_f32; samples.len()]; + + for channel in 0..channels { + let mono = samples + .iter() + .skip(channel) + .step_by(channels) + .copied() + .collect::>(); + let processed = process_channel( + &mono, + &window, + hop, + windows, + config, + cancel, + &progress, + total_steps, + &mut completed, + &forward, + &inverse, + )?; + for (frame, value) in processed.into_iter().enumerate() { + output[frame * channels + channel] = value.clamp(-1.0, 1.0); + } + } + + if let Some(report) = progress { + report(total_steps, total_steps); + } + Ok(output) +} + +#[allow(clippy::too_many_arguments)] +fn process_channel( + samples: &[f32], + window: &[f32], + hop: usize, + windows: usize, + config: AudioDenoise, + cancel: &MediaCancelToken, + progress: &Option, + total_steps: usize, + completed: &mut usize, + forward: &Arc>, + inverse: &Arc>, +) -> Result, DenoiseError> { + let frame_len = window.len(); + let bins = frame_len / 2 + 1; + const MAX_NOISE_ESTIMATE_WINDOWS: usize = 512; + let estimate_stride = windows.div_ceil(MAX_NOISE_ESTIMATE_WINDOWS).max(1); + let mut powers = (0..bins) + .map(|_| Vec::with_capacity(windows.min(MAX_NOISE_ESTIMATE_WINDOWS))) + .collect::>(); + let mut spectrum = vec![Complex32::new(0.0, 0.0); frame_len]; + + for frame_index in 0..windows { + if cancel.checkpoint() { + return Err(DenoiseError::Cancelled); + } + load_window(samples, window, frame_index * hop, &mut spectrum); + forward.process(&mut spectrum); + if frame_index.is_multiple_of(estimate_stride) { + for bin in 0..bins { + powers[bin].push(spectrum[bin].norm_sqr()); + } + } + report_step(progress, total_steps, completed); + } + + let noise_power = powers + .into_iter() + .map(|mut values| { + values.sort_by(f32::total_cmp); + let index = ((values.len().saturating_sub(1)) as f32 * 0.15).round() as usize; + values[index].max(1.0e-12) + }) + .collect::>(); + let strength = config.strength as f32; + let oversubtraction = match config.mode { + DenoiseMode::Adaptive => 1.0 + 4.5 * strength, + DenoiseMode::Voice => 1.0 + 6.0 * strength, + }; + let floor_gain = 1.0 - 0.92 * strength; + let mut prior_gain = vec![1.0_f32; bins]; + let mut raw_gain = vec![1.0_f32; bins]; + let mut out = vec![0.0_f32; samples.len()]; + let mut norm = vec![0.0_f32; samples.len()]; + + for frame_index in 0..windows { + if cancel.checkpoint() { + return Err(DenoiseError::Cancelled); + } + let start = frame_index * hop; + load_window(samples, window, start, &mut spectrum); + forward.process(&mut spectrum); + for bin in 0..bins { + let power = spectrum[bin].norm_sqr().max(1.0e-12); + let clean_ratio = (1.0 - oversubtraction * noise_power[bin] / power).max(0.0); + raw_gain[bin] = clean_ratio.sqrt().max(floor_gain); + } + for bin in 0..bins { + let lo = bin.saturating_sub(1); + let hi = (bin + 1).min(bins - 1); + let frequency_smoothed = raw_gain[lo..=hi].iter().sum::() / (hi - lo + 1) as f32; + let gain = (prior_gain[bin] * 0.25 + frequency_smoothed * 0.75).clamp(floor_gain, 1.0); + prior_gain[bin] = gain; + spectrum[bin] *= gain; + if bin > 0 && bin < frame_len / 2 { + spectrum[frame_len - bin] *= gain; + } + } + inverse.process(&mut spectrum); + for index in 0..frame_len { + let output_index = start + index; + if output_index >= out.len() { + break; + } + let weight = window[index]; + out[output_index] += spectrum[index].re / frame_len as f32 * weight; + norm[output_index] += weight * weight; + } + report_step(progress, total_steps, completed); + } + + let input_peak = samples + .iter() + .map(|sample| sample.abs()) + .fold(0.0_f32, f32::max) + .min(1.0); + let edge_span = (frame_len / 2).min(samples.len().saturating_sub(1)).max(1); + for (index, (value, weight)) in out.iter_mut().zip(norm).enumerate() { + let normalized = if weight > 1.0e-6 { + *value / weight + } else { + samples[index] + }; + // A centered STFT would normally pad both ends before analysis. Keep + // the implementation allocation-bounded by crossfading the unpadded + // edge into the processed signal instead. The peak guard prevents + // low Hann-normalization weights from creating a click or a new peak. + let edge_distance = index.min(samples.len() - 1 - index); + let processed_mix = (edge_distance as f32 / edge_span as f32).min(1.0); + *value = (samples[index] * (1.0 - processed_mix) + normalized * processed_mix) + .clamp(-input_peak, input_peak); + } + Ok(out) +} + +fn load_window(samples: &[f32], window: &[f32], start: usize, target: &mut [Complex32]) { + for (index, complex) in target.iter_mut().enumerate() { + let value = samples.get(start + index).copied().unwrap_or(0.0); + *complex = Complex32::new(value * window[index], 0.0); + } +} + +fn report_step(progress: &Option, total: usize, completed: &mut usize) { + *completed = completed.saturating_add(1); + if let Some(report) = progress { + report((*completed).min(total), total); + } +} diff --git a/crates/opentake-media/src/analysis/loudness.rs b/crates/opentake-media/src/analysis/loudness.rs new file mode 100644 index 00000000..e5a022ea --- /dev/null +++ b/crates/opentake-media/src/analysis/loudness.rs @@ -0,0 +1,384 @@ +//! Deterministic EBU R128 / ITU-R BS.1770 loudness analysis for mono PCM. +//! +//! OpenTake decodes clip windows to 48 kHz mono before analysis. The same +//! computed gain is persisted on the clip and consumed by preview and export; +//! analysis is never repeated during playback or rendering. + +use std::sync::Arc; + +use thiserror::Error; + +use crate::MediaCancelToken; + +const ABSOLUTE_GATE_LUFS: f64 = -70.0; +const RELATIVE_GATE_LU: f64 = -10.0; +const LOUDNESS_OFFSET: f64 = -0.691; +const BLOCK_MILLIS: u64 = 400; +const BLOCK_STEP_MILLIS: u64 = 100; +const SILENCE_EPSILON: f64 = 1.0e-12; + +pub type LoudnessProgressCallback = Arc; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct LoudnessNormalizationConfig { + pub target_lufs: f64, + pub true_peak_ceiling_dbtp: f64, +} + +impl Default for LoudnessNormalizationConfig { + fn default() -> Self { + Self { + target_lufs: -16.0, + true_peak_ceiling_dbtp: -1.0, + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct LoudnessAnalysis { + pub input_integrated_lufs: f64, + pub input_true_peak_dbtp: f64, + pub target_lufs: f64, + pub true_peak_ceiling_dbtp: f64, + pub gain_db: f64, + pub output_integrated_lufs: f64, + pub output_true_peak_dbtp: f64, +} + +#[derive(Clone, Debug, Error, PartialEq)] +pub enum LoudnessError { + #[error("loudness_invalid_config: target LUFS and true-peak ceiling must be finite, with ceiling <= 0 dBTP")] + InvalidConfig, + #[error("loudness_unreadable_audio: sample rate must be positive and PCM must contain finite samples")] + UnreadableAudio, + #[error("loudness_silent_audio: no block passed the EBU R128 absolute gate")] + SilentAudio, + #[error("loudness_target_unreachable: the requested target cannot be reached under the true-peak ceiling")] + TargetUnreachable, + #[error("loudness_cancelled")] + Cancelled, +} + +#[derive(Clone, Copy)] +struct Biquad { + b0: f64, + b1: f64, + b2: f64, + a1: f64, + a2: f64, + x1: f64, + x2: f64, + y1: f64, + y2: f64, +} + +impl Biquad { + fn process(&mut self, input: f64) -> f64 { + let output = self.b0 * input + self.b1 * self.x1 + self.b2 * self.x2 + - self.a1 * self.y1 + - self.a2 * self.y2; + self.x2 = self.x1; + self.x1 = input; + self.y2 = self.y1; + self.y1 = output; + output + } +} + +/// Analyze mono PCM with EBU R128 gating and a 4x inter-sample peak estimate. +pub fn analyze_loudness( + samples: &[f32], + sample_rate: u32, + config: LoudnessNormalizationConfig, +) -> Result { + analyze_loudness_with_progress(samples, sample_rate, config, &MediaCancelToken::new(), None) +} + +pub fn analyze_loudness_with_progress( + samples: &[f32], + sample_rate: u32, + config: LoudnessNormalizationConfig, + cancel: &MediaCancelToken, + progress: Option, +) -> Result { + validate(samples, sample_rate, config)?; + if cancel.checkpoint() { + return Err(LoudnessError::Cancelled); + } + + let input_integrated_lufs = + integrated_loudness(samples, sample_rate, cancel, progress.as_deref())?; + let input_true_peak_dbtp = true_peak_dbtp(samples); + if !input_integrated_lufs.is_finite() || !input_true_peak_dbtp.is_finite() { + return Err(LoudnessError::SilentAudio); + } + + // Compensate for the shared ceiling stage instead of sacrificing program + // loudness on high-crest-factor speech. Three correction passes converge + // the exact persisted gain against the same hard ceiling preview/export + // use, while remaining deterministic and bounded. + let mut gain_db = (config.target_lufs - input_integrated_lufs).clamp(-120.0, 60.0); + let mut output_integrated_lufs = input_integrated_lufs; + let mut output_true_peak_dbtp = input_true_peak_dbtp; + for _ in 0..4 { + if cancel.checkpoint() { + return Err(LoudnessError::Cancelled); + } + let mut normalized = apply_loudness_gain(samples, gain_db); + crate::encode::mix::apply_true_peak_ceiling( + &mut normalized, + Some(config.true_peak_ceiling_dbtp), + ); + output_integrated_lufs = integrated_loudness(&normalized, sample_rate, cancel, None)?; + output_true_peak_dbtp = true_peak_dbtp(&normalized); + let correction = config.target_lufs - output_integrated_lufs; + if correction.abs() <= 0.05 { + break; + } + gain_db = (gain_db + correction).clamp(-120.0, 60.0); + } + if (output_integrated_lufs - config.target_lufs).abs() > 1.0 { + return Err(LoudnessError::TargetUnreachable); + } + if let Some(report) = &progress { + report(samples.len(), samples.len()); + } + Ok(LoudnessAnalysis { + input_integrated_lufs, + input_true_peak_dbtp, + target_lufs: config.target_lufs, + true_peak_ceiling_dbtp: config.true_peak_ceiling_dbtp, + gain_db, + output_integrated_lufs, + output_true_peak_dbtp, + }) +} + +fn integrated_loudness( + samples: &[f32], + sample_rate: u32, + cancel: &MediaCancelToken, + progress: Option<&(dyn Fn(usize, usize) + Send + Sync)>, +) -> Result { + let weighted = k_weight(samples, sample_rate, cancel, progress)?; + let block_len = + (((u64::from(sample_rate) * BLOCK_MILLIS) / 1_000) as usize).min(weighted.len()); + let block_step = + (((u64::from(sample_rate) * BLOCK_STEP_MILLIS) / 1_000) as usize).min(weighted.len()); + if block_len == 0 || block_step == 0 { + return Err(LoudnessError::SilentAudio); + } + + let mut block_powers = Vec::with_capacity((weighted.len() - block_len) / block_step + 1); + let block_count = (weighted.len() - block_len) / block_step + 1; + for (block_index, start) in (0..=weighted.len() - block_len) + .step_by(block_step) + .enumerate() + { + if block_index.is_multiple_of(32) { + if cancel.checkpoint() { + return Err(LoudnessError::Cancelled); + } + if let Some(report) = progress { + report(weighted.len() + block_index, weighted.len() + block_count); + } + } + let power = weighted[start..start + block_len] + .iter() + .map(|sample| sample * sample) + .sum::() + / block_len as f64; + if power_to_lufs(power) >= ABSOLUTE_GATE_LUFS { + block_powers.push(power); + } + } + if block_powers.is_empty() { + return Err(LoudnessError::SilentAudio); + } + + let absolute_mean = mean(&block_powers); + let relative_gate = power_to_lufs(absolute_mean) + RELATIVE_GATE_LU; + let relative_powers = block_powers + .iter() + .copied() + .filter(|power| power_to_lufs(*power) >= relative_gate) + .collect::>(); + if relative_powers.is_empty() { + return Err(LoudnessError::SilentAudio); + } + let integrated = power_to_lufs(mean(&relative_powers)); + if let Some(report) = progress { + let total = weighted.len() + block_count; + report(total, total); + } + Ok(integrated) +} + +pub fn apply_loudness_gain(samples: &[f32], gain_db: f64) -> Vec { + let gain_db = if gain_db.is_finite() { + gain_db.clamp(-120.0, 60.0) + } else { + 0.0 + }; + let gain = 10.0_f64.powf(gain_db / 20.0) as f32; + samples.iter().map(|sample| *sample * gain).collect() +} + +fn validate( + samples: &[f32], + sample_rate: u32, + config: LoudnessNormalizationConfig, +) -> Result<(), LoudnessError> { + if !config.target_lufs.is_finite() + || !config.true_peak_ceiling_dbtp.is_finite() + || config.true_peak_ceiling_dbtp > 0.0 + || !(-70.0..=0.0).contains(&config.target_lufs) + || !(-20.0..=0.0).contains(&config.true_peak_ceiling_dbtp) + { + return Err(LoudnessError::InvalidConfig); + } + if sample_rate == 0 || samples.is_empty() || samples.iter().any(|sample| !sample.is_finite()) { + return Err(LoudnessError::UnreadableAudio); + } + Ok(()) +} + +fn k_weight( + samples: &[f32], + sample_rate: u32, + cancel: &MediaCancelToken, + progress: Option<&(dyn Fn(usize, usize) + Send + Sync)>, +) -> Result, LoudnessError> { + let mut shelf = shelf_filter(sample_rate as f64); + let mut high_pass = high_pass_filter(sample_rate as f64); + let mut output = Vec::with_capacity(samples.len()); + for (index, sample) in samples.iter().enumerate() { + if index.is_multiple_of(16_384) { + if cancel.checkpoint() { + return Err(LoudnessError::Cancelled); + } + if let Some(report) = progress { + report(index, samples.len()); + } + } + output.push(high_pass.process(shelf.process(f64::from(*sample)))); + } + Ok(output) +} + +// Coefficients are generated from the analog transfer functions in ITU-R +// BS.1770, allowing analysis at device rates other than 48 kHz. +fn shelf_filter(sample_rate: f64) -> Biquad { + let f0 = 1_681.974_450_955_533; + let gain_db = 3.999_843_853_973_347; + let q = 0.707_175_236_955_419_6; + let k = (std::f64::consts::PI * f0 / sample_rate).tan(); + let vh = 10.0_f64.powf(gain_db / 20.0); + let vb = vh.powf(0.499_666_774_154_541_6); + let a0 = 1.0 + k / q + k * k; + Biquad { + b0: (vh + vb * k / q + k * k) / a0, + b1: 2.0 * (k * k - vh) / a0, + b2: (vh - vb * k / q + k * k) / a0, + a1: 2.0 * (k * k - 1.0) / a0, + a2: (1.0 - k / q + k * k) / a0, + x1: 0.0, + x2: 0.0, + y1: 0.0, + y2: 0.0, + } +} + +fn high_pass_filter(sample_rate: f64) -> Biquad { + let f0 = 38.135_470_876_024_44; + let q = 0.500_327_037_323_877_3; + let k = (std::f64::consts::PI * f0 / sample_rate).tan(); + let a0 = 1.0 + k / q + k * k; + Biquad { + b0: 1.0 / a0, + b1: -2.0 / a0, + b2: 1.0 / a0, + a1: 2.0 * (k * k - 1.0) / a0, + a2: (1.0 - k / q + k * k) / a0, + x1: 0.0, + x2: 0.0, + y1: 0.0, + y2: 0.0, + } +} + +fn true_peak_dbtp(samples: &[f32]) -> f64 { + let mut peak = samples + .iter() + .map(|sample| f64::from(sample.abs())) + .fold(0.0_f64, f64::max); + // Four-times cubic interpolation catches inter-sample peaks without adding + // a heavyweight DSP dependency. End points are extended constantly. + for index in 0..samples.len().saturating_sub(1) { + let p0 = f64::from(samples[index.saturating_sub(1)]); + let p1 = f64::from(samples[index]); + let p2 = f64::from(samples[index + 1]); + let p3 = f64::from(samples[(index + 2).min(samples.len() - 1)]); + for phase in 1..4 { + let t = phase as f64 / 4.0; + let t2 = t * t; + let t3 = t2 * t; + let value = 0.5 + * ((2.0 * p1) + + (-p0 + p2) * t + + (2.0 * p0 - 5.0 * p1 + 4.0 * p2 - p3) * t2 + + (-p0 + 3.0 * p1 - 3.0 * p2 + p3) * t3); + peak = peak.max(value.abs()); + } + } + 20.0 * peak.max(SILENCE_EPSILON).log10() +} + +fn mean(values: &[f64]) -> f64 { + values.iter().sum::() / values.len() as f64 +} + +fn power_to_lufs(power: f64) -> f64 { + LOUDNESS_OFFSET + 10.0 * power.max(SILENCE_EPSILON).log10() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn silence_is_a_typed_error() { + let error = analyze_loudness( + &vec![0.0; 48_000], + 48_000, + LoudnessNormalizationConfig::default(), + ) + .unwrap_err(); + assert_eq!(error, LoudnessError::SilentAudio); + } + + #[test] + fn pre_cancelled_analysis_stops_before_work() { + let cancel = MediaCancelToken::new(); + cancel.cancel(); + let error = analyze_loudness_with_progress( + &vec![0.1; 48_000], + 48_000, + LoudnessNormalizationConfig::default(), + &cancel, + None, + ) + .unwrap_err(); + assert_eq!(error, LoudnessError::Cancelled); + } + + #[test] + fn audible_clip_shorter_than_one_r128_block_is_supported() { + let samples = (0..4_800) + .map(|index| (index as f32 * 440.0 * std::f32::consts::TAU / 48_000.0).sin() * 0.1) + .collect::>(); + let analysis = analyze_loudness(&samples, 48_000, LoudnessNormalizationConfig::default()) + .expect("short audible clip"); + assert!(analysis.input_integrated_lufs.is_finite()); + } +} diff --git a/crates/opentake-media/src/analysis/matting.rs b/crates/opentake-media/src/analysis/matting.rs new file mode 100644 index 00000000..226d0c2c --- /dev/null +++ b/crates/opentake-media/src/analysis/matting.rs @@ -0,0 +1,367 @@ +//! Verified local Robust Video Matting (RVM) model and frame inference. + +use std::fs::File; +use std::io::Read; +use std::path::{Path, PathBuf}; +#[cfg(feature = "model-download")] +use std::sync::Arc; + +use sha2::{Digest, Sha256}; + +#[cfg(any(feature = "ort-backend", feature = "model-download"))] +use crate::MediaCancelToken; +#[cfg(feature = "ort-backend")] +use crate::RgbaFrame; +use crate::{MediaError, Result}; + +pub const RVM_MODEL_ID: &str = "rvm-mobilenetv3-fp32-v1.0.0"; +pub const RVM_MODEL_FILE: &str = "rvm_mobilenetv3_fp32.onnx"; +pub const RVM_MODEL_SHA256: &str = + "88d4531297118f595bf2fd60f6f566aec2e559393802d1f436c380f0cbbd2828"; +pub const RVM_MODEL_BYTES: u64 = 14_975_696; +pub const RVM_MODEL_URL: &str = "https://github.com/PeterL1n/RobustVideoMatting/releases/download/v1.0.0/rvm_mobilenetv3_fp32.onnx"; + +#[cfg(feature = "model-download")] +pub type MattingDownloadProgress = Arc; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct InstalledMattingModel { + pub id: String, + pub path: PathBuf, + pub sha256: String, + pub bytes: u64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AlphaMatteFrame { + pub width: u32, + pub height: u32, + pub alpha: Vec, + /// Model-cleaned straight foreground RGB, three bytes per pixel. + pub foreground_rgb: Vec, +} + +pub fn matting_model_path(model_dir: &Path) -> PathBuf { + model_dir.join("matting").join(RVM_MODEL_FILE) +} + +pub fn verify_rvm_model(model_dir: &Path) -> Result { + let path = matting_model_path(model_dir); + let metadata = std::fs::symlink_metadata(&path).map_err(|error| { + MediaError::ModelInstall(format!("matting_model_not_installed:{error}")) + })?; + if !metadata.is_file() || metadata.file_type().is_symlink() { + return Err(MediaError::ModelInstall( + "matting_model_must_be_a_regular_file".to_string(), + )); + } + if metadata.len() != RVM_MODEL_BYTES { + return Err(MediaError::Checksum(format!( + "matting_model_size_mismatch: expected {RVM_MODEL_BYTES}, got {}", + metadata.len() + ))); + } + let mut file = File::open(&path)?; + let mut digest = Sha256::new(); + let mut buffer = [0_u8; 64 * 1024]; + loop { + let read = file.read(&mut buffer)?; + if read == 0 { + break; + } + digest.update(&buffer[..read]); + } + let actual = format!("{:x}", digest.finalize()); + if actual != RVM_MODEL_SHA256 { + return Err(MediaError::Checksum(format!( + "matting_model_integrity_failed: expected {RVM_MODEL_SHA256}, got {actual}" + ))); + } + Ok(InstalledMattingModel { + id: RVM_MODEL_ID.to_string(), + path, + sha256: actual, + bytes: metadata.len(), + }) +} + +#[cfg(feature = "model-download")] +pub async fn download_rvm_model( + model_dir: &Path, + cancel: &MediaCancelToken, + progress: Option, +) -> Result { + use std::io::Write; + + use futures_util::StreamExt; + + if cancel.checkpoint() { + return Err(MediaError::Cancelled); + } + let destination = matting_model_path(model_dir); + if destination.exists() { + return verify_rvm_model(model_dir); + } + let parent = destination + .parent() + .ok_or_else(|| MediaError::ModelInstall("matting_model_path_invalid".to_string()))?; + std::fs::create_dir_all(parent)?; + let parent_metadata = std::fs::symlink_metadata(parent)?; + if !parent_metadata.is_dir() || parent_metadata.file_type().is_symlink() { + return Err(MediaError::ModelInstall( + "matting_model_directory_must_be_regular".to_string(), + )); + } + let install = async { + let response = reqwest::Client::new() + .get(RVM_MODEL_URL) + .send() + .await + .map_err(|error| MediaError::ModelInstall(format!("matting_model_download:{error}")))? + .error_for_status() + .map_err(|error| MediaError::ModelInstall(format!("matting_model_download:{error}")))?; + if response + .content_length() + .is_some_and(|bytes| bytes != RVM_MODEL_BYTES) + { + return Err(MediaError::Checksum( + "matting_model_content_length_mismatch".to_string(), + )); + } + let mut partial = tempfile::Builder::new() + .prefix(".rvm-model-") + .suffix(".partial") + .tempfile_in(parent) + .map_err(|error| { + MediaError::ModelInstall(format!("matting_model_partial_create:{error}")) + })?; + let mut digest = Sha256::new(); + let mut downloaded = 0_u64; + let mut stream = response.bytes_stream(); + while let Some(chunk) = stream.next().await { + if cancel.checkpoint() { + return Err(MediaError::Cancelled); + } + let chunk = chunk.map_err(|error| { + MediaError::ModelInstall(format!("matting_model_download:{error}")) + })?; + downloaded = downloaded + .checked_add(chunk.len() as u64) + .ok_or_else(|| MediaError::ModelInstall("matting_model_too_large".to_string()))?; + if downloaded > RVM_MODEL_BYTES { + return Err(MediaError::Checksum( + "matting_model_download_exceeds_manifest".to_string(), + )); + } + digest.update(&chunk); + partial.as_file_mut().write_all(&chunk)?; + if let Some(progress) = &progress { + progress(downloaded, RVM_MODEL_BYTES); + } + } + partial.as_file().sync_all()?; + if downloaded != RVM_MODEL_BYTES { + return Err(MediaError::Checksum(format!( + "matting_model_size_mismatch: expected {RVM_MODEL_BYTES}, got {downloaded}" + ))); + } + let actual = format!("{:x}", digest.finalize()); + if actual != RVM_MODEL_SHA256 { + return Err(MediaError::Checksum(format!( + "matting_model_integrity_failed: expected {RVM_MODEL_SHA256}, got {actual}" + ))); + } + partial.persist_noclobber(&destination).map_err(|error| { + MediaError::ModelInstall(format!("matting_model_publish:{}", error.error)) + })?; + Ok(()) + } + .await; + install?; + verify_rvm_model(model_dir) +} + +#[cfg(feature = "ort-backend")] +pub struct RvmMattingSession { + model: crate::ort_worker::OrtModel, + recurrent: [ndarray::ArrayD; 4], + pub installed: InstalledMattingModel, +} + +#[cfg(feature = "ort-backend")] +impl RvmMattingSession { + pub fn load(model_dir: &Path) -> Result { + use ndarray::{ArrayD, IxDyn}; + + let installed = verify_rvm_model(model_dir)?; + let model = crate::ort_worker::OrtModel::load( + &installed.path, + crate::ort_worker::ExecutionProvider::platform_default(), + )?; + let (inputs, outputs) = model.io_contract(); + let input_names = inputs + .iter() + .map(|(name, _)| name.as_str()) + .collect::>(); + let output_names = outputs + .iter() + .map(|(name, _)| name.as_str()) + .collect::>(); + if input_names != ["src", "r1i", "r2i", "r3i", "r4i", "downsample_ratio"] + || output_names != ["fgr", "pha", "r1o", "r2o", "r3o", "r4o"] + { + return Err(MediaError::ModelInstall( + "matting_model_io_contract_mismatch".to_string(), + )); + } + let empty = || ArrayD::zeros(IxDyn(&[1, 1, 1, 1])); + Ok(Self { + model, + recurrent: [empty(), empty(), empty(), empty()], + installed, + }) + } + + pub fn reset_temporal_state(&mut self) { + use ndarray::{ArrayD, IxDyn}; + self.recurrent = std::array::from_fn(|_| ArrayD::zeros(IxDyn(&[1, 1, 1, 1]))); + } + + pub fn infer( + &mut self, + frame: &RgbaFrame, + cancel: &MediaCancelToken, + ) -> Result { + use ndarray::{ArrayD, IxDyn}; + + if cancel.checkpoint() { + return Err(MediaError::Cancelled); + } + let width = frame.width as usize; + let height = frame.height as usize; + let mut src = vec![0.0_f32; 3 * width * height]; + for (index, pixel) in frame.rgba.chunks_exact(4).enumerate() { + src[index] = pixel[0] as f32 / 255.0; + src[width * height + index] = pixel[1] as f32 / 255.0; + src[2 * width * height + index] = pixel[2] as f32 / 255.0; + } + let src = ArrayD::from_shape_vec(IxDyn(&[1, 3, height, width]), src) + .map_err(|error| MediaError::Decode(format!("matting_input_shape:{error}")))?; + let mut outputs = self.model.run_f32(vec![ + ("src".to_string(), src), + ("r1i".to_string(), self.recurrent[0].clone()), + ("r2i".to_string(), self.recurrent[1].clone()), + ("r3i".to_string(), self.recurrent[2].clone()), + ("r4i".to_string(), self.recurrent[3].clone()), + ( + "downsample_ratio".to_string(), + ArrayD::from_shape_vec(IxDyn(&[1]), vec![0.25]).expect("fixed ratio shape"), + ), + ])?; + if cancel.checkpoint() { + return Err(MediaError::Cancelled); + } + for (index, name) in ["r1o", "r2o", "r3o", "r4o"].into_iter().enumerate() { + self.recurrent[index] = outputs.remove(name).ok_or_else(|| { + MediaError::Decode(format!("matting_model_missing_output:{name}")) + })?; + } + let foreground = outputs + .remove("fgr") + .ok_or_else(|| MediaError::Decode("matting_model_missing_output:fgr".to_string()))?; + if foreground.shape() != [1, 3, height, width] { + return Err(MediaError::Decode(format!( + "matting_foreground_shape_mismatch:{:?}", + foreground.shape() + ))); + } + let foreground = foreground + .as_slice() + .ok_or_else(|| MediaError::Decode("matting_foreground_not_contiguous".to_string()))?; + let alpha = outputs + .remove("pha") + .ok_or_else(|| MediaError::Decode("matting_model_missing_output:pha".to_string()))?; + if alpha.shape() != [1, 1, height, width] { + return Err(MediaError::Decode(format!( + "matting_alpha_shape_mismatch:{:?}", + alpha.shape() + ))); + } + let alpha = alpha + .iter() + .map(|value| (value.clamp(0.0, 1.0) * 255.0).round() as u8) + .collect(); + let plane = width * height; + let foreground_rgb = (0..plane) + .flat_map(|index| { + [ + foreground[index], + foreground[plane + index], + foreground[2 * plane + index], + ] + .map(|value| (value.clamp(0.0, 1.0) * 255.0).round() as u8) + }) + .collect(); + Ok(AlphaMatteFrame { + width: frame.width, + height: frame.height, + alpha, + foreground_rgb, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn missing_model_is_a_typed_install_error() { + let root = tempfile::tempdir().unwrap(); + let error = verify_rvm_model(root.path()).expect_err("model must be absent"); + assert!(matches!(error, MediaError::ModelInstall(_))); + assert!(error.to_string().contains("matting_model_not_installed")); + } + + #[cfg(feature = "model-download")] + #[test] + fn pre_cancelled_download_never_creates_a_partial_model() { + use futures_util::FutureExt; + + let root = tempfile::tempdir().unwrap(); + let cancel = MediaCancelToken::new(); + cancel.cancel(); + let error = download_rvm_model(root.path(), &cancel, None) + .now_or_never() + .expect("pre-cancelled install completes without polling the network") + .expect_err("pre-cancelled install must fail before network access"); + assert!(matches!(error, MediaError::Cancelled)); + assert!(!matting_model_path(root.path()).exists()); + } + + #[cfg(feature = "ort-backend")] + #[test] + fn official_rvm_model_returns_frame_aligned_alpha() { + let Some(source) = std::env::var_os("OPENTAKE_TEST_RVM_MODEL") else { + return; + }; + let root = tempfile::tempdir().unwrap(); + let destination = matting_model_path(root.path()); + std::fs::create_dir_all(destination.parent().unwrap()).unwrap(); + std::fs::copy(source, destination).unwrap(); + let mut session = RvmMattingSession::load(root.path()).expect("load verified RVM"); + let mut frame = RgbaFrame::black(64, 64); + for y in 8..56 { + for x in 16..48 { + let index = ((y * 64 + x) * 4) as usize; + frame.rgba[index..index + 4].copy_from_slice(&[210, 160, 130, 255]); + } + } + let matte = session + .infer(&frame, &MediaCancelToken::new()) + .expect("infer alpha"); + assert_eq!((matte.width, matte.height), (64, 64)); + assert_eq!(matte.alpha.len(), 64 * 64); + assert_eq!(matte.foreground_rgb.len(), 64 * 64 * 3); + } +} diff --git a/crates/opentake-media/src/analysis/mod.rs b/crates/opentake-media/src/analysis/mod.rs index dce2342b..76d57ae9 100644 --- a/crates/opentake-media/src/analysis/mod.rs +++ b/crates/opentake-media/src/analysis/mod.rs @@ -2,11 +2,38 @@ pub mod autocrop; pub mod beat; +pub mod denoise; +pub mod loudness; +pub mod matting; pub mod silence; +pub mod stabilization; +pub mod stems; pub use autocrop::{ detect_autocrop, AutocropConfig, AutocropPlan, CropRect, CropTransform, FrameBuffer, PixelFormat, }; pub use beat::{detect_beats, BeatDetectionConfig, BeatOnset}; +pub use denoise::{denoise_interleaved, DenoiseError, DenoiseProgressCallback}; +pub use loudness::{ + analyze_loudness, analyze_loudness_with_progress, apply_loudness_gain, LoudnessAnalysis, + LoudnessError, LoudnessNormalizationConfig, LoudnessProgressCallback, +}; +#[cfg(feature = "ort-backend")] +pub use matting::RvmMattingSession; +#[cfg(feature = "model-download")] +pub use matting::{download_rvm_model, MattingDownloadProgress}; +pub use matting::{ + matting_model_path, verify_rvm_model, AlphaMatteFrame, InstalledMattingModel, RVM_MODEL_BYTES, + RVM_MODEL_FILE, RVM_MODEL_ID, RVM_MODEL_SHA256, RVM_MODEL_URL, +}; pub use silence::{detect_silences, SilenceDetectionConfig, SilenceRange}; +pub use stabilization::{ + analyze_stabilization, track_region_motion, track_translation_motion, NormalizedMotionRegion, + RegionMotionTrack, StabilizationConfig, StabilizationMotionSample, +}; +pub use stems::{ + ensure_local_stem_model, separate_stems, verify_local_stem_model, InstalledStemModel, + StemExecution, StemMetrics, StemOutput, StemProgressCallback, StemProvenance, + StemSeparationRequest, StemSeparationResult, +}; diff --git a/crates/opentake-media/src/analysis/stabilization.rs b/crates/opentake-media/src/analysis/stabilization.rs new file mode 100644 index 00000000..3af3059e --- /dev/null +++ b/crates/opentake-media/src/analysis/stabilization.rs @@ -0,0 +1,475 @@ +//! Deterministic camera-motion smoothing for editable stabilization tracks. + +use opentake_domain::{StabilizationKeyframe, StabilizationTrack}; + +use crate::{MediaCancelToken, MediaError, Result, RgbaFrame}; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct StabilizationMotionSample { + pub frame: i32, + /// Observed camera translation in normalized output-canvas coordinates. + pub translation_x: f64, + pub translation_y: f64, + pub rotation_degrees: f64, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct NormalizedMotionRegion { + pub x: f64, + pub y: f64, + pub width: f64, + pub height: f64, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct RegionMotionTrack { + pub samples: Vec, + pub minimum_confidence: f64, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct StabilizationConfig { + /// Half-width of the centered moving-average window. + pub smoothing_radius: usize, +} + +impl Default for StabilizationConfig { + fn default() -> Self { + Self { + smoothing_radius: 2, + } + } +} + +/// Convert tracked camera motion into a non-destructive compensation track. +/// The analyzer never reads or writes the source media; callers own motion +/// extraction and persist the returned track through the edit command layer. +pub fn analyze_stabilization( + samples: &[StabilizationMotionSample], + source_identity: impl Into, + config: StabilizationConfig, + cancel: &MediaCancelToken, +) -> Result { + if cancel.checkpoint() { + return Err(MediaError::Cancelled); + } + if samples.len() < 2 { + return Err(MediaError::Decode( + "stabilization requires at least two motion samples".to_string(), + )); + } + let source_identity = source_identity.into(); + if source_identity.trim().is_empty() { + return Err(MediaError::Decode( + "stabilization source identity is required".to_string(), + )); + } + for (index, sample) in samples.iter().enumerate() { + if cancel.checkpoint() { + return Err(MediaError::Cancelled); + } + if index > 0 && sample.frame <= samples[index - 1].frame { + return Err(MediaError::Decode( + "stabilization motion frames must be strictly increasing".to_string(), + )); + } + if !sample.translation_x.is_finite() + || !sample.translation_y.is_finite() + || !sample.rotation_degrees.is_finite() + { + return Err(MediaError::Decode( + "stabilization motion samples must be finite".to_string(), + )); + } + } + + let radius = config.smoothing_radius.min(samples.len() - 1); + let keyframes = samples + .iter() + .enumerate() + .map(|(index, sample)| { + let start = index.saturating_sub(radius); + let end = (index + radius + 1).min(samples.len()); + let count = (end - start) as f64; + let smoothed_x = samples[start..end] + .iter() + .map(|entry| entry.translation_x) + .sum::() + / count; + let smoothed_y = samples[start..end] + .iter() + .map(|entry| entry.translation_y) + .sum::() + / count; + let smoothed_rotation = samples[start..end] + .iter() + .map(|entry| entry.rotation_degrees) + .sum::() + / count; + StabilizationKeyframe { + frame: sample.frame, + translation_x: smoothed_x - sample.translation_x, + translation_y: smoothed_y - sample.translation_y, + rotation_degrees: smoothed_rotation - sample.rotation_degrees, + } + }) + .collect(); + + Ok(StabilizationTrack { + model: "opentake.motion-smoothing".to_string(), + model_version: 1, + source_identity, + strength: 1.0, + crop_margin: 0.0, + keyframes, + }) +} + +/// Track a dominant translation path from decoded frames using deterministic +/// luma block matching. Each frame is paired with its clip-relative timeline +/// frame so the returned motion can be turned directly into a persisted track. +pub fn track_translation_motion( + frames: &[(i32, RgbaFrame)], + cancel: &MediaCancelToken, +) -> Result> { + if frames.len() < 2 { + return Err(MediaError::Decode( + "stabilization requires at least two decoded frames".to_string(), + )); + } + let width = frames[0].1.width; + let height = frames[0].1.height; + if width < 24 || height < 24 { + return Err(MediaError::Decode( + "stabilization frames are too small for motion tracking".to_string(), + )); + } + if frames + .iter() + .any(|(_, frame)| frame.width != width || frame.height != height) + { + return Err(MediaError::Decode( + "stabilization frames must have consistent dimensions".to_string(), + )); + } + + let mut x = 0.0; + let mut y = 0.0; + let mut samples = vec![StabilizationMotionSample { + frame: frames[0].0, + translation_x: x, + translation_y: y, + rotation_degrees: 0.0, + }]; + for pair in frames.windows(2) { + if cancel.checkpoint() { + return Err(MediaError::Cancelled); + } + let (dx, dy) = estimate_translation(&pair[0].1, &pair[1].1, cancel)?; + x += dx as f64 / width as f64; + y += dy as f64 / height as f64; + samples.push(StabilizationMotionSample { + frame: pair[1].0, + translation_x: x, + translation_y: y, + rotation_degrees: 0.0, + }); + } + Ok(samples) +} + +/// Track the selected subject rectangle rather than the dominant full-frame +/// camera motion. The rectangle is normalized to the decoded frame and follows +/// the previous match, so a static background cannot overpower a small moving +/// subject. Translation samples are normalized to the full frame and can be +/// applied directly as position-keyframe deltas. +pub fn track_region_motion( + frames: &[(i32, RgbaFrame)], + region: NormalizedMotionRegion, + cancel: &MediaCancelToken, +) -> Result { + if frames.len() < 2 { + return Err(MediaError::Decode( + "motion tracking requires at least two decoded frames".to_string(), + )); + } + if ![region.x, region.y, region.width, region.height] + .into_iter() + .all(f64::is_finite) + || region.x < 0.0 + || region.y < 0.0 + || region.width <= 0.0 + || region.height <= 0.0 + || region.x + region.width > 1.0 + || region.y + region.height > 1.0 + { + return Err(MediaError::Decode( + "motion tracking region must be a positive normalized rectangle inside the frame" + .to_string(), + )); + } + let width = frames[0].1.width; + let height = frames[0].1.height; + if frames + .iter() + .any(|(_, frame)| frame.width != width || frame.height != height) + { + return Err(MediaError::Decode( + "motion tracking frames must have consistent dimensions".to_string(), + )); + } + let region_width = (region.width * width as f64).round() as i32; + let region_height = (region.height * height as f64).round() as i32; + if region_width < 8 || region_height < 8 { + return Err(MediaError::Decode( + "motion tracking region is too small".to_string(), + )); + } + let mut origin_x = + ((region.x * width as f64).round() as i32).clamp(0, width as i32 - region_width); + let mut origin_y = + ((region.y * height as f64).round() as i32).clamp(0, height as i32 - region_height); + let mut total_x = 0_i32; + let mut total_y = 0_i32; + let mut minimum_confidence = 1.0_f64; + let mut samples = vec![StabilizationMotionSample { + frame: frames[0].0, + translation_x: 0.0, + translation_y: 0.0, + rotation_degrees: 0.0, + }]; + for pair in frames.windows(2) { + if cancel.checkpoint() { + return Err(MediaError::Cancelled); + } + let (dx, dy, confidence) = estimate_region_translation( + &pair[0].1, + &pair[1].1, + origin_x, + origin_y, + region_width, + region_height, + cancel, + )?; + origin_x += dx; + origin_y += dy; + total_x += dx; + total_y += dy; + minimum_confidence = minimum_confidence.min(confidence); + samples.push(StabilizationMotionSample { + frame: pair[1].0, + translation_x: total_x as f64 / width as f64, + translation_y: total_y as f64 / height as f64, + rotation_degrees: 0.0, + }); + } + Ok(RegionMotionTrack { + samples, + minimum_confidence, + }) +} + +fn estimate_region_translation( + previous: &RgbaFrame, + current: &RgbaFrame, + origin_x: i32, + origin_y: i32, + region_width: i32, + region_height: i32, + cancel: &MediaCancelToken, +) -> Result<(i32, i32, f64)> { + const SEARCH: i32 = 8; + const STEP: usize = 2; + let mut min_luma = u16::MAX; + let mut max_luma = 0_u16; + for y in (0..region_height).step_by(STEP) { + for x in (0..region_width).step_by(STEP) { + let value = luma(previous, (origin_x + x) as u32, (origin_y + y) as u32); + min_luma = min_luma.min(value); + max_luma = max_luma.max(value); + } + } + let texture = (max_luma.saturating_sub(min_luma) as f64 / 64.0).clamp(0.0, 1.0); + let mut best = (u64::MAX, i32::MAX, i32::MAX, i32::MAX, 0, 0); + for dy in -SEARCH..=SEARCH { + if cancel.checkpoint() { + return Err(MediaError::Cancelled); + } + for dx in -SEARCH..=SEARCH { + let candidate_x = origin_x + dx; + let candidate_y = origin_y + dy; + if candidate_x < 0 + || candidate_y < 0 + || candidate_x + region_width > current.width as i32 + || candidate_y + region_height > current.height as i32 + { + continue; + } + let mut error = 0_u64; + let mut count = 0_u64; + for y in (0..region_height).step_by(STEP) { + for x in (0..region_width).step_by(STEP) { + let a = luma(previous, (origin_x + x) as u32, (origin_y + y) as u32); + let b = luma(current, (candidate_x + x) as u32, (candidate_y + y) as u32); + error += a.abs_diff(b) as u64; + count += 1; + } + } + let normalized = error.checked_div(count).unwrap_or(u64::MAX); + let candidate = (normalized, dx.abs() + dy.abs(), dy.abs(), dx.abs(), dx, dy); + if candidate < best { + best = candidate; + } + } + } + if best.0 == u64::MAX { + return Err(MediaError::Decode( + "motion tracking region left the frame".to_string(), + )); + } + let match_quality = (1.0 - best.0 as f64 / 255.0).clamp(0.0, 1.0); + Ok((best.4, best.5, match_quality * texture)) +} + +fn estimate_translation( + previous: &RgbaFrame, + current: &RgbaFrame, + cancel: &MediaCancelToken, +) -> Result<(i32, i32)> { + const SEARCH: i32 = 8; + const STEP: usize = 8; + let width = current.width as i32; + let height = current.height as i32; + let mut best = (u64::MAX, i32::MAX, i32::MAX, i32::MAX, 0, 0); + for dy in -SEARCH..=SEARCH { + if cancel.checkpoint() { + return Err(MediaError::Cancelled); + } + for dx in -SEARCH..=SEARCH { + let mut error = 0_u64; + let mut count = 0_u64; + for y in ((SEARCH + 1) as usize..(height - SEARCH - 1) as usize).step_by(STEP) { + for x in ((SEARCH + 1) as usize..(width - SEARCH - 1) as usize).step_by(STEP) { + let previous_x = x as i32 - dx; + let previous_y = y as i32 - dy; + let a = luma(previous, previous_x as u32, previous_y as u32); + let b = luma(current, x as u32, y as u32); + error += a.abs_diff(b) as u64; + count += 1; + } + } + let normalized = error.checked_div(count).unwrap_or(u64::MAX); + let candidate = (normalized, dx.abs() + dy.abs(), dy.abs(), dx.abs(), dx, dy); + if candidate < best { + best = candidate; + } + } + } + Ok((best.4, best.5)) +} + +fn luma(frame: &RgbaFrame, x: u32, y: u32) -> u16 { + let offset = ((y * frame.width + x) * 4) as usize; + let r = frame.rgba[offset] as u16; + let g = frame.rgba[offset + 1] as u16; + let b = frame.rgba[offset + 2] as u16; + (54 * r + 183 * g + 19 * b) >> 8 +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rejects_pre_cancelled_analysis_before_work() { + let cancel = MediaCancelToken::new(); + cancel.cancel(); + let result = analyze_stabilization( + &[ + StabilizationMotionSample { + frame: 0, + translation_x: 0.0, + translation_y: 0.0, + rotation_degrees: 0.0, + }, + StabilizationMotionSample { + frame: 1, + translation_x: 0.1, + translation_y: 0.0, + rotation_degrees: 0.0, + }, + ], + "asset", + StabilizationConfig::default(), + &cancel, + ); + assert!(matches!(result, Err(MediaError::Cancelled))); + } + + #[test] + fn block_match_tracks_known_translation() { + let make_frame = |offset: u32| { + let mut frame = RgbaFrame::black(64, 48); + for y in 8..40 { + for x in offset..offset + 24 { + let index = ((y * frame.width + x) * 4) as usize; + let value = ((x - offset) * 37 + y * 19 + (x - offset) * y * 3) as u8; + frame.rgba[index..index + 3].copy_from_slice(&[value, value, value]); + } + } + frame + }; + let samples = track_translation_motion( + &[ + (0, make_frame(16)), + (1, make_frame(20)), + (2, make_frame(24)), + ], + &MediaCancelToken::new(), + ) + .expect("track translated fixture"); + assert!(samples[1].translation_x > 0.0); + assert!(samples[2].translation_x > samples[1].translation_x); + assert_eq!(samples[2].translation_y, 0.0); + } + + #[test] + fn region_tracker_keeps_known_subject_center_within_five_pixels() { + let make_frame = |offset_x: u32, offset_y: u32| { + let mut frame = RgbaFrame::black(96, 72); + for y in offset_y..offset_y + 20 { + for x in offset_x..offset_x + 24 { + let index = ((y * frame.width + x) * 4) as usize; + let local_x = x - offset_x; + let local_y = y - offset_y; + frame.rgba[index..index + 3].copy_from_slice(&[ + (local_x * 9 + local_y * 3) as u8, + (local_x * 2 + local_y * 11) as u8, + (local_x * 7 + local_y * 5) as u8, + ]); + } + } + frame + }; + let tracked = track_region_motion( + &[ + (0, make_frame(20, 24)), + (1, make_frame(24, 26)), + (2, make_frame(28, 28)), + ], + NormalizedMotionRegion { + x: 20.0 / 96.0, + y: 24.0 / 72.0, + width: 24.0 / 96.0, + height: 20.0 / 72.0, + }, + &MediaCancelToken::new(), + ) + .expect("track selected subject"); + + assert!(tracked.minimum_confidence >= 0.25); + let final_sample = tracked.samples.last().expect("final sample"); + assert!((final_sample.translation_x * 96.0 - 8.0).abs() <= 5.0); + assert!((final_sample.translation_y * 72.0 - 4.0).abs() <= 5.0); + } +} diff --git a/crates/opentake-media/src/analysis/stems.rs b/crates/opentake-media/src/analysis/stems.rs new file mode 100644 index 00000000..9a7233ee --- /dev/null +++ b/crates/opentake-media/src/analysis/stems.rs @@ -0,0 +1,358 @@ +//! Deterministic two-stem separation shared by the desktop job and tests. +//! +//! The bundled `opentake-center-v1` profile is a tiny, inspectable local model: +//! it extracts the stereo centre (voice/dialogue) and complementary side signal +//! (music/ambience). Both user-facing stems are emitted dual-mono so either one +//! remains audible through OpenTake's current mono export mixdown. It is +//! intentionally local-first and offline. Hosted +//! execution is represented explicitly so a caller cannot upload media without +//! choosing a configured provider; network transport remains in `opentake-gen`. + +use std::fs::{self, File, OpenOptions}; +use std::io::{Read, Write}; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use sha2::{Digest, Sha256}; + +use crate::{ + decode_pcm_interleaved_cancellable, MediaCancelToken, MediaError, PcmFormat, PcmSpec, Result, +}; + +const MODEL_ID: &str = "opentake-center-v1"; +const MODEL_FILE: &str = "opentake-center-v1.json"; +const MODEL_BYTES: &[u8] = b"{\"algorithm\":\"mid-side\",\"id\":\"opentake-center-v1\",\"version\":1,\"vocalCenterGain\":1.0,\"residualGain\":1.0}\n"; +const MODEL_SHA256: &str = "9c72ab220f370000a702fc11c8071905648a56d1102d9519659a6062abb4b376"; +const SAMPLE_RATE: u32 = 48_000; +const CHANNELS: u16 = 2; +const PROGRESS_TOTAL: usize = 1_000; + +pub type StemProgressCallback = Arc; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct InstalledStemModel { + pub id: String, + pub path: PathBuf, + pub sha256: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum StemExecution<'a> { + Local { model_dir: &'a Path }, + Hosted { provider: String, model: String }, +} + +#[derive(Clone, Debug)] +pub struct StemSeparationRequest<'a> { + pub source: &'a Path, + pub output_dir: &'a Path, + pub execution: StemExecution<'a>, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct StemOutput { + pub path: PathBuf, + pub name: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct StemProvenance { + pub source_sha256: String, + pub execution: String, + pub model_sha256: Option, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct StemMetrics { + /// Centre/side cross-talk removed by the local matrix, expressed as an + /// estimated SDR improvement. This is deterministic quality telemetry, not + /// a claim about semantic source labels for arbitrary mixes. + pub vocal_sdr_improvement_db: f64, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct StemSeparationResult { + pub vocals: StemOutput, + pub accompaniment: StemOutput, + pub provenance: StemProvenance, + pub metrics: StemMetrics, +} + +fn model_path(model_dir: &Path) -> PathBuf { + model_dir.join("stems").join(MODEL_FILE) +} + +fn digest_bytes(bytes: &[u8]) -> String { + format!("{:x}", Sha256::digest(bytes)) +} + +fn digest_file(path: &Path, cancel: Option<&MediaCancelToken>) -> Result { + let mut file = File::open(path)?; + let mut digest = Sha256::new(); + let mut chunk = [0_u8; 64 * 1024]; + loop { + if cancel.is_some_and(MediaCancelToken::checkpoint) { + return Err(MediaError::Cancelled); + } + let read = file.read(&mut chunk)?; + if read == 0 { + break; + } + digest.update(&chunk[..read]); + } + Ok(format!("{:x}", digest.finalize())) +} + +pub fn verify_local_stem_model(model_dir: &Path) -> Result { + let path = model_path(model_dir); + let actual = digest_file(&path, None)?; + if actual != MODEL_SHA256 { + return Err(MediaError::Checksum(format!( + "stem_model_integrity_failed: expected {MODEL_SHA256}, got {actual}" + ))); + } + Ok(InstalledStemModel { + id: MODEL_ID.to_string(), + path, + sha256: actual, + }) +} + +/// Install the bundled, offline model once. Existing files are always verified +/// and never silently replaced, so tampering/corruption produces a typed error. +pub fn ensure_local_stem_model(model_dir: &Path) -> Result { + let path = model_path(model_dir); + if path.exists() { + return verify_local_stem_model(model_dir); + } + let parent = path + .parent() + .ok_or_else(|| MediaError::ModelInstall("stem_model_path_invalid".to_string()))?; + fs::create_dir_all(parent)?; + let partial = parent.join(format!(".{MODEL_FILE}.partial")); + let install = (|| -> Result<()> { + let mut file = OpenOptions::new() + .create_new(true) + .write(true) + .open(&partial) + .map_err(|error| MediaError::ModelInstall(format!("stem_model_install: {error}")))?; + file.write_all(MODEL_BYTES)?; + file.sync_all()?; + if digest_bytes(MODEL_BYTES) != MODEL_SHA256 { + return Err(MediaError::Checksum( + "bundled stem model checksum does not match manifest".to_string(), + )); + } + fs::rename(&partial, &path)?; + Ok(()) + })(); + if install.is_err() { + let _ = fs::remove_file(&partial); + } + install?; + verify_local_stem_model(model_dir) +} + +fn report(progress: &Option, completed: usize) { + if let Some(report) = progress { + report(completed.min(PROGRESS_TOTAL), PROGRESS_TOTAL); + } +} + +fn safe_source_stem(path: &Path) -> String { + let stem = path + .file_stem() + .and_then(|value| value.to_str()) + .unwrap_or("audio"); + let safe = stem + .chars() + .map(|character| { + if character.is_ascii_alphanumeric() || matches!(character, '-' | '_') { + character + } else { + '_' + } + }) + .collect::(); + if safe.is_empty() { + "audio".to_string() + } else { + safe + } +} + +fn write_wav_stereo(path: &Path, samples: &[f32], cancel: &MediaCancelToken) -> Result<()> { + let sample_count = u32::try_from(samples.len()) + .map_err(|_| MediaError::Encode("stem_output_too_large".to_string()))?; + let data_len = sample_count + .checked_mul(2) + .ok_or_else(|| MediaError::Encode("stem_output_too_large".to_string()))?; + let mut file = OpenOptions::new().create_new(true).write(true).open(path)?; + file.write_all(b"RIFF")?; + file.write_all(&(36_u32 + data_len).to_le_bytes())?; + file.write_all(b"WAVEfmt ")?; + file.write_all(&16_u32.to_le_bytes())?; + file.write_all(&1_u16.to_le_bytes())?; + file.write_all(&CHANNELS.to_le_bytes())?; + file.write_all(&SAMPLE_RATE.to_le_bytes())?; + file.write_all(&(SAMPLE_RATE * u32::from(CHANNELS) * 2).to_le_bytes())?; + file.write_all(&(CHANNELS * 2).to_le_bytes())?; + file.write_all(&16_u16.to_le_bytes())?; + file.write_all(b"data")?; + file.write_all(&data_len.to_le_bytes())?; + for chunk in samples.chunks(8 * 1024) { + if cancel.checkpoint() { + return Err(MediaError::Cancelled); + } + let mut bytes = Vec::with_capacity(chunk.len() * 2); + for sample in chunk { + let quantized = (sample.clamp(-1.0, 1.0) * 32767.0).round() as i16; + bytes.extend_from_slice(&quantized.to_le_bytes()); + } + file.write_all(&bytes)?; + } + file.sync_all()?; + Ok(()) +} + +/// Run the local two-stem owner. Hosted selections are validated here but must +/// be executed by `opentake-gen`, which owns credentials and network transport. +pub fn separate_stems( + request: StemSeparationRequest<'_>, + cancel: &MediaCancelToken, + progress: Option, +) -> Result { + if cancel.checkpoint() { + return Err(MediaError::Cancelled); + } + report(&progress, 0); + let model = match &request.execution { + StemExecution::Local { model_dir } => ensure_local_stem_model(model_dir)?, + StemExecution::Hosted { provider, model } => { + if provider.trim().is_empty() || model.trim().is_empty() { + return Err(MediaError::ModelInstall( + "stem_hosted_provider_and_model_required".to_string(), + )); + } + return Err(MediaError::ModelInstall(format!( + "stem_hosted_execution_requires_configured_provider:{provider}:{model}" + ))); + } + }; + report(&progress, 80); + let source_sha256 = digest_file(request.source, Some(cancel))?; + report(&progress, 160); + let spec = PcmSpec { + sample_rate: SAMPLE_RATE, + channels: CHANNELS, + format: PcmFormat::F32, + }; + let input = decode_pcm_interleaved_cancellable(request.source, &spec, None, cancel)?; + if !input.len().is_multiple_of(usize::from(CHANNELS)) { + return Err(MediaError::Decode( + "stem_input_interleaving_invalid".to_string(), + )); + } + report(&progress, 320); + + let mut vocals = Vec::new(); + let mut accompaniment = Vec::new(); + vocals + .try_reserve_exact(input.len()) + .map_err(|error| MediaError::Decode(format!("stem_audio_allocation_failed: {error}")))?; + accompaniment + .try_reserve_exact(input.len()) + .map_err(|error| MediaError::Decode(format!("stem_audio_allocation_failed: {error}")))?; + let mut side_energy = 0.0_f64; + for (index, frame) in input.chunks_exact(2).enumerate() { + if index.is_multiple_of(8 * 1024) { + if cancel.checkpoint() { + return Err(MediaError::Cancelled); + } + let completed = 320 + index.saturating_mul(360) / (input.len() / 2).max(1); + report(&progress, completed); + } + let centre = (frame[0] + frame[1]) * 0.5; + let side = (frame[0] - frame[1]) * 0.5; + vocals.extend_from_slice(&[centre, centre]); + accompaniment.extend_from_slice(&[side, side]); + side_energy += f64::from(side) * f64::from(side); + } + report(&progress, 700); + + fs::create_dir_all(request.output_dir)?; + let base = safe_source_stem(request.source); + let identity = &source_sha256[..12]; + let vocals_path = request + .output_dir + .join(format!("{base}-{identity}-vocals.wav")); + let accompaniment_path = request + .output_dir + .join(format!("{base}-{identity}-accompaniment.wav")); + let vocals_partial = vocals_path.with_extension("vocals.wav.partial"); + let accompaniment_partial = accompaniment_path.with_extension("accompaniment.wav.partial"); + + for path in [ + &vocals_partial, + &accompaniment_partial, + &vocals_path, + &accompaniment_path, + ] { + if path.exists() { + return Err(MediaError::Encode(format!( + "stem_output_already_exists: {}", + path.display() + ))); + } + } + let publish = (|| -> Result<()> { + write_wav_stereo(&vocals_partial, &vocals, cancel)?; + report(&progress, 820); + write_wav_stereo(&accompaniment_partial, &accompaniment, cancel)?; + report(&progress, 920); + if cancel.checkpoint() { + return Err(MediaError::Cancelled); + } + fs::rename(&vocals_partial, &vocals_path)?; + fs::rename(&accompaniment_partial, &accompaniment_path)?; + Ok(()) + })(); + if publish.is_err() { + for path in [ + &vocals_partial, + &accompaniment_partial, + &vocals_path, + &accompaniment_path, + ] { + let _ = fs::remove_file(path); + } + } + publish?; + report(&progress, PROGRESS_TOTAL); + + let removed_cross_talk = if side_energy <= f64::EPSILON { + 60.0 + } else { + // The centre output has a mathematically zero side component. Bound the + // metric to a useful telemetry range instead of reporting infinity. + (10.0 * (side_energy / (side_energy * 1.0e-6)).log10()).clamp(0.0, 60.0) + }; + Ok(StemSeparationResult { + vocals: StemOutput { + path: vocals_path, + name: format!("{base} Vocals"), + }, + accompaniment: StemOutput { + path: accompaniment_path, + name: format!("{base} Accompaniment"), + }, + provenance: StemProvenance { + source_sha256, + execution: format!("local:{}", model.id), + model_sha256: Some(model.sha256), + }, + metrics: StemMetrics { + vocal_sdr_improvement_db: removed_cross_talk, + }, + }) +} diff --git a/crates/opentake-media/src/cache_key.rs b/crates/opentake-media/src/cache_key.rs index 969a1845..fa080959 100644 --- a/crates/opentake-media/src/cache_key.rs +++ b/crates/opentake-media/src/cache_key.rs @@ -104,6 +104,14 @@ fn sha256_hex_prefix(seed: &str, prefix_chars: usize) -> String { hex } +/// Parse a `ryu-js` explicit exponent (`e` suffix of the closest-shortest +/// digits). `ryu-js` always emits a numeric exponent today; a future format +/// change must not panic production code, so malformed input falls back to a +/// zero exponent (cache keys only need to be stable within this binary). +fn parse_exponent(exponent: &str) -> i32 { + exponent.parse().unwrap_or(0) +} + /// Render `v` using Swift `Double.description`'s closest-shortest digits and /// finishing policy. ECMAScript uses the same closest-shortest tie-breaking; /// `ryu-js` provides those digits, after which we apply Swift's exponential @@ -131,10 +139,7 @@ fn swift_double(v: f64) -> String { let mut buffer = ryu_js::Buffer::new(); let shortest = buffer.format(magnitude); let (mantissa, explicit_exponent) = match shortest.split_once('e') { - Some((mantissa, exponent)) => ( - mantissa, - exponent.parse::().expect("ryu-js exponent is numeric"), - ), + Some((mantissa, exponent)) => (mantissa, parse_exponent(exponent)), None => (shortest, 0), }; let fractional_digits = mantissa @@ -196,6 +201,16 @@ mod tests { use super::*; use std::io::Write; + #[test] + fn malformed_exponent_falls_back_to_zero() { + assert_eq!(parse_exponent("7"), 7); + assert_eq!(parse_exponent("-3"), -3); + assert_eq!(parse_exponent("+2"), 2); + assert_eq!(parse_exponent(""), 0); + assert_eq!(parse_exponent("abc"), 0); + assert_eq!(parse_exponent("1e999"), 0); + } + #[test] fn identity_hex_is_stable_and_lowercase() { let a = identity_hex("/a/b.mp4", 1000.0, 42); diff --git a/crates/opentake-media/src/color.rs b/crates/opentake-media/src/color.rs new file mode 100644 index 00000000..f5133c0b --- /dev/null +++ b/crates/opentake-media/src/color.rs @@ -0,0 +1,114 @@ +//! Explicit source-color policy for the current SDR compositor. +//! +//! OpenTake retains the source signalling in the media manifest. PQ/HLG video +//! is converted to BT.709 before it becomes RGBA8 so seek-preview, continuous +//! playback and export all see the same display-referred pixels. This is an SDR +//! delivery policy, not an HDR passthrough claim. + +use opentake_domain::MediaColorMetadata; +use std::process::Command; +use std::sync::OnceLock; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum HdrDecodeBackend { + VideoToolbox, + Zscale, + Unsupported, +} + +fn reports_filter(output: &str, expected: &str) -> bool { + output.lines().any(|line| { + let mut fields = line.split_whitespace(); + let _flags = fields.next(); + fields.next() == Some(expected) + }) +} + +fn backend_from_filter_listing(output: &str) -> HdrDecodeBackend { + if cfg!(target_os = "macos") && reports_filter(output, "scale_vt") { + HdrDecodeBackend::VideoToolbox + } else if reports_filter(output, "zscale") { + HdrDecodeBackend::Zscale + } else { + HdrDecodeBackend::Unsupported + } +} + +fn hdr_decode_backend() -> HdrDecodeBackend { + static BACKEND: OnceLock = OnceLock::new(); + *BACKEND.get_or_init(|| { + let output = Command::new(crate::ff::ffmpeg_path()) + .args(["-hide_banner", "-filters"]) + .output(); + let Ok(output) = output else { + return HdrDecodeBackend::Unsupported; + }; + let mut listing = String::from_utf8_lossy(&output.stdout).into_owned(); + listing.push_str(&String::from_utf8_lossy(&output.stderr)); + backend_from_filter_listing(&listing) + }) +} + +/// FFmpeg filter chain for an HDR source entering the SDR RGBA compositor. +/// Tokens are selected from a fixed allowlist; untrusted probe strings are +/// never interpolated into a filter expression. +pub fn hdr_tonemap_filter(color: &MediaColorMetadata) -> Option { + let transfer = color.transfer.as_deref()?.to_ascii_lowercase(); + let input_transfer = match transfer.as_str() { + "smpte2084" | "pq" => "smpte2084", + "arib-std-b67" | "hlg" => "arib-std-b67", + _ => return None, + }; + match hdr_decode_backend() { + HdrDecodeBackend::VideoToolbox => { + // When the active macOS FFmpeg exposes scale_vt, VideoToolbox + // performs the metadata-driven EDR→SDR conversion in the hardware + // scaler. p010le is the supported hwdownload bridge before the + // ordinary software RGBA pipeline resumes. + Some( + "scale_vt=w=iw:h=ih:color_matrix=bt709:color_primaries=bt709:color_transfer=bt709,hwdownload,format=p010le" + .to_string(), + ) + } + HdrDecodeBackend::Zscale => Some(format!( + "zscale=pin=bt2020:tin={input_transfer}:min=bt2020nc:rin=limited:t=linear:npl=100,format=gbrpf32le,tonemap=mobius:param=0.3:desat=2,zscale=p=bt709:t=bt709:m=bt709:r=limited" + )), + HdrDecodeBackend::Unsupported => None, + } +} + +/// Decoder input arguments required by the platform HDR conversion path. +pub fn hdr_decode_input_args(color: &MediaColorMetadata) -> Vec { + if color.is_hdr() && hdr_decode_backend() == HdrDecodeBackend::VideoToolbox { + vec![ + "-hwaccel".into(), + "videotoolbox".into(), + "-hwaccel_output_format".into(), + "videotoolbox_vld".into(), + ] + } else { + Vec::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn packaged_zscale_listing_never_selects_unavailable_videotoolbox_filter() { + let listing = " .S. tonemap V->V Conversion\n .SC zscale V->V Apply resizing"; + assert_eq!( + backend_from_filter_listing(listing), + HdrDecodeBackend::Zscale + ); + } + + #[test] + fn missing_hdr_filters_are_reported_as_unsupported() { + assert_eq!( + backend_from_filter_listing(" .. scale V->V Scale video"), + HdrDecodeBackend::Unsupported + ); + } +} diff --git a/crates/opentake-media/src/decode/frame.rs b/crates/opentake-media/src/decode/frame.rs index 3057e231..169ece8c 100644 --- a/crates/opentake-media/src/decode/frame.rs +++ b/crates/opentake-media/src/decode/frame.rs @@ -48,6 +48,316 @@ impl Default for FrameRequest { } } +/// Source-frame reconstruction policy used when a timeline requests frames at +/// a different rate than the decoded asset. Optical flow is a deterministic +/// local motion-compensated path; it never implies a cloud/model dependency. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum FrameInterpolationMode { + Nearest, + Blend, + OpticalFlow, +} + +/// Explicit recovery behavior when optical flow is unavailable on the current +/// device/runtime. The caller chooses quality, determinism, or fail-closed. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum FrameInterpolationFallback { + Nearest, + Blend, + Error, +} + +/// One target-rate sample mapped back into the source-frame interval. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct FrameRateSample { + pub timestamp_secs: f64, + pub source_frame: u64, + pub next_source_frame: u64, + pub source_alpha: f64, +} + +/// Result of one pair interpolation, including the effective mode after an +/// explicit unsupported-device fallback. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct FrameInterpolationResult { + pub frame: RgbaFrame, + pub mode_used: FrameInterpolationMode, +} + +/// Map a finite source sequence onto a target frame rate while preserving both +/// endpoint timestamps exactly. Interior timestamps follow the target-rate +/// grid; the final sample is pinned to the source's final presentation time. +pub fn convert_frame_rate( + source_frame_count: u64, + source_fps: f64, + target_fps: f64, +) -> Result> { + if source_frame_count == 0 { + return Err(MediaError::Decode( + "source_frame_count must be greater than zero".to_string(), + )); + } + if !source_fps.is_finite() || source_fps <= 0.0 { + return Err(MediaError::Decode( + "source_fps must be finite and greater than zero".to_string(), + )); + } + if !target_fps.is_finite() || target_fps <= 0.0 { + return Err(MediaError::Decode( + "target_fps must be finite and greater than zero".to_string(), + )); + } + if source_frame_count == 1 { + return Ok(vec![FrameRateSample { + timestamp_secs: 0.0, + source_frame: 0, + next_source_frame: 0, + source_alpha: 0.0, + }]); + } + + let source_last = source_frame_count - 1; + let duration_secs = source_last as f64 / source_fps; + let target_intervals = (duration_secs * target_fps).round().max(1.0) as u64; + let mut samples = Vec::with_capacity(target_intervals as usize + 1); + for output_frame in 0..=target_intervals { + let timestamp_secs = if output_frame == target_intervals { + duration_secs + } else { + (output_frame as f64 / target_fps).min(duration_secs) + }; + let source_position = (timestamp_secs * source_fps).clamp(0.0, source_last as f64); + let source_frame = source_position.floor() as u64; + let next_source_frame = source_frame.saturating_add(1).min(source_last); + let source_alpha = if source_frame == next_source_frame { + 0.0 + } else { + source_position - source_frame as f64 + }; + samples.push(FrameRateSample { + timestamp_secs, + source_frame, + next_source_frame, + source_alpha, + }); + } + Ok(samples) +} + +/// Interpolate two equal-size RGBA frames at `alpha` in `[0, 1]`. +/// +/// The optical-flow path estimates a deterministic local block-motion field, +/// warps both endpoints toward the requested instant, then blends the aligned +/// pixels. This traditional path is intentionally model-free and provides a +/// stable baseline for preview/export parity. +pub fn interpolate_frame_pair( + first: &RgbaFrame, + last: &RgbaFrame, + alpha: f64, + requested: FrameInterpolationMode, + fallback: FrameInterpolationFallback, + optical_flow_available: bool, +) -> Result { + if first.width != last.width + || first.height != last.height + || first.rgba.len() != last.rgba.len() + { + return Err(MediaError::Decode( + "interpolation frames must have identical dimensions".to_string(), + )); + } + if !alpha.is_finite() { + return Err(MediaError::Decode( + "interpolation alpha must be finite".to_string(), + )); + } + + let mode_used = if requested == FrameInterpolationMode::OpticalFlow && !optical_flow_available { + match fallback { + FrameInterpolationFallback::Nearest => FrameInterpolationMode::Nearest, + FrameInterpolationFallback::Blend => FrameInterpolationMode::Blend, + FrameInterpolationFallback::Error => { + return Err(MediaError::Decode( + "optical-flow interpolation is unavailable and fallback is Error".to_string(), + )); + } + } + } else { + requested + }; + + let alpha = alpha.clamp(0.0, 1.0); + let frame = if alpha == 0.0 { + first.clone() + } else if alpha == 1.0 { + last.clone() + } else { + match mode_used { + FrameInterpolationMode::Nearest => { + if alpha < 0.5 { + first.clone() + } else { + last.clone() + } + } + FrameInterpolationMode::Blend => blend_frames(first, last, alpha), + FrameInterpolationMode::OpticalFlow => optical_flow_frame(first, last, alpha), + } + }; + + Ok(FrameInterpolationResult { frame, mode_used }) +} + +fn blend_frames(first: &RgbaFrame, last: &RgbaFrame, alpha: f64) -> RgbaFrame { + let rgba = first + .rgba + .iter() + .zip(&last.rgba) + .map(|(&a, &b)| lerp_channel(a, b, alpha)) + .collect(); + RgbaFrame::new(first.width, first.height, rgba) +} + +fn optical_flow_frame(first: &RgbaFrame, last: &RgbaFrame, alpha: f64) -> RgbaFrame { + let flow = estimate_block_motion(first, last); + let mut rgba = vec![0; first.rgba.len()]; + for y in 0..first.height { + for x in 0..first.width { + let (motion_x, motion_y) = flow.at(x, y); + let x = x as f64; + let y = y as f64; + let from_first = sample_bilinear(first, x - alpha * motion_x, y - alpha * motion_y); + let from_last = sample_bilinear( + last, + x + (1.0 - alpha) * motion_x, + y + (1.0 - alpha) * motion_y, + ); + let offset = ((y as u32 * first.width + x as u32) * 4) as usize; + for channel in 0..4 { + rgba[offset + channel] = + lerp_channel(from_first[channel], from_last[channel], alpha); + } + } + } + RgbaFrame::new(first.width, first.height, rgba) +} + +struct BlockMotionField { + block_size: u32, + columns: u32, + rows: u32, + vectors: Vec<(f64, f64)>, +} + +impl BlockMotionField { + fn at(&self, x: u32, y: u32) -> (f64, f64) { + let column = (x / self.block_size).min(self.columns.saturating_sub(1)); + let row = (y / self.block_size).min(self.rows.saturating_sub(1)); + self.vectors[(row * self.columns + column) as usize] + } +} + +/// Estimate a deterministic local motion field with block matching. Bounded +/// search and per-block spatial sampling avoid the whole-frame distortion of a +/// single global translation vector without introducing a model dependency. +fn estimate_block_motion(first: &RgbaFrame, last: &RgbaFrame) -> BlockMotionField { + let shortest = first.width.min(first.height).max(1); + let block_size = shortest.min(32); + let columns = first.width.div_ceil(block_size); + let rows = first.height.div_ceil(block_size); + let search_radius = (block_size / 2).clamp(1, 12) as i32; + let sample_step = (block_size / 8).max(1); + let mut vectors = Vec::with_capacity((columns * rows) as usize); + + for row in 0..rows { + for column in 0..columns { + let start_x = column * block_size; + let start_y = row * block_size; + let end_x = (start_x + block_size).min(first.width); + let end_y = (start_y + block_size).min(first.height); + let mut best = (f64::INFINITY, i32::MAX, 0, 0); + for dy in -search_radius..=search_radius { + for dx in -search_radius..=search_radius { + let mut error = 0.0; + let mut samples = 0u32; + for y in (start_y..end_y).step_by(sample_step as usize) { + for x in (start_x..end_x).step_by(sample_step as usize) { + let target_x = x as i32 + dx; + let target_y = y as i32 + dy; + let target = if target_x < 0 + || target_y < 0 + || target_x >= last.width as i32 + || target_y >= last.height as i32 + { + 255.0 + } else { + luma_at(last, target_x as u32, target_y as u32) + }; + error += (luma_at(first, x, y) - target).abs(); + samples += 1; + } + } + let mean_error = error / samples.max(1) as f64; + let distance = dx * dx + dy * dy; + let candidate = (mean_error, distance, dy, dx); + if candidate < best { + best = candidate; + } + } + } + vectors.push((best.3 as f64, best.2 as f64)); + } + } + + BlockMotionField { + block_size, + columns, + rows, + vectors, + } +} + +fn luma_at(frame: &RgbaFrame, x: u32, y: u32) -> f64 { + let offset = ((y * frame.width + x) * 4) as usize; + let r = frame.rgba[offset] as f64; + let g = frame.rgba[offset + 1] as f64; + let b = frame.rgba[offset + 2] as f64; + let a = frame.rgba[offset + 3] as f64 / 255.0; + (0.2126 * r + 0.7152 * g + 0.0722 * b) * a +} + +fn sample_bilinear(frame: &RgbaFrame, x: f64, y: f64) -> [u8; 4] { + let x0 = x.floor() as i64; + let y0 = y.floor() as i64; + let fx = x - x0 as f64; + let fy = y - y0 as f64; + let mut out = [0; 4]; + for (channel, value) in out.iter_mut().enumerate() { + let p00 = sample_channel(frame, x0, y0, channel); + let p10 = sample_channel(frame, x0 + 1, y0, channel); + let p01 = sample_channel(frame, x0, y0 + 1, channel); + let p11 = sample_channel(frame, x0 + 1, y0 + 1, channel); + let top = p00 + (p10 - p00) * fx; + let bottom = p01 + (p11 - p01) * fx; + *value = (top + (bottom - top) * fy).round().clamp(0.0, 255.0) as u8; + } + out +} + +fn sample_channel(frame: &RgbaFrame, x: i64, y: i64, channel: usize) -> f64 { + if x < 0 || y < 0 || x >= frame.width as i64 || y >= frame.height as i64 { + return if channel == 3 { 255.0 } else { 0.0 }; + } + let offset = ((y as u32 * frame.width + x as u32) * 4) as usize; + frame.rgba[offset + channel] as f64 +} + +fn lerp_channel(first: u8, last: u8, alpha: f64) -> u8 { + (first as f64 + (last as f64 - first as f64) * alpha) + .round() + .clamp(0.0, 255.0) as u8 +} + /// Scale `(w, h)` down to fit within `max` while preserving aspect ratio. Never /// enlarges. A zero in either `max` dimension disables that bound. Mirrors /// `AVAssetImageGenerator.maximumSize` semantics ("not larger than this box, @@ -74,9 +384,21 @@ pub fn fit_within(w: u32, h: u32, max: (u32, u32)) -> (u32, u32) { /// Build the ffmpeg arg list for decoding one frame to rawvideo RGBA on stdout. /// Pure so the exact CLI contract is testable. +#[cfg(test)] fn frame_args(path: &Path, req: &FrameRequest) -> Vec { + frame_args_with_color(path, req, None) +} + +fn frame_args_with_color( + path: &Path, + req: &FrameRequest, + color: Option<&opentake_domain::MediaColorMetadata>, +) -> Vec { let seek = (req.time_secs - req.tolerance_secs).max(0.0); let mut args: Vec = Vec::new(); + if let Some(color) = color { + args.extend(crate::color::hdr_decode_input_args(color)); + } // Fast input seek to just before the target keyframe window. args.push("-ss".into()); args.push(format!("{seek:.6}")); @@ -87,6 +409,9 @@ fn frame_args(path: &Path, req: &FrameRequest) -> Vec { args.push("1".into()); let mut filters: Vec = Vec::new(); + if let Some(filter) = color.and_then(crate::color::hdr_tonemap_filter) { + filters.push(filter); + } if req.apply_rotation { // Honor the display matrix when transposing (ffmpeg applies it via the // autorotate behavior; the scale filter runs after rotation). @@ -134,8 +459,17 @@ pub fn decode_frame_at_cancellable( if cancel.is_cancelled() { return Err(MediaError::Cancelled); } + // Probe color only for ordinary files. FIFOs/device inputs are valid FFmpeg + // sources too; opening them once for ffprobe would consume or block the + // stream before the actual cancellable decoder child is spawned. + let color = path + .metadata() + .ok() + .filter(|metadata| metadata.is_file()) + .and_then(|_| crate::probe::probe(path).ok()) + .and_then(|probe| probe.color); let mut child = ff::ffmpeg() - .args(frame_args(path, req)) + .args(frame_args_with_color(path, req, color.as_ref())) .spawn() .map_err(|e| MediaError::Ffmpeg(format!("spawn: {e}")))?; cancel.child_spawned(); diff --git a/crates/opentake-media/src/decode/mod.rs b/crates/opentake-media/src/decode/mod.rs index 34cbdec5..d0fe7653 100644 --- a/crates/opentake-media/src/decode/mod.rs +++ b/crates/opentake-media/src/decode/mod.rs @@ -8,8 +8,9 @@ pub mod stream; pub use audio_stream::{decode_pcm_interleaved, decode_pcm_interleaved_cancellable}; pub use frame::{ - decode_frame_at, decode_frame_at_cancellable, decode_frames_at, decode_frames_at_cancellable, - fit_within, FrameRequest, + convert_frame_rate, decode_frame_at, decode_frame_at_cancellable, decode_frames_at, + decode_frames_at_cancellable, fit_within, interpolate_frame_pair, FrameInterpolationFallback, + FrameInterpolationMode, FrameInterpolationResult, FrameRateSample, FrameRequest, }; pub use pcm::{ extract_pcm, extract_pcm_cancellable, extract_pcm_cancellable_with_progress, PcmBuffer, diff --git a/crates/opentake-media/src/decode/stream.rs b/crates/opentake-media/src/decode/stream.rs index 1a03bd80..b20b3fb6 100644 --- a/crates/opentake-media/src/decode/stream.rs +++ b/crates/opentake-media/src/decode/stream.rs @@ -182,7 +182,10 @@ fn run_video_stream( tx: SyncSender>, control: StreamDecodeControl, ) { - let args = video_stream_args(&req); + let color = crate::probe::probe(&req.path) + .ok() + .and_then(|probe| probe.color); + let args = video_stream_args_with_color(&req, color.as_ref()); let mut child = match ff::ffmpeg().args(args).spawn() { Ok(child) => child, Err(e) => { @@ -275,10 +278,21 @@ fn frame_to_secs(frame: i64, fps: i32) -> f64 { frame.max(0) as f64 / fps.max(1) as f64 } +#[cfg(test)] fn video_stream_args(req: &VideoStreamRequest) -> Vec { + video_stream_args_with_color(req, None) +} + +fn video_stream_args_with_color( + req: &VideoStreamRequest, + color: Option<&opentake_domain::MediaColorMetadata>, +) -> Vec { let mut args = Vec::new(); args.push("-ss".to_string()); args.push(format!("{:.6}", req.start_secs())); + if let Some(color) = color { + args.extend(crate::color::hdr_decode_input_args(color)); + } if !req.apply_rotation { args.push("-noautorotate".to_string()); } @@ -294,7 +308,11 @@ fn video_stream_args(req: &VideoStreamRequest) -> Vec { args.push(frame_limit.to_string()); } - let mut filters = vec![format!("fps=fps={}", req.timeline_fps)]; + let mut filters = Vec::new(); + if let Some(filter) = color.and_then(crate::color::hdr_tonemap_filter) { + filters.push(filter); + } + filters.push(format!("fps=fps={}", req.timeline_fps)); if req.max_size.0 > 0 || req.max_size.1 > 0 { let mw = if req.max_size.0 > 0 { req.max_size.0.to_string() diff --git a/crates/opentake-media/src/encode/mix.rs b/crates/opentake-media/src/encode/mix.rs index d06bc132..beb8b01f 100644 --- a/crates/opentake-media/src/encode/mix.rs +++ b/crates/opentake-media/src/encode/mix.rs @@ -20,6 +20,25 @@ /// The canonical mixdown sample rate. 48 kHz is the export-audio standard and /// what the encoder requests from ffmpeg for the muxed AAC/LPCM track. pub const MIX_SAMPLE_RATE: u32 = 48_000; +/// Headroom reserved below a requested true-peak ceiling for reconstruction +/// overshoot introduced by lossy codecs such as AAC. Two dB is intentional: +/// the release export's AAC encoder reconstructed the speech acceptance fixture +/// 1.77 dB above the clamped PCM sample peak, so a one-dB margin did not keep +/// the encoded deliverable below the user-selected ceiling. +pub const TRUE_PEAK_CODEC_SAFETY_DB: f64 = 2.0; + +/// Apply the shared preview/export ceiling stage in-place. `None` preserves the +/// legacy rail clamp performed by the surrounding mixer. +pub fn apply_true_peak_ceiling(samples: &mut [f32], ceiling_dbtp: Option) { + let Some(ceiling_dbtp) = ceiling_dbtp.filter(|value| value.is_finite()) else { + return; + }; + let ceiling = 10.0_f32 + .powf(((ceiling_dbtp - TRUE_PEAK_CODEC_SAFETY_DB).clamp(-120.0, 0.0) / 20.0) as f32); + for sample in samples { + *sample = sample.clamp(-ceiling, ceiling); + } +} /// One audio clip's contribution to the mix: a mono f32 source window plus the /// per-sample gain to apply, laid down starting at `start_sample` on the shared @@ -192,6 +211,16 @@ mod tests { assert_eq!(mix_clips(&[c]).unwrap(), vec![0.0, 0.5, 1.0]); } + #[test] + fn true_peak_ceiling_reserves_codec_reconstruction_margin() { + let mut samples = vec![1.0, -1.0, 0.25]; + apply_true_peak_ceiling(&mut samples, Some(-1.0)); + let expected = 10.0_f32.powf(-3.0 / 20.0); + assert!((samples[0] - expected).abs() < 1e-6); + assert!((samples[1] + expected).abs() < 1e-6); + assert_eq!(samples[2], 0.25); + } + #[test] fn static_gain_helper_skips_envelope_at_unity() { let c = ClipAudio::with_static_gain(0, vec![0.4, 0.4], 1.0); diff --git a/crates/opentake-media/src/encode/mod.rs b/crates/opentake-media/src/encode/mod.rs index 9b871006..8ee4ef72 100644 --- a/crates/opentake-media/src/encode/mod.rs +++ b/crates/opentake-media/src/encode/mod.rs @@ -21,7 +21,7 @@ use std::thread::{self, JoinHandle}; use std::time::Duration; use crate::cancel::MediaCancelToken; -use crate::decode::pcm::PcmBuffer; +use crate::decode::pcm::{PcmBuffer, PcmFormat, PcmSpec}; use crate::error::{MediaError, Result}; use crate::frame::RgbaFrame; @@ -50,6 +50,10 @@ fn encode_args(out: &Path, w: u32, h: u32, fps: i32, preset: &ExportPreset) -> V args.push(preset.vcodec_arg().into()); args.push("-pix_fmt".into()); args.push(preset.pix_fmt_arg().into()); + if preset.codec == VideoCodec::ProRes4444 { + args.push("-profile:v".into()); + args.push("4444".into()); + } args.extend(preset.color_args()); args.push(out.to_string_lossy().into_owned()); @@ -117,10 +121,16 @@ pub struct VideoEncoder { first_pass: PathBuf, output: File, acodec: &'static str, - pending_audio: Option, + pending_audio: Option, child_reaped: bool, } +struct PendingAudio { + path: PathBuf, + spec: PcmSpec, + sample_count: u64, +} + impl VideoEncoder { /// Start an encoder writing to `out`. `w`/`h` must already be even. pub fn new(out: &Path, w: u32, h: u32, fps: i32, preset: &ExportPreset) -> Result { @@ -149,7 +159,7 @@ impl VideoEncoder { ::from_mode(0o700), ) .map_err(MediaError::Io)?; - let extension = if preset.codec == VideoCodec::ProRes422 { + let extension = if matches!(preset.codec, VideoCodec::ProRes422 | VideoCodec::ProRes4444) { "mov" } else { "mp4" @@ -229,16 +239,81 @@ impl VideoEncoder { Ok(()) } - /// Record the mixed-down mono audio buffer to mux on `finish`. The buffer's - /// `spec.sample_rate` is the rate ffmpeg is told to read the muxed PCM at - /// (the orchestrator decodes/mixes at [`MIX_SAMPLE_RATE`]). An empty buffer - /// is ignored — `finish` then keeps the video-only output. - pub fn push_audio(&mut self, pcm: PcmBuffer) { + /// Record one complete mixed-down mono audio buffer. Internally this uses + /// the same file-backed chunk sink as long-timeline export, so the encoder + /// never retains the caller's `Vec` until `finish`. + pub fn push_audio(&mut self, pcm: PcmBuffer) -> Result<()> { + if let Some(pending) = self.pending_audio.take() { + let _ = std::fs::remove_file(pending.path); + } if pcm.samples_f32.is_empty() { - self.pending_audio = None; + return Ok(()); + } + self.push_audio_chunk(pcm.spec, &pcm.samples_f32, &MediaCancelToken::new()) + } + + /// Append one bounded mono f32 chunk to the private PCM spool used by the + /// final mux. The spool is file-backed, cancellable, and requires every + /// chunk to keep the same PCM contract. + pub fn push_audio_chunk( + &mut self, + spec: PcmSpec, + samples: &[f32], + cancel: &MediaCancelToken, + ) -> Result<()> { + if samples.is_empty() { + return Ok(()); + } + if spec.channels != 1 || spec.format != PcmFormat::F32 || spec.sample_rate == 0 { + return Err(MediaError::Encode( + "streamed audio must be mono f32 at a positive sample rate".to_string(), + )); + } + let path = self.workspace.path().join("audio.pcm"); + let mut output = if let Some(pending) = &self.pending_audio { + if pending.spec != spec { + return Err(MediaError::Encode( + "streamed audio format changed between chunks".to_string(), + )); + } + OpenOptions::new() + .append(true) + .open(&pending.path) + .map_err(MediaError::Io)? } else { - self.pending_audio = Some(pcm); + OpenOptions::new() + .write(true) + .create_new(true) + .open(&path) + .map_err(MediaError::Io)? + }; + for chunk in samples.chunks(OUTPUT_COPY_CHUNK / 2) { + if cancel.checkpoint() { + return Err(MediaError::Cancelled); + } + output + .write_all(&mix::mono_f32_to_s16le(chunk)) + .map_err(MediaError::Io)?; } + output.flush().map_err(MediaError::Io)?; + let appended = u64::try_from(samples.len()) + .map_err(|_| MediaError::Encode("streamed audio sample count overflow".to_string()))?; + match &mut self.pending_audio { + Some(pending) => { + pending.sample_count = + pending.sample_count.checked_add(appended).ok_or_else(|| { + MediaError::Encode("streamed audio sample count overflow".to_string()) + })?; + } + None => { + self.pending_audio = Some(PendingAudio { + path, + spec, + sample_count: appended, + }); + } + } + Ok(()) } /// Abort a mid-stream encode (e.g. a user cancel): kill the ffmpeg child and @@ -276,7 +351,7 @@ impl VideoEncoder { report_progress(progress, FIRST_PASS_END); match self.pending_audio.take() { - Some(pcm) => self.mux_audio(&pcm, cancel, progress, mux_wait_hook)?, + Some(audio) => self.mux_audio(&audio, cancel, progress, mux_wait_hook)?, None => self.copy_video_only(cancel, progress)?, }; if cancel.checkpoint() { @@ -397,20 +472,11 @@ impl VideoEncoder { fn mux_audio( &mut self, - pcm: &PcmBuffer, + audio: &PendingAudio, cancel: &MediaCancelToken, progress: Option<&EncodeProgressCallback>, mux_wait_hook: Option<&dyn Fn()>, ) -> Result<()> { - let pcm_path = self.workspace.path().join("audio.pcm"); - let mut pcm_tmp = OpenOptions::new() - .read(true) - .write(true) - .create_new(true) - .open(&pcm_path) - .map_err(MediaError::Io)?; - write_pcm_s16le_cancellable(&pcm.samples_f32, &mut pcm_tmp, cancel, progress, None)?; - pcm_tmp.flush().map_err(MediaError::Io)?; report_progress(progress, PCM_WRITE_END); let mux_path = self.workspace.path().join( @@ -427,9 +493,9 @@ impl VideoEncoder { ); let args = mux_args( &self.first_pass, - &pcm_path, + &audio.path, &mux_path, - pcm.spec.sample_rate, + audio.spec.sample_rate, self.acodec, ); let mut child = crate::ff::ffmpeg() @@ -572,6 +638,7 @@ fn drain_stderr(mut stderr: ChildStderr) -> Result<()> { } } +#[cfg(test)] fn write_pcm_s16le_cancellable( samples: &[f32], destination: &mut File, @@ -679,6 +746,36 @@ mod tests { .is_ok_and(|status| status.success()) } + #[test] + fn audio_chunks_spool_incrementally_without_retaining_the_timeline_mix() { + assert!(crate::ff::ffmpeg_available(), "test requires FFmpeg"); + let temp = tempfile::tempdir().unwrap(); + let output = temp.path().join("chunked.mp4"); + let preset = ExportPreset::new(VideoCodec::H264, ExportResolution::P720); + let mut encoder = VideoEncoder::new(&output, 2, 2, 1, &preset).unwrap(); + encoder + .push_frame(&RgbaFrame::new(2, 2, vec![0; 2 * 2 * 4])) + .unwrap(); + let spec = PcmSpec { + sample_rate: 48_000, + channels: 1, + format: PcmFormat::F32, + }; + let cancel = MediaCancelToken::new(); + encoder + .push_audio_chunk(spec, &[0.25, -0.25], &cancel) + .unwrap(); + encoder + .push_audio_chunk(spec, &[0.5, -0.5], &cancel) + .unwrap(); + let pending = encoder.pending_audio.as_ref().unwrap(); + assert_eq!(pending.sample_count, 4); + assert_eq!(std::fs::metadata(&pending.path).unwrap().len(), 8); + + encoder.finish().unwrap(); + assert!(output.is_file()); + } + #[cfg(unix)] #[test] fn dropping_live_encoder_reaps_child_and_releases_output() { @@ -747,13 +844,24 @@ mod tests { } #[test] - fn encode_args_prores_pixfmt_and_no_color_tag() { + fn encode_args_prores_pixfmt_and_bt709_delivery_tags() { let preset = ExportPreset::new(VideoCodec::ProRes422, ExportResolution::P2160); let args = encode_args(Path::new("/o.mov"), 3840, 2160, 30, &preset); assert!(args.windows(2).any(|w| w == ["-c:v", "prores_ks"])); assert!(args.windows(2).any(|w| w == ["-pix_fmt", "yuv422p10le"])); - // ProRes path does not add BT.709 color tags here. - assert!(!args.windows(2).any(|w| w == ["-colorspace", "bt709"])); + assert!(args.windows(2).any(|w| w == ["-colorspace", "bt709"])); + assert!(args + .iter() + .any(|arg| arg.contains("setparams=color_primaries=bt709"))); + } + + #[test] + fn encode_args_prores_4444_preserve_alpha() { + let preset = ExportPreset::new(VideoCodec::ProRes4444, ExportResolution::P1080); + let args = encode_args(Path::new("/matte.mov"), 1920, 1080, 30, &preset); + assert!(args.windows(2).any(|w| w == ["-c:v", "prores_ks"])); + assert!(args.windows(2).any(|w| w == ["-pix_fmt", "yuva444p10le"])); + assert!(args.windows(2).any(|w| w == ["-profile:v", "4444"])); } #[test] diff --git a/crates/opentake-media/src/encode/preset.rs b/crates/opentake-media/src/encode/preset.rs index 8c669cd5..3ef0e75a 100644 --- a/crates/opentake-media/src/encode/preset.rs +++ b/crates/opentake-media/src/encode/preset.rs @@ -9,6 +9,8 @@ pub enum VideoCodec { H264, H265, ProRes422, + /// ProRes 4444 with an alpha plane for local generated derivatives. + ProRes4444, } /// Short-edge target resolution. @@ -48,6 +50,7 @@ impl ExportPreset { VideoCodec::H264 => "libx264", VideoCodec::H265 => "libx265", VideoCodec::ProRes422 => "prores_ks", + VideoCodec::ProRes4444 => "prores_ks", } } @@ -55,7 +58,7 @@ impl ExportPreset { /// AAC (upstream presets). pub fn acodec_arg(&self) -> &'static str { match self.codec { - VideoCodec::ProRes422 => "pcm_s16le", + VideoCodec::ProRes422 | VideoCodec::ProRes4444 => "pcm_s16le", _ => "aac", } } @@ -65,24 +68,27 @@ impl ExportPreset { pub fn pix_fmt_arg(&self) -> &'static str { match self.codec { VideoCodec::ProRes422 => "yuv422p10le", + VideoCodec::ProRes4444 => "yuva444p10le", _ => "yuv420p", } } - /// BT.709 color-tagging args (primaries/transfer/matrix), applied for the - /// H.26x lossy codecs to match upstream's locked BT.709 pipeline. + /// BT.709 delivery tagging. `setparams` writes all three properties onto + /// every frame before the encoder sees it; the stream flags are retained as + /// an explicit container/codec request. This combination is required by + /// current FFmpeg/libx264, where stream flags alone leave primaries and + /// transfer as `unknown` in the produced bitstream. pub fn color_args(&self) -> Vec { - match self.codec { - VideoCodec::ProRes422 => vec![], - _ => vec![ - "-colorspace".into(), - "bt709".into(), - "-color_primaries".into(), - "bt709".into(), - "-color_trc".into(), - "bt709".into(), - ], - } + vec![ + "-vf".into(), + "setparams=color_primaries=bt709:color_trc=bt709:colorspace=bt709".into(), + "-colorspace".into(), + "bt709".into(), + "-color_primaries".into(), + "bt709".into(), + "-color_trc".into(), + "bt709".into(), + ] } } @@ -116,10 +122,15 @@ mod tests { assert_eq!(prores.vcodec_arg(), "prores_ks"); assert_eq!(prores.acodec_arg(), "pcm_s16le"); // LPCM assert_eq!(prores.pix_fmt_arg(), "yuv422p10le"); + + let alpha = ExportPreset::new(VideoCodec::ProRes4444, ExportResolution::P1080); + assert_eq!(alpha.vcodec_arg(), "prores_ks"); + assert_eq!(alpha.acodec_arg(), "pcm_s16le"); + assert_eq!(alpha.pix_fmt_arg(), "yuva444p10le"); } #[test] - fn h26x_get_bt709_color_args_prores_does_not() { + fn every_delivery_codec_gets_bt709_frame_and_stream_tags() { let h265 = ExportPreset::new(VideoCodec::H265, ExportResolution::P720); let args = h265.color_args(); assert!(args.windows(2).any(|w| w == ["-colorspace", "bt709"])); @@ -127,7 +138,10 @@ mod tests { assert!(args.windows(2).any(|w| w == ["-color_trc", "bt709"])); let prores = ExportPreset::new(VideoCodec::ProRes422, ExportResolution::P720); - assert!(prores.color_args().is_empty()); + assert!(prores + .color_args() + .iter() + .any(|arg| arg.contains("setparams=color_primaries=bt709"))); } #[test] diff --git a/crates/opentake-media/src/ff.rs b/crates/opentake-media/src/ff.rs index 4bfcb91b..a600ce28 100644 --- a/crates/opentake-media/src/ff.rs +++ b/crates/opentake-media/src/ff.rs @@ -1,4 +1,5 @@ -//! Thin internal helpers for driving the system `ffmpeg`/`ffprobe` binaries. +//! Thin internal helpers for driving bundled or development `ffmpeg`/`ffprobe` +//! binaries. //! //! We deliberately do **not** link libav*: the local toolchain is ffmpeg 8.1 //! (libavcodec 62) which the C-binding crates do not support, and pkg-config is @@ -6,23 +7,718 @@ //! binary discovery and one-shot ffprobe JSON queries so the higher-level decode //! modules stay readable. //! -//! Environment overrides `OPENTAKE_FFMPEG` / `OPENTAKE_FFPROBE` let callers (and -//! packaged builds) point at a bundled binary. +//! Packaged binaries live beside the OpenTake executable. Environment overrides +//! `OPENTAKE_FFMPEG` / `OPENTAKE_FFPROBE` remain available to tests and +//! development tools; the desktop shell pins them to the bundled sidecars before +//! media initialization. use std::ffi::OsString; +use std::future::Future; use std::io::{Seek, SeekFrom}; -use std::process::{Command, Stdio}; +use std::path::{Path, PathBuf}; +use std::process::{Command, ExitStatus, Stdio}; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::mpsc::{Receiver, SyncSender}; +use std::sync::OnceLock; +use std::time::{Duration, Instant}; use ffmpeg_sidecar::command::FfmpegCommand; +use tokio::io::{AsyncRead, AsyncReadExt}; +use tokio::process::Command as TokioCommand; +use tokio::task::{JoinHandle, JoinSet, LocalSet}; +use tokio::time::{Instant as TokioInstant, MissedTickBehavior}; -/// Path to the `ffmpeg` binary: `$OPENTAKE_FFMPEG`, else `ffmpeg` on `PATH`. +const FFPROBE_TIMEOUT: Duration = Duration::from_secs(30); +const FFPROBE_CAPTURE_MAX: usize = 8 * 1024 * 1024; +const FFPROBE_POLL_INTERVAL: Duration = Duration::from_millis(10); +const FFPROBE_READ_BUFFER_SIZE: usize = 16 * 1024; +const FFPROBE_MAX_IN_FLIGHT: usize = 8; +const FFPROBE_CLEANUP_MAX: Duration = Duration::from_millis(250); + +struct FfprobeOutput { + status: ExitStatus, + stdout: Vec, +} + +enum ProbeCaptureError { + LimitReached, + Read(std::io::Error), +} + +enum ProbeCapture { + Stdout(Result, ProbeCaptureError>), + Stderr(Result, ProbeCaptureError>), +} + +enum ProbeInput { + Path(PathBuf), + File(std::fs::File), +} + +struct ProbeSpec { + executable: OsString, + input: ProbeInput, + cancel: crate::MediaCancelToken, + operation_deadline: Instant, + api_deadline: Instant, +} + +enum ProbeExecutorRequest { + Run { + spec: ProbeSpec, + response: SyncSender>, + }, + Available { + executable: OsString, + operation_deadline: Instant, + api_deadline: Instant, + response: SyncSender>, + }, + #[cfg(test)] + Seam { + operation_deadline: Instant, + never_returns: bool, + response: SyncSender>, + }, +} + +struct ProbeExecutor { + sender: tokio::sync::mpsc::Sender, +} + +struct ProbeAdmission; + +static FFPROBE_ACTIVE: AtomicUsize = AtomicUsize::new(0); +static FFPROBE_EXECUTOR: OnceLock> = OnceLock::new(); + +impl ProbeAdmission { + fn acquire() -> crate::error::Result { + let mut active = FFPROBE_ACTIVE.load(Ordering::Acquire); + loop { + if active >= FFPROBE_MAX_IN_FLIGHT { + return Err(crate::error::MediaError::Ffmpeg( + "ffprobe admission limit reached".to_string(), + )); + } + match FFPROBE_ACTIVE.compare_exchange_weak( + active, + active + 1, + Ordering::AcqRel, + Ordering::Acquire, + ) { + Ok(_) => return Ok(Self), + Err(observed) => active = observed, + } + } + } +} + +impl Drop for ProbeAdmission { + fn drop(&mut self) { + FFPROBE_ACTIVE.fetch_sub(1, Ordering::AcqRel); + } +} + +async fn read_probe_capture( + mut pipe: impl AsyncRead + Unpin, +) -> Result, ProbeCaptureError> { + let mut bytes = Vec::new(); + let mut buffer = [0_u8; FFPROBE_READ_BUFFER_SIZE]; + loop { + match pipe.read(&mut buffer).await { + Ok(0) => return Ok(bytes), + Ok(read) => { + let remaining = FFPROBE_CAPTURE_MAX - bytes.len(); + if read >= remaining { + return Err(ProbeCaptureError::LimitReached); + } + bytes.extend_from_slice(&buffer[..read]); + } + Err(error) if error.kind() == std::io::ErrorKind::Interrupted => {} + Err(error) => return Err(ProbeCaptureError::Read(error)), + } + } +} + +fn finish_probe_capture( + stream: &str, + capture: Result, ProbeCaptureError>, +) -> crate::error::Result> { + match capture { + Ok(bytes) => Ok(bytes), + Err(ProbeCaptureError::LimitReached) => Err(crate::error::MediaError::Ffmpeg(format!( + "ffprobe {stream} exceeded its bounded capture" + ))), + Err(ProbeCaptureError::Read(error)) => Err(crate::error::MediaError::Ffmpeg(format!( + "ffprobe {stream} capture read: {error}" + ))), + } +} + +fn timeout_error() -> crate::error::MediaError { + crate::error::MediaError::Ffmpeg("ffprobe timed out".to_string()) +} + +fn probe_deadlines(timeout: Duration) -> crate::error::Result<(Instant, Instant)> { + let now = Instant::now(); + let api_deadline = now.checked_add(timeout).ok_or_else(|| { + crate::error::MediaError::Ffmpeg("ffprobe timeout is too large".to_string()) + })?; + let cleanup_reserve = (timeout / 4).min(FFPROBE_CLEANUP_MAX); + let operation_deadline = api_deadline + .checked_sub(cleanup_reserve) + .unwrap_or(api_deadline); + Ok((operation_deadline, api_deadline)) +} + +fn terminate_tree(tree: &mut Option) { + let Some(tree) = tree.as_mut() else { + return; + }; + if tree.terminate().is_ok() { + tree.disarm(); + } +} + +fn bounded_cleanup_deadline(api_deadline: TokioInstant) -> TokioInstant { + api_deadline.min(TokioInstant::now() + FFPROBE_CLEANUP_MAX) +} + +async fn finish_cleanup_before_deadline( + deadline: TokioInstant, + wait: W, + stdout: O, + stderr: E, +) -> bool +where + W: Future, + O: Future, + E: Future, +{ + tokio::time::timeout_at(deadline, async move { + let _ = tokio::join!(wait, stdout, stderr); + }) + .await + .is_ok() +} + +async fn terminate_running_ffprobe( + child: &mut tokio::process::Child, + tree: &mut Option, + stdout_worker: JoinHandle<()>, + stderr_worker: JoinHandle<()>, + deadline: TokioInstant, +) { + terminate_tree(tree); + let _ = child.start_kill(); + stdout_worker.abort(); + stderr_worker.abort(); + let _ = + finish_cleanup_before_deadline(deadline, child.wait(), stdout_worker, stderr_worker).await; +} + +async fn reap_uncontained_child(child: &mut tokio::process::Child, deadline: TokioInstant) { + let _ = child.start_kill(); + let _ = tokio::time::timeout_at(deadline, child.wait()).await; +} + +async fn ffprobe_available_async( + executable: OsString, + operation_deadline: Instant, + api_deadline: Instant, +) -> crate::error::Result { + let operation_deadline = TokioInstant::from_std(operation_deadline); + let api_deadline = TokioInstant::from_std(api_deadline); + if TokioInstant::now() >= operation_deadline { + return Err(timeout_error()); + } + let mut command = TokioCommand::new(executable); + command + .arg("-version") + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + crate::process_tree::configure_command(command.as_std_mut()); + command.kill_on_drop(true); + let mut child = command + .spawn() + .map_err(|error| crate::error::MediaError::Ffmpeg(format!("ffprobe spawn: {error}")))?; + let child_id = child.id().ok_or_else(|| { + crate::error::MediaError::Ffmpeg("ffprobe process id missing".to_string()) + })?; + let mut tree = match crate::process_tree::ProcessTree::attach(child_id) { + Ok(tree) => Some(tree), + Err(error) => { + reap_uncontained_child(&mut child, bounded_cleanup_deadline(api_deadline)).await; + return Err(crate::error::MediaError::Ffmpeg(format!( + "ffprobe containment: {error}" + ))); + } + }; + match tokio::time::timeout_at(operation_deadline, child.wait()).await { + Ok(Ok(status)) => { + terminate_tree(&mut tree); + Ok(status.success()) + } + Ok(Err(error)) => { + terminate_tree(&mut tree); + let _ = child.start_kill(); + let _ = + tokio::time::timeout_at(bounded_cleanup_deadline(api_deadline), child.wait()).await; + Err(crate::error::MediaError::Ffmpeg(format!( + "ffprobe wait: {error}" + ))) + } + Err(_) => { + terminate_tree(&mut tree); + let _ = child.start_kill(); + let _ = + tokio::time::timeout_at(bounded_cleanup_deadline(api_deadline), child.wait()).await; + Err(timeout_error()) + } + } +} + +async fn run_ffprobe_async(spec: ProbeSpec) -> crate::error::Result { + if spec.cancel.checkpoint() { + return Err(crate::error::MediaError::Cancelled); + } + let operation_deadline = TokioInstant::from_std(spec.operation_deadline); + let api_deadline = TokioInstant::from_std(spec.api_deadline); + if TokioInstant::now() >= operation_deadline { + return Err(timeout_error()); + } + + let mut command = TokioCommand::new(spec.executable); + command.args([ + "-v", + "quiet", + "-of", + "json", + "-show_streams", + "-show_format", + ]); + match spec.input { + ProbeInput::File(mut input) => { + input.seek(SeekFrom::Start(0)).map_err(|error| { + crate::error::MediaError::Ffmpeg(format!("ffprobe input rewind: {error}")) + })?; + command.arg("fd:").stdin(Stdio::from(input)); + } + ProbeInput::Path(path) => { + command.arg(path).stdin(Stdio::null()); + } + } + command.stdout(Stdio::piped()).stderr(Stdio::piped()); + crate::process_tree::configure_command(command.as_std_mut()); + command.kill_on_drop(true); + let mut child = command + .spawn() + .map_err(|error| crate::error::MediaError::Ffmpeg(format!("ffprobe spawn: {error}")))?; + spec.cancel.child_spawned(); + let child_id = child.id().ok_or_else(|| { + crate::error::MediaError::Ffmpeg("ffprobe process id missing".to_string()) + })?; + let mut tree = match crate::process_tree::ProcessTree::attach(child_id) { + Ok(tree) => Some(tree), + Err(error) => { + reap_uncontained_child(&mut child, bounded_cleanup_deadline(api_deadline)).await; + return Err(crate::error::MediaError::Ffmpeg(format!( + "ffprobe containment: {error}" + ))); + } + }; + + let Some(stdout) = child.stdout.take() else { + let empty_worker = tokio::task::spawn_local(async {}); + let second_empty_worker = tokio::task::spawn_local(async {}); + terminate_running_ffprobe( + &mut child, + &mut tree, + empty_worker, + second_empty_worker, + bounded_cleanup_deadline(api_deadline), + ) + .await; + return Err(crate::error::MediaError::Ffmpeg( + "ffprobe stdout pipe missing".to_string(), + )); + }; + let Some(stderr) = child.stderr.take() else { + let empty_worker = tokio::task::spawn_local(async {}); + let second_empty_worker = tokio::task::spawn_local(async {}); + terminate_running_ffprobe( + &mut child, + &mut tree, + empty_worker, + second_empty_worker, + bounded_cleanup_deadline(api_deadline), + ) + .await; + return Err(crate::error::MediaError::Ffmpeg( + "ffprobe stderr pipe missing".to_string(), + )); + }; + + let (capture_tx, mut capture_rx) = tokio::sync::mpsc::channel(2); + let stdout_tx = capture_tx.clone(); + let stdout_worker = tokio::task::spawn_local(async move { + let capture = read_probe_capture(stdout).await; + let _ = stdout_tx.send(ProbeCapture::Stdout(capture)).await; + }); + let stderr_worker = tokio::task::spawn_local(async move { + let capture = read_probe_capture(stderr).await; + let _ = capture_tx.send(ProbeCapture::Stderr(capture)).await; + }); + + let mut status = None; + let mut stdout = None; + let mut stderr = None; + let mut drain_deadline = api_deadline; + let mut cancel_poll = tokio::time::interval(FFPROBE_POLL_INTERVAL); + cancel_poll.set_missed_tick_behavior(MissedTickBehavior::Skip); + let failure = loop { + if status.is_some() && stdout.is_some() && stderr.is_some() { + break None; + } + let phase_deadline = if status.is_some() { + drain_deadline + } else { + operation_deadline + }; + tokio::select! { + wait = child.wait(), if status.is_none() => { + match wait { + Ok(exit_status) => { + status = Some(exit_status); + // A malicious override can leave descendants holding the + // inherited pipes. Close the whole tree before draining. + terminate_tree(&mut tree); + drain_deadline = bounded_cleanup_deadline(api_deadline); + } + Err(error) => break Some(crate::error::MediaError::Ffmpeg( + format!("ffprobe wait: {error}"), + )), + } + } + capture = capture_rx.recv(), if stdout.is_none() || stderr.is_none() => { + match capture { + Some(ProbeCapture::Stdout(result)) => match finish_probe_capture("stdout", result) { + Ok(bytes) => stdout = Some(bytes), + Err(error) => break Some(error), + }, + Some(ProbeCapture::Stderr(result)) => match finish_probe_capture("stderr", result) { + Ok(bytes) => stderr = Some(bytes), + Err(error) => break Some(error), + }, + None => break Some(crate::error::MediaError::Ffmpeg( + "ffprobe capture worker stopped unexpectedly".to_string(), + )), + } + } + _ = cancel_poll.tick() => { + if spec.cancel.checkpoint() { + break Some(crate::error::MediaError::Cancelled); + } + } + _ = tokio::time::sleep_until(phase_deadline) => { + break Some(timeout_error()); + } + } + }; + + if let Some(error) = failure { + terminate_running_ffprobe( + &mut child, + &mut tree, + stdout_worker, + stderr_worker, + bounded_cleanup_deadline(api_deadline), + ) + .await; + return Err(error); + } + + // Both workers have delivered their bounded buffers and the immediate + // process has been reaped. Their final send/return cannot retain a pipe. + drop(stdout_worker); + drop(stderr_worker); + Ok(FfprobeOutput { + status: status.expect("loop requires an exit status"), + stdout: stdout.expect("loop requires stdout"), + }) +} + +async fn execute_probe_request(request: ProbeExecutorRequest) { + match request { + ProbeExecutorRequest::Run { spec, response } => { + let _ = response.try_send(run_ffprobe_async(spec).await); + } + ProbeExecutorRequest::Available { + executable, + operation_deadline, + api_deadline, + response, + } => { + let _ = response.try_send( + ffprobe_available_async(executable, operation_deadline, api_deadline).await, + ); + } + #[cfg(test)] + ProbeExecutorRequest::Seam { + operation_deadline, + never_returns, + response, + } => { + let result = if never_returns { + let deadline = TokioInstant::from_std(operation_deadline); + let completed = finish_cleanup_before_deadline( + deadline, + std::future::pending::<()>(), + std::future::pending::<()>(), + std::future::pending::<()>(), + ) + .await; + if completed { + Ok(()) + } else { + Err(timeout_error()) + } + } else { + Ok(()) + }; + let _ = response.try_send(result); + } + } +} + +async fn probe_executor_loop(mut receiver: tokio::sync::mpsc::Receiver) { + let mut tasks = JoinSet::new(); + loop { + if tasks.len() >= FFPROBE_MAX_IN_FLIGHT { + let _ = tasks.join_next().await; + continue; + } + tokio::select! { + completed = tasks.join_next(), if !tasks.is_empty() => { + let _ = completed; + } + request = receiver.recv() => { + let Some(request) = request else { + while tasks.join_next().await.is_some() {} + return; + }; + tasks.spawn_local(execute_probe_request(request)); + } + } + } +} + +fn initialize_probe_executor() -> Result { + let (sender, receiver) = tokio::sync::mpsc::channel(FFPROBE_MAX_IN_FLIGHT); + std::thread::Builder::new() + .name("ffprobe-runtime".to_string()) + .spawn(move || { + let runtime = match tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + { + Ok(runtime) => runtime, + Err(_) => return, + }; + let local = LocalSet::new(); + local.block_on(&runtime, probe_executor_loop(receiver)); + }) + .map_err(|error| format!("ffprobe runtime thread: {error}"))?; + Ok(ProbeExecutor { sender }) +} + +fn probe_executor() -> crate::error::Result<&'static ProbeExecutor> { + match FFPROBE_EXECUTOR.get_or_init(initialize_probe_executor) { + Ok(executor) => Ok(executor), + Err(error) => Err(crate::error::MediaError::Ffmpeg(error.clone())), + } +} + +fn receive_probe_response( + receiver: &Receiver>, + cancel: Option<&crate::MediaCancelToken>, + api_deadline: Instant, +) -> crate::error::Result { + let mut cancelled = false; + let mut response_deadline = api_deadline; + loop { + if !cancelled && cancel.is_some_and(crate::MediaCancelToken::checkpoint) { + cancelled = true; + response_deadline = response_deadline.min( + Instant::now() + .checked_add(FFPROBE_CLEANUP_MAX) + .unwrap_or(response_deadline), + ); + } + let now = Instant::now(); + if now >= response_deadline { + if let Ok(result) = receiver.try_recv() { + return if cancelled { + Err(crate::error::MediaError::Cancelled) + } else { + result + }; + } + return if cancelled { + Err(crate::error::MediaError::Cancelled) + } else { + Err(timeout_error()) + }; + } + let wait = FFPROBE_POLL_INTERVAL.min(response_deadline.saturating_duration_since(now)); + match receiver.recv_timeout(wait) { + Ok(result) => { + return if cancelled { + Err(crate::error::MediaError::Cancelled) + } else { + result + }; + } + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {} + Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => { + return Err(crate::error::MediaError::Ffmpeg( + "ffprobe runtime stopped unexpectedly".to_string(), + )); + } + } + } +} + +fn run_ffprobe( + executable: &std::ffi::OsStr, + input_path: Option<&Path>, + input_file: Option<&std::fs::File>, + cancel: &crate::MediaCancelToken, + timeout: Duration, +) -> crate::error::Result { + if cancel.checkpoint() { + return Err(crate::error::MediaError::Cancelled); + } + let _admission = ProbeAdmission::acquire()?; + let (operation_deadline, api_deadline) = probe_deadlines(timeout)?; + let input = match (input_path, input_file) { + (Some(path), None) => ProbeInput::Path(path.to_path_buf()), + (None, Some(file)) => ProbeInput::File(file.try_clone().map_err(|error| { + crate::error::MediaError::Ffmpeg(format!("ffprobe input clone: {error}")) + })?), + _ => { + return Err(crate::error::MediaError::Ffmpeg( + "ffprobe input missing".to_string(), + )); + } + }; + let (response_tx, response_rx) = std::sync::mpsc::sync_channel(1); + let request = ProbeExecutorRequest::Run { + spec: ProbeSpec { + executable: executable.to_os_string(), + input, + cancel: cancel.clone(), + operation_deadline, + api_deadline, + }, + response: response_tx, + }; + probe_executor()? + .sender + .try_send(request) + .map_err(|error| { + crate::error::MediaError::Ffmpeg(format!("ffprobe runtime unavailable: {error}")) + })?; + receive_probe_response(&response_rx, Some(cancel), api_deadline) +} + +#[cfg(test)] +fn run_probe_executor_seam(never_returns: bool, timeout: Duration) -> crate::error::Result<()> { + let _admission = ProbeAdmission::acquire()?; + let (operation_deadline, api_deadline) = probe_deadlines(timeout)?; + let (response_tx, response_rx) = std::sync::mpsc::sync_channel(1); + probe_executor()? + .sender + .try_send(ProbeExecutorRequest::Seam { + operation_deadline, + never_returns, + response: response_tx, + }) + .map_err(|error| { + crate::error::MediaError::Ffmpeg(format!("ffprobe runtime unavailable: {error}")) + })?; + receive_probe_response(&response_rx, None, api_deadline) +} + +fn sidecar_filename(binary: &str) -> String { + if cfg!(windows) { + format!("{binary}.exe") + } else { + binary.to_string() + } +} + +/// Return a regular, non-symlink sidecar next to `executable`. +/// +/// Keeping this pure helper separate makes the packaged-path security boundary +/// deterministic to test without mutating the process executable or PATH. +pub fn packaged_sidecar_beside(executable: &Path, binary: &str) -> Option { + let parent = executable.parent()?; + let candidate = parent.join(sidecar_filename(binary)); + let metadata = std::fs::symlink_metadata(&candidate).ok()?; + if metadata.file_type().is_file() && !metadata.file_type().is_symlink() { + Some(candidate) + } else { + None + } +} + +/// Find a verified-by-the-package-manager sidecar beside the current executable. +/// Runtime code still checks that the path is a regular file; the build/package +/// pipeline owns its pinned SHA-256 and version verification. +pub fn packaged_sidecar_path(binary: &str) -> Option { + let executable = std::env::current_exe().ok()?; + packaged_sidecar_beside(&executable, binary) +} + +/// Resolve one CLI tool without mutating global process state in tests. +/// +/// An explicit override always wins, then a regular non-symlink packaged +/// sidecar beside the executable, and finally the platform command name on +/// `PATH`. +fn resolve_cli_path( + override_path: Option, + executable: Option<&Path>, + binary: &str, +) -> OsString { + override_path + .or_else(|| { + executable + .and_then(|path| packaged_sidecar_beside(path, binary)) + .map(PathBuf::into_os_string) + }) + .unwrap_or_else(|| OsString::from(binary)) +} + +/// Path to `ffmpeg`: explicit development override, packaged sidecar, then PATH. pub fn ffmpeg_path() -> OsString { - std::env::var_os("OPENTAKE_FFMPEG").unwrap_or_else(|| OsString::from("ffmpeg")) + let executable = std::env::current_exe().ok(); + resolve_cli_path( + std::env::var_os("OPENTAKE_FFMPEG"), + executable.as_deref(), + "ffmpeg", + ) } -/// Path to the `ffprobe` binary: `$OPENTAKE_FFPROBE`, else `ffprobe` on `PATH`. +/// Path to `ffprobe`: explicit development override, packaged sidecar, then PATH. pub fn ffprobe_path() -> OsString { - std::env::var_os("OPENTAKE_FFPROBE").unwrap_or_else(|| OsString::from("ffprobe")) + let executable = std::env::current_exe().ok(); + resolve_cli_path( + std::env::var_os("OPENTAKE_FFPROBE"), + executable.as_deref(), + "ffprobe", + ) } /// A fresh `FfmpegCommand` bound to [`ffmpeg_path`]. @@ -43,28 +739,41 @@ pub fn ffmpeg_available() -> bool { /// Whether `ffprobe` is runnable. pub fn ffprobe_available() -> bool { - Command::new(ffprobe_path()) - .arg("-version") - .output() - .map(|o| o.status.success()) - .unwrap_or(false) + run_ffprobe_availability(&ffprobe_path(), FFPROBE_TIMEOUT).unwrap_or(false) +} + +fn run_ffprobe_availability( + executable: &std::ffi::OsStr, + timeout: Duration, +) -> crate::error::Result { + let _admission = ProbeAdmission::acquire()?; + let (operation_deadline, api_deadline) = probe_deadlines(timeout)?; + let (response_tx, response_rx) = std::sync::mpsc::sync_channel(1); + probe_executor()? + .sender + .try_send(ProbeExecutorRequest::Available { + executable: executable.to_os_string(), + operation_deadline, + api_deadline, + response: response_tx, + }) + .map_err(|error| { + crate::error::MediaError::Ffmpeg(format!("ffprobe runtime unavailable: {error}")) + })?; + receive_probe_response(&response_rx, None, api_deadline) } /// Run `ffprobe -of json -show_streams -show_format ` and return parsed /// JSON. Zero decoding — header/stream parameters only. pub fn ffprobe_json(path: &std::path::Path) -> crate::error::Result { - let out = Command::new(ffprobe_path()) - .args([ - "-v", - "quiet", - "-of", - "json", - "-show_streams", - "-show_format", - ]) - .arg(path) - .output() - .map_err(|e| crate::error::MediaError::Ffmpeg(format!("ffprobe spawn: {e}")))?; + let executable = ffprobe_path(); + let out = run_ffprobe( + &executable, + Some(path), + None, + &crate::MediaCancelToken::new(), + FFPROBE_TIMEOUT, + )?; if !out.status.success() { return Err(crate::error::MediaError::Ffmpeg(format!( "ffprobe exited {}", @@ -79,25 +788,16 @@ pub fn ffprobe_json(path: &std::path::Path) -> crate::error::Result crate::error::Result { - let mut input = file - .try_clone() - .map_err(|e| crate::error::MediaError::Ffmpeg(format!("ffprobe input clone: {e}")))?; - input - .seek(SeekFrom::Start(0)) - .map_err(|e| crate::error::MediaError::Ffmpeg(format!("ffprobe input rewind: {e}")))?; - let out = Command::new(ffprobe_path()) - .args([ - "-v", - "quiet", - "-of", - "json", - "-show_streams", - "-show_format", - "fd:", - ]) - .stdin(Stdio::from(input)) - .output() - .map_err(|e| crate::error::MediaError::Ffmpeg(format!("ffprobe spawn: {e}")))?; + ffprobe_json_file_cancellable(file, &crate::MediaCancelToken::new(), FFPROBE_TIMEOUT) +} + +pub fn ffprobe_json_file_cancellable( + file: &std::fs::File, + cancel: &crate::MediaCancelToken, + timeout: Duration, +) -> crate::error::Result { + let executable = ffprobe_path(); + let out = run_ffprobe(&executable, None, Some(file), cancel, timeout)?; if !out.status.success() { return Err(crate::error::MediaError::Ffmpeg(format!( "ffprobe fd input exited {}", @@ -112,19 +812,358 @@ pub fn ffprobe_json_file(file: &std::fs::File) -> crate::error::Result '{}'\ndd if=/dev/zero bs=1048576 count=9 {output_redirection}\nwait\n", + capture.display() + ), + ) + .unwrap(); + let mut permissions = std::fs::metadata(&script).unwrap().permissions(); + permissions.set_mode(0o700); + std::fs::set_permissions(&script, permissions).unwrap(); + + let input = tempfile::tempfile().unwrap(); + // The probe executor is a single background thread shared by every + // ffprobe test. Under parallel test load that thread can be starved long + // enough for the wall-clock operation deadline to win over the bounded + // capture, which yields "ffprobe timed out" before the capture limit has + // a chance to fire — such an attempt proves nothing about the capture + // limit. Retry starved attempts; only an attempt that actually ran the + // probe can carry the assertions below, which are unchanged. + let mut result = None; + for _ in 0..3 { + let attempt = run_ffprobe( + script.as_os_str(), + None, + Some(&input), + &crate::MediaCancelToken::new(), + Duration::from_secs(3), + ); + let starved = matches!( + &attempt, + Err(crate::MediaError::Ffmpeg(message)) + if message == "ffprobe timed out" || message == "ffprobe admission limit reached" + ); + if !starved { + result = Some(attempt); + break; + } + } + let result = result.expect("every attempt was starved past the operation deadline"); + let Err(crate::MediaError::Ffmpeg(message)) = result else { + panic!("expected bounded capture error"); + }; + assert!( + message.contains(expected_stream) && message.contains("bounded capture"), + "unexpected capture error: {message}" + ); + + let pids = std::fs::read_to_string(capture).unwrap(); + for pid in pids + .lines() + .filter_map(|line| line.split_once('=').map(|(_, pid)| pid)) + { + let exit_deadline = Instant::now() + Duration::from_secs(2); + while Command::new("kill") + .args(["-0", pid]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .unwrap() + .success() + && Instant::now() < exit_deadline + { + std::thread::sleep(Duration::from_millis(10)); + } + assert!( + !Command::new("kill") + .args(["-0", pid]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .unwrap() + .success(), + "ffprobe process tree member {pid} survived capture overflow" + ); + } + } + #[test] fn env_override_is_respected_for_ffmpeg() { - // We can't safely mutate process env in parallel tests for the *default*, - // but we can assert the default value when the var is unset in this proc. - if std::env::var_os("OPENTAKE_FFMPEG").is_none() { - assert_eq!(ffmpeg_path(), OsString::from("ffmpeg")); - } + let temp = tempfile::tempdir().unwrap(); + let executable = temp.path().join(if cfg!(windows) { + "opentake.exe" + } else { + "opentake" + }); + std::fs::write(&executable, b"app").unwrap(); + std::fs::write(temp.path().join(sidecar_filename("ffmpeg")), b"sidecar").unwrap(); + + assert_eq!( + resolve_cli_path( + Some(OsString::from("/opt/opentake/custom-ffmpeg")), + Some(&executable), + "ffmpeg", + ), + OsString::from("/opt/opentake/custom-ffmpeg"), + ); } #[test] fn default_ffprobe_is_ffprobe() { - if std::env::var_os("OPENTAKE_FFPROBE").is_none() { - assert_eq!(ffprobe_path(), OsString::from("ffprobe")); + assert_eq!( + resolve_cli_path(None, None, "ffprobe"), + OsString::from("ffprobe") + ); + } + + #[test] + fn nonreturning_wait_and_drain_are_abandoned_without_poisoning_executor() { + let started = Instant::now(); + for _ in 0..4 { + let result = run_probe_executor_seam(true, Duration::from_millis(80)); + let Err(crate::MediaError::Ffmpeg(message)) = result else { + panic!("expected the injected cleanup deadline to expire"); + }; + assert!(message.contains("timed out")); } + run_probe_executor_seam(false, Duration::from_secs(1)) + .expect("healthy work must run after four abandoned cleanup futures"); + // The four 80ms abandoned cleanups plus the healthy run finish in well + // under a second locally; the bound only guards against a cleanup + // deadline that never fires at all, so keep it generous for loaded CI. + assert!( + started.elapsed() < Duration::from_secs(5), + "nonreturning cleanup work escaped its deadline" + ); + } + + #[test] + fn synchronous_dispatch_is_safe_inside_an_existing_tokio_runtime() { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + runtime.block_on(async { + run_probe_executor_seam(false, Duration::from_secs(1)) + .expect("sync dispatch must use the independent ffprobe runtime"); + }); + } + + #[cfg(unix)] + #[test] + fn availability_probe_uses_deadline_and_process_tree_containment() { + use std::os::unix::fs::PermissionsExt; + + let temp = tempfile::tempdir().unwrap(); + let script = temp.path().join("fake-ffprobe-version"); + let capture = temp.path().join("version-pids"); + std::fs::write( + &script, + format!( + "#!/bin/sh\nsleep 60 &\nprintf 'parent=%s\\nchild=%s\\n' \"$$\" \"$!\" > '{}'\nwait\n", + capture.display() + ), + ) + .unwrap(); + let mut permissions = std::fs::metadata(&script).unwrap().permissions(); + permissions.set_mode(0o700); + std::fs::set_permissions(&script, permissions).unwrap(); + + let started = Instant::now(); + let result = run_ffprobe_availability(script.as_os_str(), Duration::from_secs(1)); + assert!(started.elapsed() < Duration::from_secs(2)); + let Err(crate::MediaError::Ffmpeg(message)) = result else { + panic!("expected availability timeout"); + }; + assert!(message.contains("timed out")); + + // The probe executor is single-threaded; if this request dequeues after + // its operation deadline, the pre-spawn deadline check returns "timed + // out" without ever spawning, so no capture file exists and there is no + // process tree to assert on. The "timed out" message above already + // asserts the probe failed closed. A capture file that exists but cannot + // be read is a real test failure. + let pids = match std::fs::read_to_string(&capture) { + Ok(pids) => Some(pids), + Err(_) if !capture.exists() => None, + Err(error) => panic!("failed to read ffprobe capture file: {error}"), + }; + if let Some(pids) = pids { + for pid in pids + .lines() + .filter_map(|line| line.split_once('=').map(|(_, pid)| pid)) + { + // A SIGKILLed member may linger as a zombie until reaped by + // init; poll with a bounded deadline (mirrors + // assert_capture_limit_kills_tree) so CI scheduling latency + // cannot flake the assertion. + let exit_deadline = Instant::now() + Duration::from_secs(5); + while Command::new("kill") + .args(["-0", pid]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .unwrap() + .success() + && Instant::now() < exit_deadline + { + std::thread::sleep(Duration::from_millis(50)); + } + assert!( + !Command::new("kill") + .args(["-0", pid]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .unwrap() + .success(), + "ffprobe availability process tree member {pid} survived" + ); + } + } + } + + #[test] + fn packaged_sidecar_must_be_regular_and_beside_executable() { + let temp = tempfile::tempdir().unwrap(); + let executable = temp.path().join(if cfg!(windows) { + "opentake.exe" + } else { + "opentake" + }); + std::fs::write(&executable, b"app").unwrap(); + let sidecar = temp.path().join(sidecar_filename("ffmpeg")); + std::fs::write(&sidecar, b"sidecar").unwrap(); + + assert_eq!( + packaged_sidecar_beside(&executable, "ffmpeg"), + Some(sidecar.clone()) + ); + assert_eq!( + resolve_cli_path(None, Some(&executable), "ffmpeg"), + sidecar.into_os_string() + ); + assert_eq!(packaged_sidecar_beside(&executable, "ffprobe"), None); + } + + #[cfg(unix)] + #[test] + fn stdout_capture_limit_terminates_ffprobe_tree_immediately() { + assert_capture_limit_kills_tree("2>/dev/null", "stdout"); + } + + #[cfg(unix)] + #[test] + fn stderr_capture_limit_terminates_ffprobe_tree_immediately() { + assert_capture_limit_kills_tree("1>&2 2>/dev/null", "stderr"); + } + + #[cfg(unix)] + #[test] + fn cancellable_ffprobe_kills_descendants_that_inherit_helper_resources() { + use std::os::unix::fs::PermissionsExt; + use std::sync::mpsc; + + let temp = tempfile::tempdir().unwrap(); + let script = temp.path().join("fake-ffprobe"); + let capture = temp.path().join("pids"); + std::fs::write( + &script, + format!( + "#!/bin/sh\nsleep 60 &\nprintf 'parent=%s\\nchild=%s\\n' \"$$\" \"$!\" > '{}'\nwait\n", + capture.display() + ), + ) + .unwrap(); + let mut permissions = std::fs::metadata(&script).unwrap().permissions(); + permissions.set_mode(0o700); + std::fs::set_permissions(&script, permissions).unwrap(); + let input = tempfile::tempfile().unwrap(); + let cancel = crate::MediaCancelToken::new(); + let worker_cancel = cancel.clone(); + let worker_script = script.clone(); + let (done_tx, done_rx) = mpsc::channel(); + let worker = std::thread::spawn(move || { + let result = run_ffprobe( + worker_script.as_os_str(), + None, + Some(&input), + &worker_cancel, + Duration::from_secs(60), + ); + done_tx.send(result).unwrap(); + }); + let ready_deadline = Instant::now() + Duration::from_secs(5); + while (!capture.exists() || cancel.spawned_child_count() == 0) + && Instant::now() < ready_deadline + { + std::thread::yield_now(); + } + assert!(capture.exists(), "fake ffprobe entered"); + cancel.cancel(); + let result = done_rx + .recv_timeout(Duration::from_secs(5)) + .expect("cancelled ffprobe returned"); + assert!(matches!(result, Err(crate::MediaError::Cancelled))); + worker.join().unwrap(); + + let pids = std::fs::read_to_string(capture).unwrap(); + for pid in pids + .lines() + .filter_map(|line| line.split_once('=').map(|(_, pid)| pid)) + { + // SIGKILLed members may linger as zombies until init reaps them; + // poll with a bounded deadline (mirrors + // assert_capture_limit_kills_tree) so CI scheduling latency + // cannot flake the assertion. + let exit_deadline = Instant::now() + Duration::from_secs(5); + while Command::new("kill") + .args(["-0", pid]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .unwrap() + .success() + && Instant::now() < exit_deadline + { + std::thread::sleep(Duration::from_millis(50)); + } + assert!( + !Command::new("kill") + .args(["-0", pid]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .unwrap() + .success(), + "ffprobe process tree member {pid} survived" + ); + } + } + + #[cfg(unix)] + #[test] + fn packaged_sidecar_rejects_symlink() { + use std::os::unix::fs::symlink; + + let temp = tempfile::tempdir().unwrap(); + let executable = temp.path().join("opentake"); + let outside = temp.path().join("outside"); + let sidecar = temp.path().join("ffmpeg"); + std::fs::write(&executable, b"app").unwrap(); + std::fs::write(&outside, b"untrusted").unwrap(); + symlink(&outside, &sidecar).unwrap(); + + assert_eq!(packaged_sidecar_beside(&executable, "ffmpeg"), None); } } diff --git a/crates/opentake-media/src/index_coordinator.rs b/crates/opentake-media/src/index_coordinator.rs index bb49dee0..15ca5e60 100644 --- a/crates/opentake-media/src/index_coordinator.rs +++ b/crates/opentake-media/src/index_coordinator.rs @@ -17,7 +17,8 @@ use std::path::Path; use std::sync::atomic::{AtomicUsize, Ordering}; -use std::sync::Arc; +use std::sync::{Arc, Condvar, Mutex}; +use std::time::Duration; use opentake_domain::media::MediaAsset; use opentake_domain::ClipType; @@ -29,8 +30,21 @@ use crate::transcribe::cache::has_cached_on_disk; /// Cross-window reference-counted export-active flag. Background work yields /// while the count is non-zero. Port of `ExportPauseCounter` /// (`SearchIndexCoordinator.swift:37-47`). +#[derive(Default)] +struct ExportPauseInner { + active: AtomicUsize, + changed: Condvar, + gate: Mutex<()>, +} + #[derive(Clone, Default)] -pub struct ExportPause(Arc); +pub struct ExportPause(Arc); + +/// Balanced playback/export pressure holder. Dropping the final guard wakes +/// every blocked background worker, including early-return and unwind paths. +pub struct ExportPauseGuard { + pause: ExportPause, +} impl ExportPause { pub fn new() -> Self { @@ -38,19 +52,56 @@ impl ExportPause { } /// Mark an export as begun (increment). pub fn begin(&self) { - self.0.fetch_add(1, Ordering::SeqCst); + self.0.active.fetch_add(1, Ordering::SeqCst); } /// Mark an export as ended (decrement; saturating at 0). pub fn end(&self) { - let _ = self + let previous = self .0 + .active .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |v| { Some(v.saturating_sub(1)) }); + if matches!(previous, Ok(1)) { + self.0.changed.notify_all(); + } } /// True while any export is active. pub fn is_active(&self) -> bool { - self.0.load(Ordering::SeqCst) > 0 + self.0.active.load(Ordering::SeqCst) > 0 + } + + /// Begin pressure and return an unwind-safe, automatically balanced guard. + pub fn guard(&self) -> ExportPauseGuard { + self.begin(); + ExportPauseGuard { + pause: self.clone(), + } + } + + /// Block until playback/export pressure clears. The cancellation predicate + /// is checked at short intervals so shutdown and job cancellation cannot be + /// stranded behind a missing `end`. Returns `false` when cancelled. + pub fn wait_while_active(&self, cancelled: impl Fn() -> bool) -> bool { + let mut gate = self.0.gate.lock().unwrap_or_else(|e| e.into_inner()); + while self.is_active() { + if cancelled() { + return false; + } + let (next, _) = self + .0 + .changed + .wait_timeout(gate, Duration::from_millis(20)) + .unwrap_or_else(|e| e.into_inner()); + gate = next; + } + !cancelled() + } +} + +impl Drop for ExportPauseGuard { + fn drop(&mut self) { + self.pause.end(); } } @@ -135,6 +186,30 @@ mod tests { // saturating: extra end stays at 0. p.end(); assert!(!p.is_active()); + + // A guard makes every early-return path balanced, and a blocked worker + // wakes promptly once the final nested holder leaves. + let outer = p.guard(); + let inner = p.guard(); + let waiter_pause = p.clone(); + let (tx, rx) = std::sync::mpsc::channel(); + let waiter = std::thread::spawn(move || { + tx.send(waiter_pause.wait_while_active(|| false)).unwrap(); + }); + assert!(rx + .recv_timeout(std::time::Duration::from_millis(25)) + .is_err()); + drop(inner); + assert!(rx + .recv_timeout(std::time::Duration::from_millis(25)) + .is_err()); + drop(outer); + assert!(rx.recv_timeout(std::time::Duration::from_secs(1)).unwrap()); + waiter.join().unwrap(); + + p.begin(); + assert!(!p.wait_while_active(|| true)); + p.end(); } #[test] diff --git a/crates/opentake-media/src/lib.rs b/crates/opentake-media/src/lib.rs index 762bcd73..c65ed5e7 100644 --- a/crates/opentake-media/src/lib.rs +++ b/crates/opentake-media/src/lib.rs @@ -2,7 +2,7 @@ //! //! Ports PalmierPro's AVFoundation / DSWaveformImage / macOS-Speech / CoreML //! media stack to cross-platform Rust: -//! - **probe / decode / encode**: system ffmpeg CLI via [`ff`] (no libav* link). +//! - **probe / decode / encode**: packaged/development ffmpeg CLI via [`ff`] (no libav* link). //! - **thumbnails**: seek-decode + JPEG sprite-grid disk cache. //! - **waveform**: Symphonia PCM decode → RMS downsample → normalized buckets. //! - **transcribe**: `Transcriber` trait (+ data model, locale, cache, search); @@ -20,15 +20,40 @@ //! ## Why ffmpeg over the CLI //! The local toolchain is ffmpeg 8.1 (libavcodec 62), which the C-binding crates //! (`ffmpeg-next` / `ffmpeg-the-third`) do not support, and `pkg-config` is -//! absent. `ffmpeg-sidecar` drives the binaries on `PATH` — zero native linkage -//! and a clean cross-platform build — so it is the chosen backend (SPEC §1.2, +//! absent. `ffmpeg-sidecar` drives checksum-pinned packaged binaries (or PATH in +//! development only) — zero native linkage and a clean cross-platform build — +//! so it is the chosen backend (SPEC §1.2, //! "若 ffmpeg-next 不支持 8.x … 改用 ffmpeg-sidecar"). mod ff; +#[cfg(all(feature = "ort-backend", target_os = "windows"))] +pub(crate) fn initialize_ort_backend() { + static INITIALIZE: std::sync::Once = std::sync::Once::new(); + INITIALIZE.call_once(|| { + assert!( + ort::set_api(ort_tract::api()), + "ort API was initialized before the Windows tract backend" + ); + }); +} + +#[cfg(all(feature = "ort-backend", not(target_os = "windows")))] +pub(crate) fn initialize_ort_backend() {} + +#[cfg(all(test, feature = "ort-backend", target_os = "windows"))] +mod windows_ort_backend_tests { + #[test] + fn tract_backend_initializes_before_ort_session_use() { + crate::initialize_ort_backend(); + ort::session::Session::builder().expect("tract must provide the ort session API"); + } +} + pub mod analysis; pub mod cache_key; pub mod cancel; +pub mod color; pub mod decode; pub mod encode; pub mod error; @@ -37,6 +62,11 @@ pub mod index_coordinator; pub mod library; pub mod ort_worker; pub mod probe; +#[doc(hidden)] +pub mod process_tree { + pub use opentake_process_tree::{configure_command, ProcessTree}; +} +pub mod proxy; pub mod search; pub mod thumbnail; pub mod timecode; @@ -45,19 +75,95 @@ pub mod waveform; use std::path::{Path, PathBuf}; +/// Materialize an exact visible source range as an uploadable MP4. This is used +/// by generation/upscale when `sourceClipId` is supplied, so provider uploads +/// receive the clip's trimmed source window instead of the complete asset. +pub fn trim_video_range( + source: &Path, + destination: &Path, + start_seconds: f64, + end_seconds: f64, + cancel: &MediaCancelToken, +) -> Result<()> { + if !start_seconds.is_finite() + || !end_seconds.is_finite() + || start_seconds < 0.0 + || end_seconds <= start_seconds + { + return Err(MediaError::Ffmpeg( + "invalid trimmed generation source range".to_string(), + )); + } + if let Some(parent) = destination.parent() { + std::fs::create_dir_all(parent)?; + } + let duration = end_seconds - start_seconds; + let mut child = std::process::Command::new(ff::ffmpeg_path()) + .args(["-hide_banner", "-loglevel", "error", "-nostdin", "-y"]) + .arg("-ss") + .arg(format!("{start_seconds:.6}")) + .arg("-i") + .arg(source) + .arg("-t") + .arg(format!("{duration:.6}")) + .args([ + "-map", + "0:v:0", + "-map", + "0:a?", + "-c:v", + "libx264", + "-preset", + "veryfast", + "-crf", + "18", + "-c:a", + "aac", + "-movflags", + "+faststart", + ]) + .arg(destination) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() + .map_err(|error| MediaError::Ffmpeg(format!("trim source spawn: {error}")))?; + loop { + if cancel.is_cancelled() { + let _ = child.kill(); + let _ = child.wait(); + let _ = std::fs::remove_file(destination); + return Err(MediaError::Cancelled); + } + if let Some(status) = child.try_wait()? { + if !status.success() || !destination.is_file() { + let _ = std::fs::remove_file(destination); + return Err(MediaError::Ffmpeg( + "trimmed generation source could not be materialized".to_string(), + )); + } + return Ok(()); + } + std::thread::sleep(std::time::Duration::from_millis(20)); + } +} + // --- flat re-exports of the public API --- pub use cancel::MediaCancelToken; pub use error::{MediaError, Result}; pub use frame::RgbaFrame; -pub use probe::{probe, MediaProbe}; +pub use color::{hdr_decode_input_args, hdr_tonemap_filter}; +pub use probe::{parse_probe, probe, MediaProbe}; +pub use proxy::{create_proxy, file_sha256, ProxyProgressCallback, ProxyRequest, ProxyResult}; pub use decode::{ - decode_frame_at, decode_frame_at_cancellable, decode_frames_at, decode_frames_at_cancellable, - decode_pcm_interleaved, decode_pcm_interleaved_cancellable, extract_pcm, - extract_pcm_cancellable, extract_pcm_cancellable_with_progress, FrameRequest, PcmBuffer, - PcmFormat, PcmProgressCallback, PcmSpec, StreamDecodeControl, StreamVideoFrame, VideoStream, + convert_frame_rate, decode_frame_at, decode_frame_at_cancellable, decode_frames_at, + decode_frames_at_cancellable, decode_pcm_interleaved, decode_pcm_interleaved_cancellable, + extract_pcm, extract_pcm_cancellable, extract_pcm_cancellable_with_progress, + interpolate_frame_pair, FrameInterpolationFallback, FrameInterpolationMode, + FrameInterpolationResult, FrameRateSample, FrameRequest, PcmBuffer, PcmFormat, + PcmProgressCallback, PcmSpec, StreamDecodeControl, StreamVideoFrame, VideoStream, VideoStreamRequest, DEFAULT_VIDEO_STREAM_QUEUE_CAPACITY, }; @@ -96,8 +202,8 @@ pub use transcribe::{ pub use transcribe::whisper::WhisperTranscriber; pub use search::{ - rank as search_visual_ranked, AssetIndex, CancelToken, Embedder, EmbedderSpec, Hit, - SamplerOptions, + rank as search_visual_ranked, AssetIndex, CancelToken, Embedder, EmbedderSpec, Header, Hit, + Row, SamplerOptions, }; pub use index_coordinator::{work_needed, ExportPause, IndexProgress, WorkNeeded}; @@ -107,7 +213,10 @@ pub use ort_worker::ExecutionProvider; /// ffmpeg/ffprobe availability probes (re-exported for integration tests and /// host-capability checks). pub mod ffmpeg_status { - pub use crate::ff::{ffmpeg_available, ffprobe_available}; + pub use crate::ff::{ + ffmpeg_available, ffmpeg_path, ffprobe_available, packaged_sidecar_beside, + packaged_sidecar_path, + }; } /// Facade bundling the media engine's roots for `opentake-core` (SPEC §8.4). @@ -149,6 +258,44 @@ impl MediaEngine { probe::probe_file(file) } + /// Probe a retained file while bounding and cancelling the ffprobe process + /// tree. + pub fn probe_file_cancellable( + &self, + file: &std::fs::File, + cancel: &MediaCancelToken, + timeout: std::time::Duration, + ) -> Result { + probe::probe_file_cancellable(file, cancel, timeout) + } + + /// Decode the nearest source frame for preview/render materialization. + pub fn decode_frame(&self, path: &Path, request: &FrameRequest) -> Result<(f64, RgbaFrame)> { + decode::decode_frame_at(path, request) + } + + /// Decode the first audio track into the requested PCM contract. + pub fn extract_pcm( + &self, + path: &Path, + spec: &PcmSpec, + range: Option<(f64, f64)>, + ) -> Result { + decode::extract_pcm(path, spec, range) + } + + /// Start the streaming encoder used by the render/export adapter. + pub fn video_encoder( + &self, + output: &Path, + width: u32, + height: u32, + fps: i32, + preset: &ExportPreset, + ) -> Result { + encode::VideoEncoder::new(output, width, height, fps, preset) + } + /// Generate (and cache) a video thumbnail sequence. pub fn video_thumbnails( &self, @@ -191,6 +338,20 @@ impl MediaEngine { transcribe::search::search(&self.cache_root, query, assets, limit) } + /// Rank one encoded visual query against caller-owned current index + /// snapshots. Model loading/text encoding stay in the bounded worker; this + /// facade owns the deterministic index/ranking boundary. + pub fn search_visual( + &self, + query_vector: &[f32], + indexes: &[(String, AssetIndex)], + limit: usize, + relative_cutoff: f32, + min_score: Option, + ) -> Vec { + search::rank(query_vector, indexes, limit, relative_cutoff, min_score) + } + /// The shared export-pause signal; `opentake-render` calls `begin`/`end` /// around exports so background indexing yields. pub fn export_pause(&self) -> ExportPause { @@ -307,6 +468,13 @@ mod tests { let _: Option = None; let _ = PcmFormat::F32; let _ = VideoCodec::H264; + + // The high-level facade owns every service family as methods; callers + // do not need to assemble the flat modules themselves. + let _ = MediaEngine::decode_frame; + let _ = MediaEngine::extract_pcm; + let _ = MediaEngine::video_encoder; + let _ = MediaEngine::search_visual; } // --- extract_audio codec selection (Issue #39 review #3) --- @@ -457,4 +625,39 @@ mod tests { v_streams.trim() ); } + + #[test] + fn trim_video_range_materializes_only_visible_window_and_honors_cancel() { + use std::process::Command; + if !ff::ffmpeg_available() || !ff::ffprobe_available() { + eprintln!("skipping: ffmpeg/ffprobe unavailable"); + return; + } + let temp = tempfile::tempdir().unwrap(); + let source = temp.path().join("source.mp4"); + let trimmed = temp.path().join("trimmed.mp4"); + let generated = Command::new(ff::ffmpeg_path()) + .args(["-hide_banner", "-loglevel", "error", "-y"]) + .args(["-f", "lavfi", "-i", "color=size=64x48:rate=24:duration=3"]) + .args(["-c:v", "libx264", "-pix_fmt", "yuv420p"]) + .arg(&source) + .output() + .unwrap(); + assert!(generated.status.success()); + let source_before = std::fs::read(&source).unwrap(); + + trim_video_range(&source, &trimmed, 0.75, 1.75, &MediaCancelToken::new()).unwrap(); + let trimmed_probe = probe(&trimmed).unwrap(); + assert!((trimmed_probe.duration_secs - 1.0).abs() < 0.2); + assert_eq!(std::fs::read(&source).unwrap(), source_before); + + let cancelled_path = temp.path().join("cancelled.mp4"); + let cancel = MediaCancelToken::new(); + cancel.cancel(); + assert!(matches!( + trim_video_range(&source, &cancelled_path, 0.0, 2.0, &cancel), + Err(MediaError::Cancelled) + )); + assert!(!cancelled_path.exists()); + } } diff --git a/crates/opentake-media/src/library.rs b/crates/opentake-media/src/library.rs index 4e534df1..e38aee54 100644 --- a/crates/opentake-media/src/library.rs +++ b/crates/opentake-media/src/library.rs @@ -1587,7 +1587,15 @@ impl LibraryStore { stored.handle.as_file().set_len(0)?; stored.handle.as_file().sync_all() }; - let _ = cleanup(); + if let Err(error) = cleanup() { + // The manifest is already committed, so the removal stands; only + // the best-effort truncation of the content-addressed copy + // failed, leaving an orphaned file that a later sweep can + // reclaim. Report it instead of swallowing it silently. + tracing::warn!( + "library remove {id}: stored copy cleanup failed after manifest commit: {error}" + ); + } } Ok(true) } @@ -2529,6 +2537,75 @@ mod tests { assert!(store.contains(&entry.id).unwrap()); } + #[test] + fn remove_reports_failed_stored_copy_cleanup() { + use tracing::field::Visit; + use tracing::span::{Attributes, Id, Record}; + use tracing::{Event, Level, Metadata, Subscriber}; + + struct Warnings(std::sync::Arc>>); + impl Visit for Warnings { + fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) { + if field.name() == "message" { + self.0.lock().unwrap().push(format!("{value:?}")); + } + } + } + impl Subscriber for Warnings { + fn enabled(&self, _metadata: &Metadata<'_>) -> bool { + true + } + fn new_span(&self, _attrs: &Attributes<'_>) -> Id { + Id::from_u64(1) + } + fn record(&self, _span: &Id, _values: &Record<'_>) {} + fn record_follows_from(&self, _span: &Id, _follows: &Id) {} + fn event(&self, event: &Event<'_>) { + if *event.metadata().level() <= Level::WARN { + event.record(&mut Warnings(self.0.clone())); + } + } + fn enter(&self, _span: &Id) {} + fn exit(&self, _span: &Id) {} + } + + let warnings = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + let captured = warnings.clone(); + let (entry, messages) = tracing::subscriber::with_default(Warnings(warnings), || { + let tmp = tempfile::tempdir().unwrap(); + let source = src_file(tmp.path(), "cleanup-report.mp4", b"reported cleanup"); + let store = LibraryStore::new(tmp.path().join("lib")); + let entry = store.favorite(&req(&source, "video", None)).unwrap(); + + // The `tracing::warn!` callsite inside `remove` caches its interest + // globally on first execution. Under parallel test load another + // test can reach it while no subscriber is installed, caching + // "never interested". This first failing removal guarantees the + // callsite is registered; rebuilding the interest cache then + // re-evaluates it with this subscriber active, so the second + // failing removal's warning is guaranteed to be delivered. + fail_next_removed_stored_cleanup_for_test(); + assert!(store.remove(&entry.id).unwrap()); + assert!(store.entries().unwrap().is_empty()); + let refavorited = store.favorite(&req(&source, "video", None)).unwrap(); + assert_eq!(refavorited.id, entry.id); + tracing::callsite::rebuild_interest_cache(); + fail_next_removed_stored_cleanup_for_test(); + assert!(store.remove(&refavorited.id).unwrap()); + // The manifest commit still stands even though the best-effort + // truncation of the stored copy failed. + assert!(store.entries().unwrap().is_empty()); + let messages = captured.lock().unwrap().clone(); + (entry, messages) + }); + assert!( + messages.iter().any(|message| { + message.contains("stored copy cleanup failed") && message.contains(&entry.id) + }), + "expected a warning reporting the stored copy cleanup failure, got {messages:?}" + ); + } + #[test] fn failed_capability_replace_preserves_the_existing_canonical_leaf() { let tmp = tempfile::tempdir().unwrap(); diff --git a/crates/opentake-media/src/ort_worker/mod.rs b/crates/opentake-media/src/ort_worker/mod.rs index 47868623..c0d1a766 100644 --- a/crates/opentake-media/src/ort_worker/mod.rs +++ b/crates/opentake-media/src/ort_worker/mod.rs @@ -13,7 +13,7 @@ pub mod tensor; pub use tensor::{frame_to_hwc, hwc_to_nchw_normalized, mean_pool}; /// Execution provider preference; the loader falls back to CPU when an -/// accelerator is unavailable. +/// accelerator is unavailable. Windows ships the pure-Rust tract CPU backend. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum ExecutionProvider { Cpu, @@ -24,8 +24,8 @@ pub enum ExecutionProvider { } impl ExecutionProvider { - /// The platform-preferred provider (CoreML on macOS, DirectML on Windows, - /// CUDA on Linux), used as the first choice before CPU fallback. + /// The platform-preferred provider (CoreML on macOS, CPU tract on Windows, + /// CPU on Linux), used as the first choice before CPU fallback. pub fn platform_default() -> Self { #[cfg(target_os = "macos")] { @@ -33,7 +33,7 @@ impl ExecutionProvider { } #[cfg(target_os = "windows")] { - ExecutionProvider::DirectMl + ExecutionProvider::Cpu } #[cfg(all(not(target_os = "macos"), not(target_os = "windows")))] { @@ -66,6 +66,532 @@ pub struct IoSpec { pub outputs: Vec, } +use std::any::Any; +use std::collections::HashMap; +use std::marker::PhantomData; +use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}; +use std::sync::mpsc::{self, Receiver, SyncSender, TryRecvError, TrySendError}; +use std::sync::{Arc, Condvar, Mutex, Weak}; +use std::thread::JoinHandle; +use std::time::{Duration, Instant}; + +use crate::index_coordinator::ExportPause; +use crate::search::CancelToken; + +/// The production operation class carried with every queued job. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum JobKind { + Index, + Transcribe, + Search, +} + +/// Scheduling priority. Playback/export does not enter this queue: its shared +/// [`ExportPause`] gate prevents new jobs from starting at batch boundaries. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub enum JobPriority { + Background, + Interactive, +} + +/// Stable identity used for observability, prioritisation, and deduplication. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct JobRequest { + pub kind: JobKind, + pub model_identity: String, + pub dedupe_key: String, + pub priority: JobPriority, +} + +impl JobRequest { + pub fn new( + kind: JobKind, + model_identity: impl Into, + dedupe_key: impl Into, + priority: JobPriority, + ) -> Self { + Self { + kind, + model_identity: model_identity.into(), + dedupe_key: dedupe_key.into(), + priority, + } + } +} + +/// Consumer-visible lifecycle. Every accepted job reaches exactly one terminal +/// state even when its task returns an error or panics. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum JobState { + Queued, + Running, + Cancelled, + Completed, + Failed, +} + +/// Typed queue/result failures. Model and job errors remain recoverable: the +/// single worker continues serving the next request. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum WorkerError { + QueueFull, + Cancelled, + Shutdown, + Panicked, + Model(String), + Job(String), + ResultType, +} + +impl std::fmt::Display for WorkerError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::QueueFull => f.write_str("inference queue is full"), + Self::Cancelled => f.write_str("inference job was cancelled"), + Self::Shutdown => f.write_str("inference worker is shut down"), + Self::Panicked => f.write_str("inference job panicked"), + Self::Model(message) => write!(f, "model error: {message}"), + Self::Job(message) => write!(f, "job error: {message}"), + Self::ResultType => f.write_str("inference result type mismatch"), + } + } +} + +impl std::error::Error for WorkerError {} + +type ErasedResult = Arc; +type JobTask = Box< + dyn FnOnce(&OrtModelRegistry, &CancelToken) -> Result + + Send + + 'static, +>; + +struct JobStatus { + state: JobState, + result: Option>, +} + +struct SharedJob { + request: JobRequest, + cancel: CancelToken, + status: Mutex, + changed: Condvar, +} + +impl SharedJob { + fn new(request: JobRequest) -> Self { + Self { + request, + cancel: CancelToken::new(), + status: Mutex::new(JobStatus { + state: JobState::Queued, + result: None, + }), + changed: Condvar::new(), + } + } + + fn set_running(&self) -> bool { + let mut status = self.status.lock().unwrap_or_else(|e| e.into_inner()); + if self.cancel.is_cancelled() { + false + } else { + status.state = JobState::Running; + self.changed.notify_all(); + true + } + } + + fn finish(&self, result: Result) { + let mut status = self.status.lock().unwrap_or_else(|e| e.into_inner()); + status.state = match &result { + Ok(_) => JobState::Completed, + Err(WorkerError::Cancelled) => JobState::Cancelled, + Err(_) => JobState::Failed, + }; + status.result = Some(result); + self.changed.notify_all(); + } +} + +struct QueuedJob { + sequence: u64, + shared: Arc, + task: Option, +} + +enum WorkerMessage { + Job(QueuedJob), + Shutdown, +} + +struct WorkerInner { + sender: SyncSender, + dedupe: Mutex>>, + capacity: usize, + queued: AtomicUsize, + sequence: AtomicU64, + active: AtomicUsize, + shutdown: AtomicBool, + thread: Mutex>>, +} + +/// A bounded, single-thread heavy-inference executor. Its queue is shared by +/// indexing, transcription, and semantic-search callers; jobs carry model and +/// source identities, deduplicate while live, and yield to playback/export. +#[derive(Clone)] +pub struct OrtWorker { + inner: Arc, +} + +/// Typed view of one accepted (or deduplicated) job. +pub struct JobHandle { + shared: Arc, + marker: PhantomData, +} + +impl Clone for JobHandle { + fn clone(&self) -> Self { + Self { + shared: self.shared.clone(), + marker: PhantomData, + } + } +} + +impl JobHandle +where + T: Clone + Send + Sync + 'static, +{ + pub fn state(&self) -> JobState { + self.shared + .status + .lock() + .unwrap_or_else(|e| e.into_inner()) + .state + } + + pub fn cancel(&self) { + self.shared.cancel.cancel(); + } + + pub fn wait(&self) -> Result { + let mut status = self.shared.status.lock().unwrap_or_else(|e| e.into_inner()); + while status.result.is_none() { + status = self + .shared + .changed + .wait(status) + .unwrap_or_else(|e| e.into_inner()); + } + match status.result.as_ref().expect("result checked above") { + Ok(value) => value + .downcast_ref::() + .cloned() + .ok_or(WorkerError::ResultType), + Err(error) => Err(error.clone()), + } + } + + pub fn wait_until_running(&self, timeout: Duration) -> Result<(), WorkerError> { + let deadline = Instant::now() + timeout; + let mut status = self.shared.status.lock().unwrap_or_else(|e| e.into_inner()); + loop { + match status.state { + JobState::Running => return Ok(()), + JobState::Cancelled => return Err(WorkerError::Cancelled), + JobState::Failed | JobState::Completed => { + return Err(WorkerError::Job( + "job finished before it was observed running".into(), + )) + } + JobState::Queued => {} + } + let now = Instant::now(); + if now >= deadline { + return Err(WorkerError::Job( + "timed out waiting for running state".into(), + )); + } + let (next, timed) = self + .shared + .changed + .wait_timeout(status, deadline - now) + .unwrap_or_else(|e| e.into_inner()); + status = next; + if timed.timed_out() && status.state != JobState::Running { + return Err(WorkerError::Job( + "timed out waiting for running state".into(), + )); + } + } + } +} + +/// Single-worker model cache. A worker task can lazily install a typed model by +/// stable identity; subsequent jobs reuse the exact `Arc` without a second load. +#[derive(Default)] +pub struct OrtModelRegistry { + models: Mutex>, +} + +impl OrtModelRegistry { + pub fn get_or_try_init(&self, key: &str, load: F) -> Result, WorkerError> + where + T: Send + Sync + 'static, + F: FnOnce() -> Result, + { + let mut models = self.models.lock().unwrap_or_else(|e| e.into_inner()); + if let Some(existing) = models.get(key) { + return existing + .clone() + .downcast::() + .map_err(|_| WorkerError::ResultType); + } + let model = Arc::new(load()?); + models.insert(key.to_string(), model.clone()); + Ok(model) + } +} + +impl OrtWorker { + /// Spawn one worker with a hard bounded admission queue. + pub fn spawn(export_pause: ExportPause, capacity: usize) -> Self { + let (sender, receiver) = mpsc::sync_channel(capacity.max(1)); + let inner = Arc::new(WorkerInner { + sender, + dedupe: Mutex::new(HashMap::new()), + capacity: capacity.max(1), + queued: AtomicUsize::new(0), + sequence: AtomicU64::new(0), + active: AtomicUsize::new(0), + shutdown: AtomicBool::new(false), + thread: Mutex::new(None), + }); + let thread_inner = inner.clone(); + let thread = std::thread::Builder::new() + .name("opentake-ort-worker".into()) + .spawn(move || worker_loop(thread_inner, receiver, export_pause)) + .expect("spawn bounded inference worker"); + *inner.thread.lock().unwrap_or_else(|e| e.into_inner()) = Some(thread); + Self { inner } + } + + /// Submit a typed job. A live duplicate key reuses the same result and does + /// not consume queue capacity or execute a second task. + pub fn submit(&self, request: JobRequest, task: F) -> Result, WorkerError> + where + T: Clone + Send + Sync + 'static, + F: FnOnce(&OrtModelRegistry, &CancelToken) -> Result + Send + 'static, + { + if self.inner.shutdown.load(Ordering::SeqCst) { + return Err(WorkerError::Shutdown); + } + + let mut dedupe = self.inner.dedupe.lock().unwrap_or_else(|e| e.into_inner()); + dedupe.retain(|_, weak| weak.strong_count() > 0); + if let Some(shared) = dedupe.get(&request.dedupe_key).and_then(Weak::upgrade) { + return Ok(JobHandle { + shared, + marker: PhantomData, + }); + } + + if self + .inner + .queued + .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |queued| { + (queued < self.inner.capacity).then_some(queued + 1) + }) + .is_err() + { + return Err(WorkerError::QueueFull); + } + + let key = request.dedupe_key.clone(); + let shared = Arc::new(SharedJob::new(request)); + dedupe.insert(key.clone(), Arc::downgrade(&shared)); + let sequence = self.inner.sequence.fetch_add(1, Ordering::SeqCst); + let erased: JobTask = Box::new(move |models, cancel| { + task(models, cancel).map(|value| Arc::new(value) as ErasedResult) + }); + let message = WorkerMessage::Job(QueuedJob { + sequence, + shared: shared.clone(), + task: Some(erased), + }); + match self.inner.sender.try_send(message) { + Ok(()) => Ok(JobHandle { + shared, + marker: PhantomData, + }), + Err(TrySendError::Full(_)) => { + self.inner.queued.fetch_sub(1, Ordering::SeqCst); + dedupe.remove(&key); + Err(WorkerError::QueueFull) + } + Err(TrySendError::Disconnected(_)) => { + self.inner.queued.fetch_sub(1, Ordering::SeqCst); + dedupe.remove(&key); + Err(WorkerError::Shutdown) + } + } + } + + pub fn active_jobs(&self) -> usize { + self.inner.active.load(Ordering::SeqCst) + } + + pub fn queued_jobs(&self) -> usize { + self.inner.queued.load(Ordering::SeqCst) + } + + /// Cancel queued work, wait for the cooperative running job, and join the + /// sole worker thread. Idempotent. + pub fn shutdown(&self) -> Result<(), WorkerError> { + if !self.inner.shutdown.swap(true, Ordering::SeqCst) { + // A full channel is not an error: the worker observes the atomic + // shutdown flag at its next cancellation/dispatch boundary. + let _ = self.inner.sender.try_send(WorkerMessage::Shutdown); + } + if let Some(thread) = self + .inner + .thread + .lock() + .unwrap_or_else(|e| e.into_inner()) + .take() + { + thread.join().map_err(|_| WorkerError::Panicked)?; + } + Ok(()) + } +} + +fn worker_loop(inner: Arc, receiver: Receiver, pause: ExportPause) { + let registry = OrtModelRegistry::default(); + let mut pending = Vec::::new(); + let mut high_streak = 0usize; + + loop { + if pending.is_empty() { + match receiver.recv() { + Ok(WorkerMessage::Job(job)) => pending.push(job), + Ok(WorkerMessage::Shutdown) | Err(_) => break, + } + } + + let mut shutdown = false; + loop { + match receiver.try_recv() { + Ok(WorkerMessage::Job(job)) => pending.push(job), + Ok(WorkerMessage::Shutdown) => { + shutdown = true; + break; + } + Err(TryRecvError::Empty) => break, + Err(TryRecvError::Disconnected) => { + shutdown = true; + break; + } + } + } + if shutdown || inner.shutdown.load(Ordering::SeqCst) { + cancel_pending(&pending); + break; + } + + if !pause.wait_while_active(|| inner.shutdown.load(Ordering::SeqCst)) { + cancel_pending(&pending); + break; + } + + // Requests may arrive while the worker is pressure-gated. Re-drain at + // the scheduling boundary so their priority participates immediately. + loop { + match receiver.try_recv() { + Ok(WorkerMessage::Job(job)) => pending.push(job), + Ok(WorkerMessage::Shutdown) => { + cancel_pending(&pending); + inner.active.store(0, Ordering::SeqCst); + return; + } + Err(TryRecvError::Empty) => break, + Err(TryRecvError::Disconnected) => { + cancel_pending(&pending); + inner.active.store(0, Ordering::SeqCst); + return; + } + } + } + + // Four high-priority jobs is the starvation bound. Otherwise choose the + // oldest job at the highest available priority (FIFO within priority). + let has_background = pending + .iter() + .any(|job| job.shared.request.priority == JobPriority::Background); + let force_background = has_background && high_streak >= 4; + let selected_priority = if force_background { + JobPriority::Background + } else { + pending + .iter() + .map(|job| job.shared.request.priority) + .max() + .unwrap_or(JobPriority::Background) + }; + let index = pending + .iter() + .enumerate() + .filter(|(_, job)| job.shared.request.priority == selected_priority) + .min_by_key(|(_, job)| job.sequence) + .map(|(index, _)| index) + .expect("pending queue is not empty"); + let mut job = pending.swap_remove(index); + inner.queued.fetch_sub(1, Ordering::SeqCst); + if selected_priority == JobPriority::Interactive { + high_streak += 1; + } else { + high_streak = 0; + } + + if !job.shared.set_running() { + remove_dedupe(&inner, &job.shared); + job.shared.finish(Err(WorkerError::Cancelled)); + continue; + } + inner.active.fetch_add(1, Ordering::SeqCst); + let task = job.task.take().expect("queued job owns one task"); + let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + task(®istry, &job.shared.cancel) + })) + .unwrap_or(Err(WorkerError::Panicked)); + inner.active.fetch_sub(1, Ordering::SeqCst); + remove_dedupe(&inner, &job.shared); + job.shared.finish(outcome); + } + + inner.active.store(0, Ordering::SeqCst); + inner.queued.store(0, Ordering::SeqCst); +} + +fn cancel_pending(pending: &[QueuedJob]) { + for job in pending { + job.shared.cancel.cancel(); + job.shared.finish(Err(WorkerError::Cancelled)); + } +} + +fn remove_dedupe(inner: &WorkerInner, shared: &Arc) { + let mut dedupe = inner.dedupe.lock().unwrap_or_else(|e| e.into_inner()); + if dedupe + .get(&shared.request.dedupe_key) + .and_then(Weak::upgrade) + .is_some_and(|current| Arc::ptr_eq(¤t, shared)) + { + dedupe.remove(&shared.request.dedupe_key); + } +} + #[cfg(feature = "ort-backend")] mod model { use std::collections::HashMap; @@ -79,6 +605,8 @@ mod model { use super::ExecutionProvider; use crate::error::{MediaError, Result}; + pub type OrtIoContract = (Vec<(String, String)>, Vec<(String, String)>); + /// A loaded ONNX model + a CPU-fallback-friendly session. `Session` is not /// `Sync`; wrap in a `Mutex` so the worker can share it. pub struct OrtModel { @@ -88,6 +616,7 @@ mod model { impl OrtModel { /// Load `path` with the given EP preference, falling back to CPU. pub fn load(path: &Path, _ep: ExecutionProvider) -> Result { + crate::initialize_ort_backend(); let builder = Session::builder().map_err(|e| MediaError::ModelInstall(format!("ort: {e}")))?; let builder = builder @@ -137,11 +666,29 @@ mod model { } Ok(out) } + + /// Names and debug-formatted tensor contracts declared by the model. + /// Used to fail closed when a downloaded advanced model does not match + /// the pinned architecture before any user media reaches inference. + pub fn io_contract(&self) -> OrtIoContract { + let session = self.session.lock().unwrap(); + let inputs = session + .inputs + .iter() + .map(|input| (input.name.clone(), format!("{:?}", input.input_type))) + .collect(); + let outputs = session + .outputs + .iter() + .map(|output| (output.name.clone(), format!("{:?}", output.output_type))) + .collect(); + (inputs, outputs) + } } } #[cfg(feature = "ort-backend")] -pub use model::OrtModel; +pub use model::{OrtIoContract, OrtModel}; #[cfg(test)] mod tests { @@ -172,4 +719,21 @@ mod tests { assert_eq!(t.shape[0], -1); assert_eq!(t.dtype, TensorDType::F32); } + + #[cfg(feature = "ort-backend")] + #[test] + fn installed_advanced_model_contract_can_be_inspected_before_inference() { + let Some(path) = std::env::var_os("OPENTAKE_TEST_ONNX_MODEL") else { + return; + }; + let model = OrtModel::load( + std::path::Path::new(&path), + ExecutionProvider::platform_default(), + ) + .expect("load supplied ONNX model"); + let contract = model.io_contract(); + assert!(!contract.0.is_empty()); + assert!(!contract.1.is_empty()); + eprintln!("ONNX_IO_CONTRACT={contract:?}"); + } } diff --git a/crates/opentake-media/src/probe.rs b/crates/opentake-media/src/probe.rs index 8829ee9b..732df34e 100644 --- a/crates/opentake-media/src/probe.rs +++ b/crates/opentake-media/src/probe.rs @@ -7,6 +7,9 @@ //! ffprobe. use std::path::Path; +use std::time::Duration; + +use opentake_domain::MediaColorMetadata; use crate::error::{MediaError, Result}; use crate::ff; @@ -24,10 +27,18 @@ pub struct MediaProbe { pub fps: Option, pub has_audio: bool, pub has_video: bool, + /// ffprobe `codec_name` of the primary (non-cover-art) video stream. + /// Post-encode verification compares this against the requested encoder. + pub video_codec: Option, + /// ffprobe `codec_name` of the first audio stream. + pub audio_codec: Option, /// ffprobe's comma-separated demuxer names (for example /// `mov,mp4,m4a,3gp,3g2,mj2`). Security-sensitive import boundaries use /// this to verify that downloaded bytes match their declared container. pub format_name: Option, + /// Source video color signalling retained for HDR-aware decode and durable + /// project metadata. Absent only when the stream reports no color fields. + pub color: Option, } /// Open the container and read the first video stream + audio presence. @@ -50,6 +61,17 @@ pub fn probe_file(file: &std::fs::File) -> Result { Ok(parse_probe(&json)) } +/// Probe a retained regular file with cooperative cancellation and a hard +/// helper-process deadline. +pub fn probe_file_cancellable( + file: &std::fs::File, + cancel: &crate::MediaCancelToken, + timeout: Duration, +) -> Result { + let json = ff::ffprobe_json_file_cancellable(file, cancel, timeout)?; + Ok(parse_probe(&json)) +} + /// Parse the rate string ffprobe emits, e.g. `"30000/1001"` or `"25/1"`. /// `"0/0"` (unknown) → `None`. fn parse_rate(s: &str) -> Option { @@ -114,10 +136,17 @@ pub fn parse_probe(json: &serde_json::Value) -> MediaProbe { // makes a dropped video spawn a phantom linked audio clip (the user's "no // audio but it split" report), so require channels > 0 when reported. Streams // that don't report `channels` are kept as audio (conservative default). + let mut audio_codec = None; let has_audio = streams.iter().any(|s| { if s.get("codec_type").and_then(|v| v.as_str()) != Some("audio") { return false; } + if audio_codec.is_none() { + audio_codec = s + .get("codec_name") + .and_then(|value| value.as_str()) + .map(str::to_owned); + } s.get("channels").and_then(|v| v.as_u64()) != Some(0) }); @@ -125,8 +154,14 @@ pub fn parse_probe(json: &serde_json::Value) -> MediaProbe { let mut height = None; let mut fps = None; let mut video_duration = None; + let mut color = None; + let mut video_codec = None; if let Some(v) = video { + video_codec = v + .get("codec_name") + .and_then(|value| value.as_str()) + .map(str::to_owned); let w = v.get("width").and_then(|x| x.as_u64()).map(|x| x as u32); let h = v.get("height").and_then(|x| x.as_u64()).map(|x| x as u32); let rot = stream_rotation(v); @@ -152,6 +187,28 @@ pub fn parse_probe(json: &serde_json::Value) -> MediaProbe { .get("duration") .and_then(|x| x.as_str()) .and_then(|s| s.parse::().ok()); + + let metadata = MediaColorMetadata { + primaries: v + .get("color_primaries") + .and_then(|value| value.as_str()) + .map(str::to_owned), + transfer: v + .get("color_transfer") + .and_then(|value| value.as_str()) + .map(str::to_owned), + matrix: v + .get("color_space") + .and_then(|value| value.as_str()) + .map(str::to_owned), + range: v + .get("color_range") + .and_then(|value| value.as_str()) + .map(str::to_owned), + }; + if !metadata.is_empty() { + color = Some(metadata); + } } let container_duration = json @@ -174,7 +231,10 @@ pub fn parse_probe(json: &serde_json::Value) -> MediaProbe { fps, has_audio, has_video, + video_codec, + audio_codec, format_name, + color, } } @@ -245,6 +305,28 @@ mod tests { assert_eq!(p.duration_secs, 12.5); } + #[test] + fn codec_names_carried_for_post_encode_verification() { + let p = parse_probe(&json!({ + "streams": [ + {"codec_type": "video", "codec_name": "h264", "width": 640, "height": 360, + "avg_frame_rate": "30/1", "duration": "2.0"}, + {"codec_type": "audio", "codec_name": "aac", "channels": 2} + ], + "format": {"duration": "2.0"} + })); + assert_eq!(p.video_codec.as_deref(), Some("h264")); + assert_eq!(p.audio_codec.as_deref(), Some("aac")); + assert!(p.has_video && p.has_audio); + } + + #[test] + fn no_streams_means_no_codec_names() { + let p = parse_probe(&json!({"streams": [], "format": {}})); + assert_eq!(p.video_codec, None); + assert_eq!(p.audio_codec, None); + } + #[test] fn attached_picture_is_not_a_playable_video_stream() { let probe = parse_probe(&json!({ diff --git a/crates/opentake-media/src/proxy.rs b/crates/opentake-media/src/proxy.rs new file mode 100644 index 00000000..c8f54c94 --- /dev/null +++ b/crates/opentake-media/src/proxy.rs @@ -0,0 +1,852 @@ +//! Project-local low-resolution proxy creation. +//! +//! The source is retained once and hashed before and after transcoding. FFmpeg +//! reads that same capability and writes to a sibling partial file, which is +//! published without clobbering only after a successful retained-file probe. + +use std::fs::{self, File, OpenOptions}; +use std::io::{BufReader, Read, Seek, SeekFrom}; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::sync::Arc; +use std::time::Duration; + +use same_file::Handle as FileIdentity; +use sha2::{Digest, Sha256}; + +use crate::cancel::MediaCancelToken; +use crate::error::{MediaError, Result}; +use crate::{ff, probe}; + +pub type ProxyProgressCallback = Arc; + +const PROXY_PROBE_TIMEOUT: Duration = Duration::from_secs(30); + +#[derive(Clone, Copy, Debug)] +pub struct ProxyRequest<'a> { + pub source: &'a Path, + pub output: &'a Path, + pub max_size: (u32, u32), +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ProxyResult { + pub path: PathBuf, + pub source_sha256: String, + pub width: u32, + pub height: u32, +} + +fn report(progress: &Option, done: usize) { + if let Some(callback) = progress { + callback(done, 1000); + } +} + +pub fn file_sha256(path: &Path) -> Result { + let file = open_retained_regular_file(path)?; + file_sha256_file_cancellable(&file, &MediaCancelToken::new()) +} + +fn file_sha256_file_cancellable(file: &File, cancel: &MediaCancelToken) -> Result { + let mut reader = BufReader::new(file.try_clone()?); + reader.seek(SeekFrom::Start(0))?; + let mut hasher = Sha256::new(); + let mut buffer = [0_u8; 64 * 1024]; + loop { + if cancel.checkpoint() { + return Err(MediaError::Cancelled); + } + let read = reader.read(&mut buffer)?; + if read == 0 { + break; + } + hasher.update(&buffer[..read]); + } + if cancel.checkpoint() { + return Err(MediaError::Cancelled); + } + Ok(format!("{:x}", hasher.finalize())) +} + +#[cfg(test)] +fn partial_path(output: &Path) -> PathBuf { + match output.extension().and_then(|extension| extension.to_str()) { + Some(extension) => output.with_extension(format!("{extension}.partial")), + None => output.with_extension("partial"), + } +} + +fn open_retained_regular_file(path: &Path) -> std::io::Result { + let mut options = OpenOptions::new(); + options.read(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.custom_flags(libc::O_NONBLOCK | libc::O_NOFOLLOW); + } + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt; + use windows_sys::Win32::Storage::FileSystem::{ + FILE_FLAG_OPEN_NO_RECALL, FILE_FLAG_OPEN_REPARSE_POINT, FILE_SHARE_DELETE, + FILE_SHARE_READ, FILE_SHARE_WRITE, + }; + options + .share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE) + .custom_flags(FILE_FLAG_OPEN_NO_RECALL | FILE_FLAG_OPEN_REPARSE_POINT); + } + let file = options.open(path)?; + if !file.metadata()?.is_file() { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + path.display().to_string(), + )); + } + Ok(file) +} + +struct PrivateStage { + identity: FileIdentity, + path: tempfile::TempPath, + directory: tempfile::TempDir, +} + +impl PrivateStage { + fn create(output: &Path) -> Result { + let parent = output.parent().unwrap_or_else(|| Path::new(".")); + let mut builder = tempfile::Builder::new(); + builder.prefix(".opentake-proxy-stage-"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + builder.permissions(fs::Permissions::from_mode(0o700)); + } + let directory = builder.tempdir_in(parent)?; + let path = directory.path().join("proxy.mp4"); + let mut options = OpenOptions::new(); + options.read(true).write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt; + use windows_sys::Win32::Storage::FileSystem::{ + FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE, + }; + options.share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE); + } + let file = options.open(&path)?; + Ok(Self { + identity: FileIdentity::from_file(file)?, + path: tempfile::TempPath::try_from_path(path)?, + directory, + }) + } + + fn path(&self) -> &Path { + &self.path + } + + fn file(&self) -> &File { + self.identity.as_file() + } + + fn verify_path_identity(&self) -> Result<()> { + let current = FileIdentity::from_file(open_retained_regular_file(self.path())?)?; + if current != self.identity { + return Err(MediaError::Checksum( + "proxy staging identity changed during transcode".to_string(), + )); + } + Ok(()) + } + + fn persist_noclobber(self, output: &Path) -> Result<()> { + let Self { + identity, + path, + directory, + } = self; + let result = match path.persist_noclobber(output) { + Ok(()) => Ok(()), + Err(error) if error.error.kind() == std::io::ErrorKind::AlreadyExists => Err( + MediaError::Ffmpeg("proxy destination appeared during transcode".to_string()), + ), + Err(error) => Err(error.error.into()), + }; + drop(identity); + drop(directory); + result + } +} + +pub fn create_proxy( + request: ProxyRequest<'_>, + cancel: &MediaCancelToken, + progress: Option, +) -> Result { + report(&progress, 0); + if cancel.is_cancelled() { + return Err(MediaError::Cancelled); + } + if request.max_size.0 == 0 || request.max_size.1 == 0 { + return Err(MediaError::Ffmpeg( + "proxy dimensions must be positive".to_string(), + )); + } + let source = open_retained_regular_file(request.source)?; + if request.output.exists() { + return Err(MediaError::Ffmpeg( + "proxy destination already exists".to_string(), + )); + } + + if let Some(parent) = request.output.parent() { + fs::create_dir_all(parent)?; + } + let stage = PrivateStage::create(request.output)?; + + let source_sha256 = file_sha256_file_cancellable(&source, cancel)?; + report(&progress, 100); + if cancel.is_cancelled() { + return Err(MediaError::Cancelled); + } + + let mut ffmpeg_source = source.try_clone()?; + ffmpeg_source.seek(SeekFrom::Start(0))?; + + let scale = format!( + "scale=w={}:h={}:force_original_aspect_ratio=decrease:force_divisible_by=2", + request.max_size.0, request.max_size.1 + ); + let mut child = Command::new(ff::ffmpeg_path()) + .args(["-hide_banner", "-loglevel", "error", "-nostdin", "-y"]) + .arg("-i") + .arg("fd:") + .args(["-map", "0:v:0", "-map", "0:a?", "-vf"]) + .arg(scale) + .args([ + "-c:v", + "libx264", + "-preset", + "veryfast", + "-crf", + "23", + "-pix_fmt", + "yuv420p", + "-c:a", + "aac", + "-movflags", + "+faststart", + "-f", + "mp4", + ]) + .arg(stage.path()) + .stdin(Stdio::from(ffmpeg_source)) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .map_err(|error| MediaError::Ffmpeg(format!("proxy spawn: {error}")))?; + cancel.child_spawned(); + + loop { + if cancel.is_cancelled() { + let _ = child.kill(); + let _ = child.wait(); + return Err(MediaError::Cancelled); + } + match child.try_wait() { + Ok(Some(status)) => { + if !status.success() { + return Err(MediaError::Ffmpeg( + "proxy transcode did not complete".to_string(), + )); + } + break; + } + Ok(None) => { + report(&progress, 500); + std::thread::sleep(Duration::from_millis(20)); + } + Err(error) => { + let _ = child.kill(); + let _ = child.wait(); + return Err(error.into()); + } + } + } + + stage.verify_path_identity()?; + + report(&progress, 900); + if cancel.is_cancelled() { + return Err(MediaError::Cancelled); + } + report(&progress, 910); + let verified_source_sha256 = file_sha256_file_cancellable(&source, cancel)?; + if verified_source_sha256 != source_sha256 { + return Err(MediaError::Checksum( + "source changed while proxy was being created".to_string(), + )); + } + + report(&progress, 950); + let metadata = match probe::probe_file_cancellable(stage.file(), cancel, PROXY_PROBE_TIMEOUT) { + Ok(metadata) if metadata.has_video => metadata, + Ok(_) => { + return Err(MediaError::no_track("video", stage.path())); + } + Err(error) => return Err(error), + }; + let (width, height) = match (metadata.width, metadata.height) { + (Some(width), Some(height)) if width > 0 && height > 0 => (width, height), + _ => { + return Err(MediaError::Decode( + "proxy has no usable dimensions".to_string(), + )); + } + }; + if request.output.exists() { + return Err(MediaError::Ffmpeg( + "proxy destination appeared during transcode".to_string(), + )); + } + report(&progress, 990); + if cancel.checkpoint() { + return Err(MediaError::Cancelled); + } + stage.persist_noclobber(request.output)?; + report(&progress, 1000); + + Ok(ProxyResult { + path: request.output.to_path_buf(), + source_sha256, + width, + height, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicBool, Ordering}; + use std::sync::Mutex; + + fn assert_proxy_tools_available() { + assert!(ff::ffmpeg_available(), "ffmpeg is required for proxy tests"); + assert!( + ff::ffprobe_available(), + "ffprobe is required for proxy tests" + ); + } + + fn make_video(path: &Path, duration_seconds: u32, size: &str) { + let video_source = format!("testsrc2=size={size}:rate=30"); + let duration = duration_seconds.to_string(); + let status = Command::new(ff::ffmpeg_path()) + .args([ + "-hide_banner", + "-loglevel", + "error", + "-y", + "-f", + "lavfi", + "-i", + &video_source, + "-f", + "lavfi", + "-i", + "sine=frequency=440:sample_rate=48000", + "-t", + &duration, + "-c:v", + "libx264", + "-preset", + "ultrafast", + "-pix_fmt", + "yuv420p", + "-c:a", + "aac", + ]) + .arg(path) + .status() + .expect("spawn ffmpeg fixture"); + assert!(status.success(), "ffmpeg fixture creation failed"); + } + + fn stream_count(path: &Path, selector: &str) -> usize { + let output = Command::new(ff::ffprobe_path()) + .args([ + "-v", + "error", + "-select_streams", + selector, + "-show_entries", + "stream=index", + "-of", + "csv=p=0", + ]) + .arg(path) + .output() + .expect("run ffprobe stream count"); + assert!(output.status.success(), "ffprobe stream count failed"); + String::from_utf8(output.stdout) + .expect("ffprobe stream count is UTF-8") + .lines() + .filter(|line| !line.trim().is_empty()) + .count() + } + + #[test] + fn successful_proxy_is_bounded_probed_and_source_preserving() { + assert_proxy_tools_available(); + + let temp = tempfile::tempdir().expect("proxy test directory"); + let source = temp.path().join("source.mp4"); + let output = temp.path().join("media/proxies/source-proxy.mp4"); + make_video(&source, 1, "640x360"); + let source_before = fs::read(&source).expect("read source fixture"); + let source_sha256 = file_sha256(&source).expect("hash source fixture"); + let progress_values = Arc::new(Mutex::new(Vec::new())); + let captured_progress = Arc::clone(&progress_values); + let progress: ProxyProgressCallback = Arc::new(move |done, total| { + captured_progress + .lock() + .expect("capture proxy progress") + .push((done, total)); + }); + + let result = create_proxy( + ProxyRequest { + source: &source, + output: &output, + max_size: (320, 240), + }, + &MediaCancelToken::new(), + Some(progress), + ) + .expect("create proxy"); + + assert_eq!(result.path, output); + assert_eq!(result.source_sha256, source_sha256); + assert_eq!((result.width, result.height), (320, 180)); + assert_eq!(fs::read(&source).expect("reread source"), source_before); + let metadata = probe(&output).expect("probe completed proxy"); + assert!(metadata.has_video && metadata.has_audio); + assert_eq!((metadata.width, metadata.height), (Some(320), Some(180))); + assert_eq!(stream_count(&output, "v"), 1); + assert!(stream_count(&output, "a") <= 1); + let progress = progress_values.lock().expect("read proxy progress"); + assert_eq!(progress.first().copied(), Some((0, 1000))); + assert!(progress.contains(&(100, 1000))); + assert!(progress.contains(&(900, 1000))); + assert_eq!(progress.last().copied(), Some((1000, 1000))); + assert!(!partial_path(&output).exists()); + } + + #[test] + fn cancellation_during_transcode_kills_child_and_cleans_partial() { + assert_proxy_tools_available(); + + let temp = tempfile::tempdir().expect("proxy test directory"); + let source = temp.path().join("source.mp4"); + let output = temp.path().join("media/proxies/cancelled.mp4"); + make_video(&source, 8, "1920x1080"); + let cancel = MediaCancelToken::new(); + let cancel_from_progress = cancel.clone(); + let progress: ProxyProgressCallback = Arc::new(move |done, _| { + if done == 500 { + cancel_from_progress.cancel(); + } + }); + + let result = create_proxy( + ProxyRequest { + source: &source, + output: &output, + max_size: (320, 180), + }, + &cancel, + Some(progress), + ); + + assert!(matches!(result, Err(MediaError::Cancelled))); + assert_eq!(cancel.spawned_child_count(), 1); + assert!(!output.exists()); + assert!(!partial_path(&output).exists()); + } + + #[test] + fn changed_source_fails_identity_check_and_cleans_partial() { + assert_proxy_tools_available(); + + let temp = tempfile::tempdir().expect("proxy test directory"); + let source = temp.path().join("source.mp4"); + let output = temp.path().join("media/proxies/source-proxy.mp4"); + make_video(&source, 1, "640x360"); + let source_to_change = source.clone(); + let progress: ProxyProgressCallback = Arc::new(move |done, _| { + if done == 900 { + use std::io::Write; + let mut source = fs::OpenOptions::new() + .append(true) + .open(&source_to_change) + .expect("open source for identity change"); + source + .write_all(b"changed-after-transcode") + .expect("change source identity"); + } + }); + + let result = create_proxy( + ProxyRequest { + source: &source, + output: &output, + max_size: (320, 180), + }, + &MediaCancelToken::new(), + Some(progress), + ); + + assert!(matches!(result, Err(MediaError::Checksum(_)))); + assert!(!output.exists()); + assert!(!partial_path(&output).exists()); + } + + #[test] + fn pathname_replacement_a_to_b_to_a_cannot_change_the_retained_source() { + assert_proxy_tools_available(); + + let temp = tempfile::tempdir().expect("proxy test directory"); + let source = temp.path().join("source.mp4"); + let replacement = temp.path().join("replacement.mp4"); + let parked_source = temp.path().join("retained-source.mp4"); + let output = temp.path().join("media/proxies/source-proxy.mp4"); + make_video(&source, 1, "640x360"); + make_video(&replacement, 1, "360x640"); + let source_for_callback = source.clone(); + let replacement_for_callback = replacement.clone(); + let parked_for_callback = parked_source.clone(); + let replaced = Arc::new(AtomicBool::new(false)); + let restored = Arc::new(AtomicBool::new(false)); + let callback_replaced = Arc::clone(&replaced); + let callback_restored = Arc::clone(&restored); + let progress: ProxyProgressCallback = Arc::new(move |done, _| { + if done == 100 && !callback_replaced.swap(true, Ordering::AcqRel) { + fs::rename(&source_for_callback, &parked_for_callback) + .expect("park original source pathname"); + fs::rename(&replacement_for_callback, &source_for_callback) + .expect("replace source pathname with B"); + } + if done == 900 && !callback_restored.swap(true, Ordering::AcqRel) { + fs::rename(&source_for_callback, &replacement_for_callback) + .expect("restore replacement pathname"); + fs::rename(&parked_for_callback, &source_for_callback) + .expect("restore original source pathname A"); + } + }); + + let result = create_proxy( + ProxyRequest { + source: &source, + output: &output, + max_size: (320, 320), + }, + &MediaCancelToken::new(), + Some(progress), + ) + .expect("create proxy from retained source A"); + + assert!(replaced.load(Ordering::Acquire)); + assert!(restored.load(Ordering::Acquire)); + assert_eq!((result.width, result.height), (320, 180)); + let metadata = probe(&output).expect("probe retained-source proxy"); + assert_eq!((metadata.width, metadata.height), (Some(320), Some(180))); + } + + #[test] + fn cancellation_at_verification_probe_and_prepublication_cleans_partial() { + assert_proxy_tools_available(); + + for cancellation_progress in [910, 950, 990] { + let temp = tempfile::tempdir().expect("proxy test directory"); + let source = temp.path().join("source.mp4"); + let output = temp.path().join("media/proxies/source-proxy.mp4"); + make_video(&source, 1, "640x360"); + let cancel = MediaCancelToken::new(); + let cancel_from_progress = cancel.clone(); + let progress: ProxyProgressCallback = Arc::new(move |done, _| { + if done == cancellation_progress { + cancel_from_progress.cancel(); + } + }); + + let result = create_proxy( + ProxyRequest { + source: &source, + output: &output, + max_size: (320, 180), + }, + &cancel, + Some(progress), + ); + + assert!( + matches!(result, Err(MediaError::Cancelled)), + "phase {cancellation_progress} did not return Cancelled" + ); + assert!(!output.exists()); + assert!(!partial_path(&output).exists()); + } + } + + #[test] + fn destination_appearing_after_final_check_is_preserved_and_partial_is_cleaned() { + assert_proxy_tools_available(); + + let temp = tempfile::tempdir().expect("proxy test directory"); + let source = temp.path().join("source.mp4"); + let output = temp.path().join("media/proxies/source-proxy.mp4"); + make_video(&source, 1, "640x360"); + let output_to_claim = output.clone(); + let progress: ProxyProgressCallback = Arc::new(move |done, _| { + if done == 990 { + fs::write(&output_to_claim, b"competing-writer").expect("claim proxy destination"); + } + }); + + let result = create_proxy( + ProxyRequest { + source: &source, + output: &output, + max_size: (320, 180), + }, + &MediaCancelToken::new(), + Some(progress), + ); + + assert!(matches!(result, Err(MediaError::Ffmpeg(_)))); + assert_eq!( + fs::read(&output).expect("read competing output"), + b"competing-writer" + ); + assert!(!partial_path(&output).exists()); + } + + #[test] + fn verified_partial_rebound_at_prepublication_cannot_change_output_or_delete_replacement() { + assert_proxy_tools_available(); + + let temp = tempfile::tempdir().expect("proxy test directory"); + let source = temp.path().join("source.mp4"); + let replacement = temp.path().join("replacement.mp4"); + let output = temp.path().join("media/proxies/source-proxy.mp4"); + make_video(&source, 1, "640x360"); + make_video(&replacement, 1, "360x640"); + let partial = partial_path(&output); + let replacement_bytes = fs::read(&replacement).expect("read replacement fixture"); + let rebound = Arc::new(AtomicBool::new(false)); + let rebound_from_callback = Arc::clone(&rebound); + let partial_for_callback = partial.clone(); + let replacement_for_callback = replacement.clone(); + let progress: ProxyProgressCallback = Arc::new(move |done, _| { + if done == 990 && !rebound_from_callback.swap(true, Ordering::AcqRel) { + fs::copy(&replacement_for_callback, &partial_for_callback) + .expect("introduce an attacker-controlled ambient partial"); + } + }); + + let result = create_proxy( + ProxyRequest { + source: &source, + output: &output, + max_size: (320, 320), + }, + &MediaCancelToken::new(), + Some(progress), + ) + .expect("publish the retained verified artifact"); + + assert!(rebound.load(Ordering::Acquire)); + assert_eq!((result.width, result.height), (320, 180)); + let published = probe(&output).expect("probe published retained artifact"); + assert_eq!((published.width, published.height), (Some(320), Some(180))); + assert_eq!( + fs::read(&partial).expect("attacker replacement must remain untouched"), + replacement_bytes + ); + } + + #[test] + fn ffmpeg_output_is_never_exposed_at_the_ambient_partial_path() { + assert_proxy_tools_available(); + + let temp = tempfile::tempdir().expect("proxy test directory"); + let source = temp.path().join("source.mp4"); + let output = temp.path().join("media/proxies/source-proxy.mp4"); + make_video(&source, 1, "640x360"); + let public_partial = partial_path(&output); + let observed_public_partial = Arc::new(AtomicBool::new(false)); + let observed_from_callback = Arc::clone(&observed_public_partial); + let partial_from_callback = public_partial.clone(); + let progress: ProxyProgressCallback = Arc::new(move |done, _| { + if done == 900 && partial_from_callback.exists() { + observed_from_callback.store(true, Ordering::Release); + } + }); + + create_proxy( + ProxyRequest { + source: &source, + output: &output, + max_size: (320, 180), + }, + &MediaCancelToken::new(), + Some(progress), + ) + .expect("create proxy entirely through private staging"); + + assert!(output.is_file()); + assert!( + !observed_public_partial.load(Ordering::Acquire), + "FFmpeg output was exposed in the ambient output directory" + ); + assert!(!public_partial.exists()); + } + + #[cfg(unix)] + #[test] + fn rebound_fifo_does_not_block_or_get_deleted_on_publication_error() { + use std::ffi::CString; + use std::os::unix::ffi::OsStrExt; + use std::os::unix::fs::FileTypeExt; + use std::time::Instant; + + assert_proxy_tools_available(); + + let temp = tempfile::tempdir().expect("proxy test directory"); + let source = temp.path().join("source.mp4"); + let output = temp.path().join("media/proxies/source-proxy.mp4"); + make_video(&source, 1, "640x360"); + let partial = partial_path(&output); + let rebound = Arc::new(AtomicBool::new(false)); + let rebound_from_callback = Arc::clone(&rebound); + let partial_for_callback = partial.clone(); + let output_for_callback = output.clone(); + let progress: ProxyProgressCallback = Arc::new(move |done, _| { + if done == 990 && !rebound_from_callback.swap(true, Ordering::AcqRel) { + let fifo = CString::new(partial_for_callback.as_os_str().as_bytes()) + .expect("FIFO path has no NUL"); + assert_eq!(unsafe { libc::mkfifo(fifo.as_ptr(), 0o600) }, 0); + fs::write(&output_for_callback, b"competing-writer") + .expect("claim proxy destination"); + } + }); + + let started = Instant::now(); + let result = create_proxy( + ProxyRequest { + source: &source, + output: &output, + max_size: (320, 320), + }, + &MediaCancelToken::new(), + Some(progress), + ); + + assert!(matches!(result, Err(MediaError::Ffmpeg(_)))); + assert!(rebound.load(Ordering::Acquire)); + assert!( + started.elapsed() < Duration::from_secs(5), + "cleanup followed or blocked on the rebound FIFO" + ); + assert_eq!( + fs::read(&output).expect("read competing output"), + b"competing-writer" + ); + assert!(fs::symlink_metadata(&partial) + .expect("rebound FIFO must remain") + .file_type() + .is_fifo()); + } + + #[cfg(unix)] + #[test] + fn initial_source_fifo_and_symlink_are_rejected_without_blocking() { + use std::ffi::CString; + use std::os::unix::ffi::OsStrExt; + use std::os::unix::fs::symlink; + use std::time::Instant; + + let temp = tempfile::tempdir().expect("proxy test directory"); + let regular = temp.path().join("regular.mp4"); + let fifo = temp.path().join("source.fifo"); + let link = temp.path().join("source-link.mp4"); + fs::write(®ular, b"not opened through aliases").expect("write regular fixture"); + symlink(®ular, &link).expect("create source symlink"); + let fifo_name = CString::new(fifo.as_os_str().as_bytes()).expect("FIFO path has no NUL"); + assert_eq!(unsafe { libc::mkfifo(fifo_name.as_ptr(), 0o600) }, 0); + + for source in [&fifo, &link] { + let output = temp.path().join(format!( + "{}.proxy.mp4", + source.file_name().expect("source leaf").to_string_lossy() + )); + let started = Instant::now(); + let result = create_proxy( + ProxyRequest { + source, + output: &output, + max_size: (320, 180), + }, + &MediaCancelToken::new(), + None, + ); + + assert!(matches!(result, Err(MediaError::Io(_)))); + assert!( + started.elapsed() < Duration::from_secs(2), + "initial source open followed or blocked on {}", + source.display() + ); + assert!(!output.exists()); + } + } + + #[cfg(unix)] + #[test] + fn unlinked_source_namespace_does_not_break_the_retained_source() { + assert_proxy_tools_available(); + + let temp = tempfile::tempdir().expect("proxy test directory"); + let source = temp.path().join("source.mp4"); + let output = temp.path().join("media/proxies/source-proxy.mp4"); + make_video(&source, 1, "640x360"); + + let source_to_remove = source.clone(); + let progress: ProxyProgressCallback = Arc::new(move |done, _| { + if done == 900 { + fs::remove_file(&source_to_remove).expect("remove source before identity recheck"); + } + }); + let result = create_proxy( + ProxyRequest { + source: &source, + output: &output, + max_size: (320, 180), + }, + &MediaCancelToken::new(), + Some(progress), + ) + .expect("retained source remains readable after unlink"); + + assert_eq!((result.width, result.height), (320, 180)); + assert!(!source.exists()); + assert!(output.is_file()); + assert!(!partial_path(&output).exists()); + } +} diff --git a/crates/opentake-media/src/search/ort_embedder.rs b/crates/opentake-media/src/search/ort_embedder.rs index d87acc00..6ae1859e 100644 --- a/crates/opentake-media/src/search/ort_embedder.rs +++ b/crates/opentake-media/src/search/ort_embedder.rs @@ -109,6 +109,7 @@ impl OrtEmbedder { } fn build_session(path: &Path) -> Result { + crate::initialize_ort_backend(); let builder = Session::builder().map_err(|e| MediaError::ModelInstall(format!("ort: {e}")))?; // Default EP set; ort falls back to CPU when an accelerator is unavailable. let builder = builder diff --git a/crates/opentake-media/src/thumbnail/mod.rs b/crates/opentake-media/src/thumbnail/mod.rs index bdc5f9e2..0c8a7ca3 100644 --- a/crates/opentake-media/src/thumbnail/mod.rs +++ b/crates/opentake-media/src/thumbnail/mod.rs @@ -139,6 +139,25 @@ pub fn video_thumbnails( /// Port of `makeImageThumbnail` (`:152-163`). pub fn image_thumbnail(path: &Path, max_pixel: u32) -> Result { let img = image::open(path).map_err(|e| MediaError::Decode(format!("image open: {e}")))?; + image_thumbnail_from_image(img, max_pixel) +} + +/// Decode a single image from an arbitrary reader to the same scaled RGBA +/// thumbnail as [`image_thumbnail`]. Used by retained no-follow asset reads +/// where the caller holds an authority-opened file instead of a pathname. +pub fn image_thumbnail_reader(reader: R, max_pixel: u32) -> Result +where + R: std::io::BufRead + std::io::Seek, +{ + let img = image::ImageReader::new(reader) + .with_guessed_format() + .map_err(|e| MediaError::Decode(format!("image format guess: {e}")))? + .decode() + .map_err(|e| MediaError::Decode(format!("image decode: {e}")))?; + image_thumbnail_from_image(img, max_pixel) +} + +fn image_thumbnail_from_image(img: image::DynamicImage, max_pixel: u32) -> Result { let rgba = img.to_rgba8(); let (w, h) = (rgba.width(), rgba.height()); let (nw, nh) = fit_within(w, h, (max_pixel, max_pixel)); diff --git a/crates/opentake-media/src/thumbnail/project.rs b/crates/opentake-media/src/thumbnail/project.rs index d3ac14cf..aaa3fd14 100644 --- a/crates/opentake-media/src/thumbnail/project.rs +++ b/crates/opentake-media/src/thumbnail/project.rs @@ -223,6 +223,8 @@ mod tests { source_height: Some(4), source_fps: None, has_audio: None, + color: None, + proxy: None, folder_id: None, cached_remote_url: None, cached_remote_url_expires_at: None, diff --git a/crates/opentake-media/src/transcribe/captions.rs b/crates/opentake-media/src/transcribe/captions.rs index ebeb88ee..bae57e64 100644 --- a/crates/opentake-media/src/transcribe/captions.rs +++ b/crates/opentake-media/src/transcribe/captions.rs @@ -41,7 +41,7 @@ use opentake_domain::Clip; -use super::{TranscriptionResult, TranscriptionSegment}; +use super::{TranscriptionResult, TranscriptionSegment, TranscriptionWord}; /// Per-phrase floor display duration, in **seconds**. 1:1 with upstream /// `AppTheme.Caption.minDisplayDuration = 0.7` (`AppTheme.swift:249`), the @@ -395,8 +395,7 @@ pub fn caption_specs bool>( if clips.is_empty() { continue; } - let seg_phrases: Vec = result - .segments + let seg_phrases: Vec = visible_caption_segments(result, &clips, fps) .iter() .flat_map(|seg| phrases(seg, fits, MIN_DISPLAY_DURATION_SECS)) .collect(); @@ -434,6 +433,151 @@ pub fn caption_specs bool>( out } +/// Rebuild source segments from the words that are still visible through the +/// current clip fragments. A cached source segment keeps its original text even +/// after a ripple cut; using it verbatim would resurrect deleted fillers in +/// newly generated captions. Uncut segments retain their original punctuation, +/// while cut segments are sliced from that original text using the surviving +/// Whisper token sequence (including subword tokens such as `synchron` + +/// `ized`). +fn visible_caption_segments( + result: &TranscriptionResult, + clips: &[&CaptionTarget<'_>], + fps: i32, +) -> Vec { + let fps_d = fps as f64; + let mut rebuilt = Vec::new(); + for segment in &result.segments { + let segment_words = result + .words + .iter() + .filter(|word| match (word.start, word.end) { + (Some(start), Some(end)) => { + let midpoint = (start + end) / 2.0; + midpoint >= segment.start && midpoint < segment.end + } + _ => false, + }) + .collect::>(); + if segment_words.is_empty() { + rebuilt.push(segment.clone()); + continue; + } + + let owners = segment_words + .iter() + .map(|word| { + let (start, end) = (word.start?, word.end?); + let midpoint_frame = (start + end) / 2.0 * fps_d; + clips.iter().position(|target| { + let (visible_start, visible_end) = visible_source_span(target.clip); + visible_start <= midpoint_frame && midpoint_frame < visible_end + }) + }) + .collect::>(); + + if owners.iter().all(Option::is_some) && owners.windows(2).all(|pair| pair[0] == pair[1]) { + rebuilt.push(segment.clone()); + continue; + } + + let token_ranges = token_byte_ranges(&segment.text, &segment_words); + let mut index = 0; + while index < segment_words.len() { + let Some(owner) = owners[index] else { + index += 1; + continue; + }; + let run_start = index; + index += 1; + while index < segment_words.len() && owners[index] == Some(owner) { + index += 1; + } + let run_end = index; + let text = token_ranges + .as_ref() + .and_then(|ranges| slice_token_run(&segment.text, ranges, run_start, run_end)) + .unwrap_or_else(|| detokenize(&segment_words[run_start..run_end])); + let start = segment_words[run_start].start.unwrap_or(segment.start); + let end = segment_words[run_end - 1].end.unwrap_or(segment.end); + if !text.trim().is_empty() && end > start { + rebuilt.push(TranscriptionSegment { text, start, end }); + } + } + } + rebuilt +} + +fn token_byte_ranges(text: &str, words: &[&TranscriptionWord]) -> Option> { + let mut normalized = Vec::new(); + for (byte_start, ch) in text.char_indices() { + if !ch.is_alphanumeric() { + continue; + } + let byte_end = byte_start + ch.len_utf8(); + for folded in ch.to_lowercase() { + normalized.push((folded, byte_start, byte_end)); + } + } + + let mut cursor = 0; + let mut ranges = Vec::with_capacity(words.len()); + for word in words { + let needle = word + .text + .chars() + .filter(|ch| ch.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect::>(); + if needle.is_empty() { + return None; + } + if cursor > normalized.len() || needle.len() > normalized.len() - cursor { + return None; + } + let position = (cursor..=normalized.len().saturating_sub(needle.len())).find(|start| { + normalized[*start..*start + needle.len()] + .iter() + .map(|entry| entry.0) + .eq(needle.iter().copied()) + })?; + let end_position = position + needle.len() - 1; + ranges.push((normalized[position].1, normalized[end_position].2)); + cursor = position + needle.len(); + } + Some(ranges) +} + +fn slice_token_run( + text: &str, + ranges: &[(usize, usize)], + start: usize, + end: usize, +) -> Option { + let byte_start = ranges.get(start)?.0; + let mut byte_end = ranges.get(end.checked_sub(1)?)?.1; + let next_start = ranges.get(end).map(|range| range.0).unwrap_or(text.len()); + let punctuation_start = byte_end; + // Preserve punctuation attached to the final surviving token, but not the + // whitespace leading into a removed token. + for (offset, ch) in text[punctuation_start..next_start].char_indices() { + if ch.is_whitespace() || ch.is_alphanumeric() { + break; + } + byte_end = punctuation_start + offset + ch.len_utf8(); + } + Some(text.get(byte_start..byte_end)?.trim().to_string()) +} + +fn detokenize(words: &[&TranscriptionWord]) -> String { + words + .iter() + .map(|word| word.text.trim()) + .filter(|text| !text.is_empty()) + .collect::>() + .join(" ") +} + /// The clip whose visible source window overlaps phrase `p` the most, but only /// when the overlap is real (`> 0`) and covers at least half the phrase. 1:1 port /// of `bestClip(for:among:)` (`EditorViewModel+Captions.swift:186-195`). @@ -786,6 +930,45 @@ mod tests { assert_eq!(out[0].start_frame, 0); } + #[test] + fn caption_specs_rebuild_cut_segment_from_visible_words() { + // A three-frame ripple cut removes only "Um" between two fragments of + // the same source. A cached segment still contains the old full text; + // regenerated captions must use the surviving words and preserve a + // split Whisper token as the original "synchronized" spelling. + let before = clip("before", 0, 151, 0, 1.0); + let after = clip("after", 151, 746, 154, 1.0); + let transcript = result( + vec![ + word("Um", 5.0, 5.1), + word("today", 5.2, 5.4), + word("synchron", 5.4, 5.7), + word("ized", 5.7, 5.9), + ], + vec![seg("Um, today synchronized", 5.0, 5.9)], + ); + let targets = vec![ + CaptionTarget { + clip_id: "before".into(), + track_id: "voice".into(), + clip: &before, + transcript: Some(&transcript), + }, + CaptionTarget { + clip_id: "after".into(), + track_id: "voice".into(), + clip: &after, + transcript: Some(&transcript), + }, + ]; + + let out = caption_specs(&targets, 30, CaptionCase::Auto, "g", &fits_words(5)); + + assert_eq!(out.len(), 1); + assert_eq!(out[0].content, "today synchronized"); + assert!(!out[0].content.contains("Um")); + } + #[test] fn caption_specs_empty_transcript_yields_nothing() { let c = clip("c1", 0, 300, 0, 1.0); diff --git a/crates/opentake-media/src/transcribe/whisper.rs b/crates/opentake-media/src/transcribe/whisper.rs index f0cb067c..2100aa94 100644 --- a/crates/opentake-media/src/transcribe/whisper.rs +++ b/crates/opentake-media/src/transcribe/whisper.rs @@ -50,6 +50,203 @@ fn cs_to_secs(cs: i64) -> f64 { cs as f64 / 100.0 } +// whisper.cpp's experimental token timestamps can spread the first words of +// an utterance backwards across a long leading pause (the segment timestamp +// token commonly starts at the pause itself). That is dangerous for edit +// tools: deleting a filler word would then delete silence, or an earlier word, +// instead of the spoken filler. Tighten only *silent segment edges* using the +// same PCM that Whisper consumed, then preserve token order by mapping the +// original token positions into the audible interval. Internal pauses are +// intentionally untouched. +fn align_segment_to_speech( + pcm: &PcmBuffer, + segment: &mut TranscriptionSegment, + words: &mut [TranscriptionWord], +) { + const WINDOW_SECS: f64 = 0.020; + const HOP_SECS: f64 = 0.010; + const ABSOLUTE_RMS_FLOOR: f64 = 0.001; // -60 dBFS + const RELATIVE_TO_PEAK: f64 = 0.02; // -34 dB from this segment's peak + const EDGE_PADDING_SECS: f64 = 0.040; + const MIN_EDGE_TRIM_SECS: f64 = 0.080; + + let sample_rate = pcm.spec.sample_rate as usize; + let old_start = segment.start.max(0.0); + let old_end = segment.end.min(pcm.duration_secs()); + let old_duration = old_end - old_start; + if sample_rate == 0 || old_duration <= 0.0 || words.is_empty() { + return; + } + + let range_start = (old_start * sample_rate as f64).floor() as usize; + let range_end = ((old_end * sample_rate as f64).ceil() as usize).min(pcm.samples_f32.len()); + if range_end <= range_start { + return; + } + + let window = ((WINDOW_SECS * sample_rate as f64).round() as usize).max(1); + let hop = ((HOP_SECS * sample_rate as f64).round() as usize).max(1); + let mut energy_windows = Vec::new(); + let mut cursor = range_start; + while cursor < range_end { + let end = (cursor + window).min(range_end); + let samples = &pcm.samples_f32[cursor..end]; + let square_sum = samples + .iter() + .map(|sample| f64::from(*sample) * f64::from(*sample)) + .sum::(); + let rms = (square_sum / samples.len() as f64).sqrt(); + energy_windows.push((cursor, end, rms)); + cursor = cursor.saturating_add(hop); + } + + let peak_rms = energy_windows + .iter() + .map(|(_, _, rms)| *rms) + .fold(0.0_f64, f64::max); + if peak_rms <= ABSOLUTE_RMS_FLOOR { + return; + } + let threshold = ABSOLUTE_RMS_FLOOR.max(peak_rms * RELATIVE_TO_PEAK); + let Some(first_audible) = energy_windows + .iter() + .position(|(_, _, rms)| *rms > threshold) + else { + return; + }; + let last_audible = energy_windows + .iter() + .rposition(|(_, _, rms)| *rms > threshold) + .unwrap_or(first_audible); + + let detected_start = energy_windows[first_audible].0 as f64 / sample_rate as f64; + let detected_end = energy_windows[last_audible].1 as f64 / sample_rate as f64; + let mut new_start = (detected_start - EDGE_PADDING_SECS).max(old_start); + let mut new_end = (detected_end + EDGE_PADDING_SECS).min(old_end); + if new_start - old_start < MIN_EDGE_TRIM_SECS { + new_start = old_start; + } + if old_end - new_end < MIN_EDGE_TRIM_SECS { + new_end = old_end; + } + if new_end <= new_start || (new_start == old_start && new_end == old_end) { + return; + } + + let new_duration = new_end - new_start; + let remap = |time: f64| { + let progress = ((time - old_start) / old_duration).clamp(0.0, 1.0); + new_start + progress * new_duration + }; + for word in words { + if let Some(start) = word.start { + word.start = Some(remap(start)); + } + if let Some(end) = word.end { + word.end = Some(remap(end)); + } + if let (Some(start), Some(end)) = (word.start, word.end) { + if end < start { + word.end = Some(start); + } + } + } + segment.start = new_start; + segment.end = new_end; +} + +/// Keep edit-facing word rows lexical and give Whisper's zero-duration lexical +/// tokens a real, non-overlapping interval. Whisper commonly emits `You` +/// `[t,t]` followed by `know` `[t,t+n]`; dropping the first span makes the +/// multi-word filler impossible to review or remove. Punctuation-only tokens +/// are not independently editable words, so their time is available to the +/// neighboring lexical token. +fn normalize_word_timings(segment: &TranscriptionSegment, words: &mut Vec) { + const EPSILON: f64 = 1e-9; + + words.retain(|word| word.text.chars().any(char::is_alphanumeric)); + let mut index = 0; + while index < words.len() { + let (Some(start), Some(end)) = (words[index].start, words[index].end) else { + index += 1; + continue; + }; + if end > start + EPSILON { + index += 1; + continue; + } + + // A same-start run with a later positive interval (for example + // `You [t,t]`, `know [t,t+n]`) represents a single Whisper interval + // shared by multiple lexical tokens. Split it by character weight. + let mut run_end = index + 1; + let mut shared_end = start; + while run_end < words.len() { + let (Some(next_start), Some(next_end)) = (words[run_end].start, words[run_end].end) + else { + break; + }; + if (next_start - start).abs() > EPSILON { + break; + } + shared_end = shared_end.max(next_end); + run_end += 1; + } + if shared_end > start + EPSILON { + let total_weight = words[index..run_end] + .iter() + .map(|word| { + word.text + .chars() + .filter(|c| c.is_alphanumeric()) + .count() + .max(1) + }) + .sum::() as f64; + let mut cursor = start; + for word in &mut words[index..run_end] { + let weight = word + .text + .chars() + .filter(|c| c.is_alphanumeric()) + .count() + .max(1) as f64; + let next = if (cursor - shared_end).abs() <= EPSILON { + shared_end + } else { + (cursor + (shared_end - start) * weight / total_weight).min(shared_end) + }; + word.start = Some(cursor); + word.end = Some(next); + cursor = next; + } + if let Some(last) = words.get_mut(run_end - 1) { + last.end = Some(shared_end); + } + index = run_end; + continue; + } + + // A lone zero-duration token owns the gap to the next lexical token. + let next_start = words[index + 1..] + .iter() + .filter_map(|word| word.start) + .find(|next| *next > start + EPSILON) + .unwrap_or(segment.end); + if next_start > start + EPSILON { + words[index].end = Some(next_start.min(segment.end)); + } else if index > 0 { + let previous_end = words[index - 1].end.unwrap_or(segment.start); + let fallback_start = (segment.end - 0.08).max(previous_end); + if segment.end > fallback_start + EPSILON { + words[index].start = Some(fallback_start); + words[index].end = Some(segment.end); + } + } + index += 1; + } +} + impl Transcriber for WhisperTranscriber { fn transcribe_pcm( &self, @@ -100,16 +297,13 @@ impl Transcriber for WhisperTranscriber { // they only ever show up reconstructed at the segment level. // Excluded from `full_text` too, so the plain-text summary stays // consistent with `segments`. - if !trimmed.is_empty() && !super::is_non_speech_marker(trimmed) { + let keep_segment = !trimmed.is_empty() && !super::is_non_speech_marker(trimmed); + if keep_segment { full_text.push_str(&seg_text); - segments.push(TranscriptionSegment { - text: trimmed.to_string(), - start: cs_to_secs(t0), - end: cs_to_secs(t1), - }); } let n_tokens = state.full_n_tokens(i).unwrap_or(0); + let mut segment_words = Vec::new(); for j in 0..n_tokens { let tok_text = match state.full_get_token_text(i, j) { Ok(t) => t, @@ -131,12 +325,23 @@ impl Transcriber for WhisperTranscriber { Some(d) => (Some(cs_to_secs(d.t0)), Some(cs_to_secs(d.t1))), None => (None, None), }; - words.push(TranscriptionWord { + segment_words.push(TranscriptionWord { text: trimmed_tok.to_string(), start, end, }); } + if keep_segment { + let mut segment = TranscriptionSegment { + text: trimmed.to_string(), + start: cs_to_secs(t0), + end: cs_to_secs(t1), + }; + align_segment_to_speech(pcm, &mut segment, &mut segment_words); + normalize_word_timings(&segment, &mut segment_words); + segments.push(segment); + words.extend(segment_words); + } } let language = opts @@ -156,10 +361,116 @@ impl Transcriber for WhisperTranscriber { #[cfg(test)] mod tests { use super::*; + use crate::decode::pcm::{PcmFormat, PcmSpec}; #[test] fn centiseconds_convert_to_seconds() { assert!((cs_to_secs(150) - 1.5).abs() < 1e-9); assert_eq!(cs_to_secs(0), 0.0); } + + fn pcm(samples: Vec, sample_rate: u32) -> PcmBuffer { + PcmBuffer { + spec: PcmSpec { + sample_rate, + channels: 1, + format: PcmFormat::F32, + }, + samples_f32: samples, + } + } + + fn word(text: &str, start: f64, end: f64) -> TranscriptionWord { + TranscriptionWord { + text: text.into(), + start: Some(start), + end: Some(end), + } + } + + #[test] + fn silent_segment_edges_are_trimmed_and_words_remapped() { + let sample_rate = 1_000; + let mut samples = vec![0.0; 2 * sample_rate as usize]; + samples.extend(vec![0.5; 6 * sample_rate as usize]); + samples.extend(vec![0.0; 2 * sample_rate as usize]); + let pcm = pcm(samples, sample_rate); + let mut segment = TranscriptionSegment { + text: "one two three".into(), + start: 0.0, + end: 10.0, + }; + let mut words = vec![ + word("one", 1.0, 2.0), + word("two", 4.0, 5.0), + word("three", 8.0, 9.0), + ]; + + align_segment_to_speech(&pcm, &mut segment, &mut words); + + assert!((segment.start - 1.96).abs() < 0.02, "{:?}", segment); + assert!((segment.end - 8.05).abs() < 0.02, "{:?}", segment); + assert!(words[0].start.unwrap() >= segment.start); + assert!(words[2].end.unwrap() <= segment.end); + assert!(words.windows(2).all(|pair| { + pair[0].start.unwrap() <= pair[1].start.unwrap() + && pair[0].end.unwrap() <= pair[1].end.unwrap() + })); + } + + #[test] + fn fully_audible_segment_keeps_original_timestamps() { + let pcm = pcm(vec![0.5; 2_000], 1_000); + let mut segment = TranscriptionSegment { + text: "hello".into(), + start: 0.0, + end: 2.0, + }; + let mut words = vec![word("hello", 0.2, 1.8)]; + + align_segment_to_speech(&pcm, &mut segment, &mut words); + + assert_eq!(segment.start, 0.0); + assert_eq!(segment.end, 2.0); + assert_eq!(words[0].start, Some(0.2)); + assert_eq!(words[0].end, Some(1.8)); + } + + #[test] + fn zero_duration_words_receive_reviewable_non_overlapping_spans() { + let segment = TranscriptionSegment { + text: "Um, today. You know.".into(), + start: 4.8, + end: 12.5, + }; + let mut words = vec![ + word("Um", 5.0, 5.0), + word(",", 5.0, 5.2), + word("today", 5.2, 5.7), + word("You", 11.8, 11.8), + word("know", 11.8, 12.3), + word(".", 12.3, 12.4), + ]; + + normalize_word_timings(&segment, &mut words); + + assert_eq!( + words + .iter() + .map(|word| word.text.as_str()) + .collect::>(), + vec!["Um", "today", "You", "know"] + ); + assert_eq!(words[0].start, Some(5.0)); + assert_eq!(words[0].end, Some(5.2)); + assert_eq!(words[2].start, Some(11.8)); + assert!(words[2].end.unwrap() > 11.8); + assert_eq!(words[2].end, words[3].start); + assert_eq!(words[3].end, Some(12.3)); + assert!(words.windows(2).all(|pair| { + pair[0].end.unwrap() <= pair[1].start.unwrap() + && pair[0].end.unwrap() > pair[0].start.unwrap() + })); + assert!(words.last().unwrap().end.unwrap() > words.last().unwrap().start.unwrap()); + } } diff --git a/crates/opentake-media/tests/denoise.rs b/crates/opentake-media/tests/denoise.rs new file mode 100644 index 00000000..42426ebe --- /dev/null +++ b/crates/opentake-media/tests/denoise.rs @@ -0,0 +1,117 @@ +use opentake_domain::{AudioDenoise, DenoiseMode}; +use opentake_media::analysis::{denoise_interleaved, DenoiseError}; +use opentake_media::MediaCancelToken; + +const SAMPLE_RATE: u32 = 48_000; + +fn speech_fixture(seconds: usize) -> Vec { + (0..SAMPLE_RATE as usize * seconds) + .map(|index| { + let time = index as f32 / SAMPLE_RATE as f32; + let phrase = if (time * 2.5).fract() < 0.68 { + 1.0 + } else { + 0.0 + }; + let attack = ((time * 2.5).fract() * 12.0).min(1.0); + phrase + * attack + * ((std::f32::consts::TAU * 173.0 * time).sin() * 0.24 + + (std::f32::consts::TAU * 346.0 * time).sin() * 0.08 + + (std::f32::consts::TAU * 691.0 * time).sin() * 0.035) + }) + .collect() +} + +fn noisy_fixture(clean: &[f32]) -> Vec { + let mut state = 0x5eed_1234_u32; + clean + .iter() + .map(|sample| { + state ^= state << 13; + state ^= state >> 17; + state ^= state << 5; + let white = (state as f64 / u32::MAX as f64 * 2.0 - 1.0) as f32; + sample + white * 0.075 + }) + .collect() +} + +fn snr_db(clean: &[f32], candidate: &[f32]) -> f64 { + let signal = clean + .iter() + .map(|value| f64::from(*value).powi(2)) + .sum::(); + let error = clean + .iter() + .zip(candidate) + .map(|(expected, actual)| f64::from(*actual - *expected).powi(2)) + .sum::(); + 10.0 * (signal / error.max(1.0e-20)).log10() +} + +#[test] +fn deterministic_noise_fixture_and_bypass() { + let clean = speech_fixture(5); + let noisy = noisy_fixture(&clean); + let source_before = noisy.clone(); + let config = AudioDenoise { + mode: DenoiseMode::Adaptive, + strength: 0.9, + preview_enabled: true, + }; + let processed = denoise_interleaved( + &noisy, + 1, + SAMPLE_RATE, + config, + &MediaCancelToken::new(), + None, + ) + .expect("denoise deterministic fixture"); + + let input_snr = snr_db(&clean, &noisy); + let output_snr = snr_db(&clean, &processed); + assert!( + output_snr >= input_snr + 3.0, + "input SNR={input_snr:.2} dB output SNR={output_snr:.2} dB" + ); + assert!(processed.iter().all(|sample| sample.abs() <= 1.0)); + let input_peak = noisy + .iter() + .map(|sample| sample.abs()) + .fold(0.0_f32, f32::max); + let output_peak = processed + .iter() + .map(|sample| sample.abs()) + .fold(0.0_f32, f32::max); + assert!( + output_peak <= input_peak + 1.0e-6, + "denoise must not introduce a new peak: input={input_peak:.6} output={output_peak:.6}" + ); + assert_eq!( + noisy, source_before, + "processing must not mutate source PCM" + ); + + let bypass = denoise_interleaved( + &noisy, + 1, + SAMPLE_RATE, + AudioDenoise { + strength: 0.0, + ..config + }, + &MediaCancelToken::new(), + None, + ) + .expect("bypass"); + assert_eq!(bypass, noisy, "zero strength is a bit-exact bypass"); + + let cancelled = MediaCancelToken::new(); + cancelled.cancel(); + assert!(matches!( + denoise_interleaved(&noisy, 1, SAMPLE_RATE, config, &cancelled, None), + Err(DenoiseError::Cancelled) + )); +} diff --git a/crates/opentake-media/tests/facade_contract.rs b/crates/opentake-media/tests/facade_contract.rs new file mode 100644 index 00000000..7fd1876f --- /dev/null +++ b/crates/opentake-media/tests/facade_contract.rs @@ -0,0 +1,161 @@ +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; + +use opentake_media::{ + AssetIndex, ExportPreset, ExportResolution, FrameRequest, Header, MediaEngine, PcmBuffer, + PcmFormat, PcmSpec, Row, TranscribeOptions, Transcriber, TranscriptionResult, VideoCodec, +}; + +struct FixtureTranscriber; + +impl Transcriber for FixtureTranscriber { + fn transcribe_pcm( + &self, + pcm: &PcmBuffer, + _opts: &TranscribeOptions, + ) -> opentake_media::Result { + Ok(TranscriptionResult { + text: format!("{} samples", pcm.samples_f32.len()), + language: Some("en".into()), + words: vec![], + segments: vec![], + }) + } +} + +fn manifest(crate_name: &str) -> String { + let workspace = Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .and_then(Path::parent) + .expect("media crate belongs to the workspace"); + std::fs::read_to_string(workspace.join("crates").join(crate_name).join("Cargo.toml")) + .expect("read workspace crate manifest") +} + +fn make_av_fixture(path: &Path) -> bool { + Command::new("ffmpeg") + .args([ + "-v", + "error", + "-f", + "lavfi", + "-i", + "color=c=0x336699:s=32x18:r=4", + "-f", + "lavfi", + "-i", + "sine=frequency=440:sample_rate=16000", + "-t", + "1", + "-c:v", + "mpeg4", + "-c:a", + "aac", + "-y", + ]) + .arg(path) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .is_ok_and(|status| status.success()) +} + +#[test] +fn all_services_are_reachable_only_through_facade_and_dependencies_stay_acyclic() { + // The production dependency direction is domain <- media. Neither the + // zero-IO domain leaf nor media imports core/render back upward; render may + // consume media at its adapter/test boundary without creating a cycle. + let media_manifest = manifest("opentake-media"); + let domain_manifest = manifest("opentake-domain"); + let render_manifest = manifest("opentake-render"); + let core_manifest = manifest("opentake-core"); + assert!(media_manifest.contains("opentake-domain = { workspace = true }")); + assert!(!media_manifest.contains("opentake-core")); + assert!(!media_manifest.contains("opentake-render")); + assert!(!domain_manifest.contains("opentake-media")); + assert!(render_manifest.contains("opentake-media = { workspace = true }")); + assert!(!core_manifest.contains("opentake-render")); + + let temp = tempfile::tempdir().unwrap(); + let engine = MediaEngine::new(temp.path().join("cache"), temp.path().join("models")); + + // Search is a real facade operation over the persisted index value model. + let indexes = vec![( + "asset-a".to_string(), + AssetIndex { + header: Header { + model: "fixture".into(), + model_version: 1, + sampler_version: 1, + dim: 2, + count: 1, + }, + rows: vec![Row { + time: 0.25, + shot_start: 0.0, + shot_end: 1.0, + }], + vectors: vec![1.0, 0.0], + }, + )]; + let hits = engine.search_visual(&[1.0, 0.0], &indexes, 20, 0.85, Some(0.05)); + assert_eq!(hits.len(), 1); + assert_eq!(hits[0].asset_id, "asset-a"); + + // The exact IO methods must compile on every platform. Environments without + // ffmpeg stop after the pure dependency/search assertions. + let source = temp.path().join("facade-source.mp4"); + if !make_av_fixture(&source) { + return; + } + + let probe = engine.probe(&source).unwrap(); + assert_eq!((probe.width, probe.height), (Some(32), Some(18))); + assert!(probe.has_audio && probe.has_video); + + let frame = engine + .decode_frame( + &source, + &FrameRequest { + time_secs: 0.25, + max_size: (32, 18), + tolerance_secs: 0.25, + apply_rotation: true, + }, + ) + .unwrap() + .1; + assert_eq!((frame.width, frame.height), (32, 18)); + + let pcm_spec = PcmSpec { + sample_rate: 16_000, + channels: 1, + format: PcmFormat::F32, + }; + let pcm = engine.extract_pcm(&source, &pcm_spec, None).unwrap(); + assert_eq!(pcm.spec, pcm_spec); + assert!((15_000..=16_500).contains(&pcm.samples_f32.len())); + + let transcript_cache = opentake_media::TranscriptCache::new(temp.path().join("cache")); + let transcript = engine + .transcribe(&source, true, None, &FixtureTranscriber, &transcript_cache) + .unwrap(); + assert!(transcript.text.ends_with(" samples")); + + let encoded = temp.path().join("facade-encoded.mp4"); + let mut encoder = engine + .video_encoder( + &encoded, + 32, + 18, + 4, + &ExportPreset::new(VideoCodec::H264, ExportResolution::P720), + ) + .unwrap(); + encoder.push_frame(&frame).unwrap(); + encoder.finish().unwrap(); + assert!(encoded.is_file()); + assert!(engine.probe(&encoded).unwrap().has_video); + + let _: PathBuf = engine.cache_root().to_path_buf(); +} diff --git a/crates/opentake-media/tests/ffmpeg_integration.rs b/crates/opentake-media/tests/ffmpeg_integration.rs index 6fbb169a..72ea035b 100644 --- a/crates/opentake-media/tests/ffmpeg_integration.rs +++ b/crates/opentake-media/tests/ffmpeg_integration.rs @@ -14,7 +14,7 @@ use opentake_media::decode::spawn_video_stream; use opentake_media::ffmpeg_status::{ffmpeg_available, ffprobe_available}; use opentake_media::{ decode_frame_at, encode, extract_pcm, probe, video_thumbnails, waveform, ExportPreset, - ExportResolution, FrameRequest, PcmFormat, PcmSpec, VideoCodec, VideoEncoder, + ExportResolution, FrameRequest, PcmFormat, PcmSpec, RgbaFrame, VideoCodec, VideoEncoder, VideoStreamRequest, }; @@ -426,6 +426,46 @@ fn encode_prores_roundtrip_produces_prores_mov() { encode_codec_roundtrip(VideoCodec::ProRes422, "mov", "prores"); } +#[test] +fn prores_4444_roundtrip_preserves_alpha_plane() { + if !ffmpeg_available() || !ffprobe_available() { + eprintln!("SKIP: ffmpeg/ffprobe unavailable"); + return; + } + let root = tempfile::tempdir().unwrap(); + let output = root.path().join("alpha.mov"); + let preset = ExportPreset::new(VideoCodec::ProRes4444, ExportResolution::P720); + let mut encoder = VideoEncoder::new(&output, 2, 2, 1, &preset).unwrap(); + encoder + .push_frame(&RgbaFrame { + width: 2, + height: 2, + rgba: vec![ + 255, 0, 0, 0, 0, 255, 0, 85, 0, 0, 255, 170, 255, 255, 255, 255, + ], + }) + .unwrap(); + encoder.finish().unwrap(); + let decoded = decode_frame_at( + &output, + &FrameRequest { + max_size: (2, 2), + tolerance_secs: 0.0, + ..FrameRequest::default() + }, + ) + .unwrap() + .1; + let alpha = decoded + .rgba + .chunks_exact(4) + .map(|pixel| pixel[3]) + .collect::>(); + for (actual, expected) in alpha.iter().zip([0_u8, 85, 170, 255]) { + assert!(actual.abs_diff(expected) <= 3, "{alpha:?}"); + } +} + #[test] #[ignore = "requires OPENTAKE_MAIN10_FIXTURE pointing at a real HEVC Main10 clip"] fn continuous_decode_scales_real_main10_frames_without_corruption() { diff --git a/crates/opentake-media/tests/hdr.rs b/crates/opentake-media/tests/hdr.rs new file mode 100644 index 00000000..8b40a438 --- /dev/null +++ b/crates/opentake-media/tests/hdr.rs @@ -0,0 +1,173 @@ +use opentake_domain::MediaColorMetadata; +use opentake_media::{ + decode_frame_at, hdr_tonemap_filter, parse_probe, probe, ExportPreset, ExportResolution, + FrameRequest, VideoCodec, VideoEncoder, +}; +use serde_json::json; + +#[test] +fn hdr_probe_and_sdr_delivery_policy_preserve_source_metadata() { + for (transfer, expected_token) in [("smpte2084", "smpte2084"), ("arib-std-b67", "arib-std-b67")] + { + let probe = parse_probe(&json!({ + "streams": [{ + "codec_type": "video", + "width": 3840, + "height": 2160, + "avg_frame_rate": "30/1", + "color_primaries": "bt2020", + "color_transfer": transfer, + "color_space": "bt2020nc", + "color_range": "tv" + }], + "format": {"duration": "5.0"} + })); + + let color = probe.color.expect("HDR metadata must survive probing"); + assert_eq!( + color, + MediaColorMetadata { + primaries: Some("bt2020".into()), + transfer: Some(transfer.into()), + matrix: Some("bt2020nc".into()), + range: Some("tv".into()), + } + ); + assert!(color.is_hdr()); + let filter = hdr_tonemap_filter(&color).expect("PQ/HLG must choose an explicit tonemap"); + if cfg!(target_os = "macos") { + assert!(filter.contains("scale_vt=")); + assert!(filter.contains("color_transfer=bt709")); + assert!(filter.contains("hwdownload,format=p010le")); + } else { + assert!(filter.contains(expected_token)); + assert!(filter.contains("tonemap=")); + assert!(filter.contains("p=bt709:t=bt709:m=bt709")); + } + } + + let preset = ExportPreset::new(VideoCodec::H265, ExportResolution::P1080); + let args = preset.color_args(); + assert!(args + .windows(2) + .any(|pair| pair == ["-color_primaries", "bt709"])); + assert!(args.windows(2).any(|pair| pair == ["-color_trc", "bt709"])); + assert!(args.windows(2).any(|pair| pair == ["-colorspace", "bt709"])); +} + +#[test] +fn sdr_or_unknown_transfer_does_not_apply_hdr_tonemapping() { + let sdr = MediaColorMetadata { + primaries: Some("bt709".into()), + transfer: Some("bt709".into()), + matrix: Some("bt709".into()), + range: Some("tv".into()), + }; + assert!(!sdr.is_hdr()); + assert_eq!(hdr_tonemap_filter(&sdr), None); + assert_eq!(hdr_tonemap_filter(&MediaColorMetadata::default()), None); +} + +#[test] +fn packaged_hdr_decode_path_materializes_bt709_rgba_pixels() { + if !opentake_media::ffmpeg_status::ffmpeg_available() + || !opentake_media::ffmpeg_status::ffprobe_available() + { + return; + } + let temp = tempfile::tempdir().unwrap(); + let source = temp.path().join("pq.mp4"); + let generated = std::process::Command::new("ffmpeg") + .args([ + "-hide_banner", + "-loglevel", + "error", + "-y", + "-f", + "lavfi", + "-i", + "testsrc2=size=160x90:rate=24", + "-frames:v", + "1", + "-vf", + "format=yuv420p10le", + "-c:v", + "libx265", + "-preset", + "ultrafast", + "-x265-params", + "log-level=error:hdr-opt=1:repeat-headers=1:colorprim=bt2020:transfer=smpte2084:colormatrix=bt2020nc", + "-color_primaries", + "bt2020", + "-color_trc", + "smpte2084", + "-colorspace", + "bt2020nc", + ]) + .arg(&source) + .output() + .unwrap(); + assert!( + generated.status.success(), + "generate HDR fixture: {}", + String::from_utf8_lossy(&generated.stderr) + ); + let metadata = probe(&source).unwrap(); + assert!(metadata.color.as_ref().is_some_and(|color| color.is_hdr())); + + let (_, frame) = decode_frame_at( + &source, + &FrameRequest { + time_secs: 0.0, + max_size: (160, 90), + ..FrameRequest::default() + }, + ) + .expect("platform HDR conversion must decode to RGBA"); + assert_eq!((frame.width, frame.height), (160, 90)); + let (min, max) = frame + .rgba + .chunks_exact(4) + .flat_map(|pixel| pixel[..3].iter().copied()) + .fold((u8::MAX, u8::MIN), |(min, max), value| { + (min.min(value), max.max(value)) + }); + assert!( + max.saturating_sub(min) > 32, + "tone-mapped frame must retain contrast" + ); + + let delivered = temp.path().join("delivery.mp4"); + let preset = ExportPreset::new(VideoCodec::H264, ExportResolution::P720); + let mut encoder = VideoEncoder::new(&delivered, frame.width, frame.height, 1, &preset).unwrap(); + encoder.push_frame(&frame).unwrap(); + encoder.finish().unwrap(); + let tags = std::process::Command::new("ffprobe") + .args([ + "-v", + "error", + "-select_streams", + "v:0", + "-show_entries", + "stream=color_primaries,color_transfer,color_space", + "-of", + "json", + ]) + .arg(&delivered) + .output() + .unwrap(); + assert!(tags.status.success()); + let tags: serde_json::Value = serde_json::from_slice(&tags.stdout).unwrap(); + assert_eq!( + tags.pointer("/streams/0/color_primaries"), + Some(&json!("bt709")) + ); + assert_eq!( + tags.pointer("/streams/0/color_transfer"), + Some(&json!("bt709")) + ); + assert_eq!( + tags.pointer("/streams/0/color_space"), + Some(&json!("bt709")) + ); +} diff --git a/crates/opentake-media/tests/loudness.rs b/crates/opentake-media/tests/loudness.rs new file mode 100644 index 00000000..c436e955 --- /dev/null +++ b/crates/opentake-media/tests/loudness.rs @@ -0,0 +1,107 @@ +use opentake_media::analysis::{ + analyze_loudness, apply_loudness_gain, LoudnessNormalizationConfig, +}; +use opentake_media::encode::mix::apply_true_peak_ceiling; + +const SAMPLE_RATE: u32 = 48_000; + +fn sine_fixture(amplitude: f32, frequency_hz: f32, duration_seconds: usize) -> Vec { + let sample_count = SAMPLE_RATE as usize * duration_seconds; + (0..sample_count) + .map(|index| { + let phase = index as f32 * frequency_hz * std::f32::consts::TAU / SAMPLE_RATE as f32; + phase.sin() * amplitude + }) + .collect() +} + +#[test] +fn normalization_reaches_configured_lufs_within_tolerance() { + let samples = sine_fixture(0.08, 997.0, 4); + let config = LoudnessNormalizationConfig { + target_lufs: -16.0, + true_peak_ceiling_dbtp: -1.0, + }; + + let analysis = analyze_loudness(&samples, SAMPLE_RATE, config).expect("analyze fixture"); + // Cross-checked against FFmpeg 8.1 loudnorm for the same 997 Hz / 0.08 + // amplitude / 48 kHz fixture (`input_i=-24.95`, `input_tp=-21.94`). + assert!((analysis.input_integrated_lufs - -24.95).abs() <= 0.2); + assert!((analysis.input_true_peak_dbtp - -21.94).abs() <= 0.1); + let normalized = apply_loudness_gain(&samples, analysis.gain_db); + let measured = analyze_loudness( + &normalized, + SAMPLE_RATE, + LoudnessNormalizationConfig { + target_lufs: analysis.output_integrated_lufs, + true_peak_ceiling_dbtp: 0.0, + }, + ) + .expect("measure normalized fixture"); + + assert!( + (measured.input_integrated_lufs - config.target_lufs).abs() <= 1.0, + "measured={} target={} gain={} input={} peak={}", + measured.input_integrated_lufs, + config.target_lufs, + analysis.gain_db, + analysis.input_integrated_lufs, + measured.input_true_peak_dbtp, + ); + assert!(measured.input_true_peak_dbtp <= config.true_peak_ceiling_dbtp + 0.05); +} + +fn verify_program_fixture(mut samples: Vec) { + let config = LoudnessNormalizationConfig::default(); + let analysis = analyze_loudness(&samples, SAMPLE_RATE, config).expect("analyze fixture"); + samples = apply_loudness_gain(&samples, analysis.gain_db); + apply_true_peak_ceiling(&mut samples, Some(config.true_peak_ceiling_dbtp)); + let measured = analyze_loudness(&samples, SAMPLE_RATE, config).expect("measure output"); + assert!( + (measured.input_integrated_lufs - config.target_lufs).abs() <= 1.0, + "measured={} target={} gain={} input={} peak={}", + measured.input_integrated_lufs, + config.target_lufs, + analysis.gain_db, + analysis.input_integrated_lufs, + measured.input_true_peak_dbtp, + ); + assert!(measured.input_true_peak_dbtp <= config.true_peak_ceiling_dbtp + 0.05); +} + +#[test] +fn speech_and_music_fixtures_reach_target_without_exceeding_true_peak() { + let sample_count = SAMPLE_RATE as usize * 5; + let speech = (0..sample_count) + .map(|index| { + let time = index as f32 / SAMPLE_RATE as f32; + let syllable = if (time * 3.2).fract() < 0.62 { + 1.0 + } else { + 0.08 + }; + let voiced = (std::f32::consts::TAU * 173.0 * time).sin() * 0.035 + + (std::f32::consts::TAU * 346.0 * time).sin() * 0.018; + let plosive = if index % 31_337 < 12 { 0.72 } else { 0.0 }; + voiced * syllable + plosive + }) + .collect(); + verify_program_fixture(speech); + + let music = (0..sample_count) + .map(|index| { + let time = index as f32 / SAMPLE_RATE as f32; + let tonal = (std::f32::consts::TAU * 220.0 * time).sin() * 0.045 + + (std::f32::consts::TAU * 329.63 * time).sin() * 0.035 + + (std::f32::consts::TAU * 440.0 * time).sin() * 0.025; + let beat_phase = index % (SAMPLE_RATE as usize / 2); + let beat = if beat_phase < 240 { + 0.35 * (1.0 - beat_phase as f32 / 240.0) + } else { + 0.0 + }; + tonal + beat + }) + .collect(); + verify_program_fixture(music); +} diff --git a/crates/opentake-media/tests/proxy.rs b/crates/opentake-media/tests/proxy.rs new file mode 100644 index 00000000..c1aa9f8c --- /dev/null +++ b/crates/opentake-media/tests/proxy.rs @@ -0,0 +1,104 @@ +use std::fs; +use std::path::Path; +use std::sync::{Arc, Mutex}; + +use opentake_media::{ + create_proxy, probe, MediaCancelToken, MediaError, ProxyProgressCallback, ProxyRequest, +}; + +fn make_video(path: &Path) { + let status = std::process::Command::new("ffmpeg") + .args([ + "-hide_banner", + "-loglevel", + "error", + "-y", + "-f", + "lavfi", + "-i", + "testsrc2=size=640x360:rate=30", + "-f", + "lavfi", + "-i", + "sine=frequency=440:sample_rate=48000", + "-t", + "1", + "-c:v", + "libx264", + "-pix_fmt", + "yuv420p", + "-c:a", + "aac", + ]) + .arg(path) + .status() + .expect("spawn ffmpeg fixture"); + assert!(status.success()); +} + +#[test] +fn proxy_creation_is_cancellable_atomic_persistent_and_source_preserving() { + if !opentake_media::ffmpeg_status::ffmpeg_available() + || !opentake_media::ffmpeg_status::ffprobe_available() + { + return; + } + let temp = tempfile::tempdir().unwrap(); + let source = temp.path().join("source.mp4"); + let output = temp.path().join("media/proxies/source-proxy.mp4"); + make_video(&source); + let source_before = fs::read(&source).unwrap(); + let progress_values = Arc::new(Mutex::new(Vec::new())); + let capture = progress_values.clone(); + let progress: ProxyProgressCallback = Arc::new(move |done, total| { + capture.lock().unwrap().push((done, total)); + }); + + let result = create_proxy( + ProxyRequest { + source: &source, + output: &output, + max_size: (320, 180), + }, + &MediaCancelToken::new(), + Some(progress), + ) + .expect("create bounded proxy"); + + assert_eq!(result.path, output); + assert_eq!(result.width, 320); + assert_eq!(result.height, 180); + assert_eq!(result.source_sha256.len(), 64); + assert_eq!(fs::read(&source).unwrap(), source_before); + let proxy_probe = probe(&output).unwrap(); + assert_eq!( + (proxy_probe.width, proxy_probe.height), + (Some(320), Some(180)) + ); + assert!(proxy_probe.has_video && proxy_probe.has_audio); + let values = progress_values.lock().unwrap(); + assert_eq!(values.first().copied(), Some((0, 1000))); + assert_eq!(values.last().copied(), Some((1000, 1000))); + assert!(!temp + .path() + .join("media/proxies/source-proxy.mp4.partial") + .exists()); + + let cancelled = temp.path().join("media/proxies/cancelled.mp4"); + let cancel = MediaCancelToken::new(); + cancel.cancel(); + assert!(matches!( + create_proxy( + ProxyRequest { + source: &source, + output: &cancelled, + max_size: (320, 180), + }, + &cancel, + None, + ), + Err(MediaError::Cancelled) + )); + assert!(!cancelled.exists()); + assert!(!cancelled.with_extension("mp4.partial").exists()); +} diff --git a/crates/opentake-media/tests/stems.rs b/crates/opentake-media/tests/stems.rs new file mode 100644 index 00000000..51e0af6c --- /dev/null +++ b/crates/opentake-media/tests/stems.rs @@ -0,0 +1,219 @@ +use std::fs; +use std::path::Path; +use std::sync::Arc; + +use opentake_media::analysis::stems::{ + ensure_local_stem_model, separate_stems, verify_local_stem_model, StemExecution, + StemProgressCallback, StemSeparationRequest, +}; +use opentake_media::{MediaCancelToken, MediaError}; +use tempfile::TempDir; + +const SAMPLE_RATE: u32 = 48_000; +const FRAMES: usize = 48_000; + +fn write_stereo_fixture(path: &Path) { + let data_len = (FRAMES * 2 * 2) as u32; + let mut wav = Vec::with_capacity(44 + data_len as usize); + wav.extend_from_slice(b"RIFF"); + wav.extend_from_slice(&(36 + data_len).to_le_bytes()); + wav.extend_from_slice(b"WAVEfmt "); + wav.extend_from_slice(&16_u32.to_le_bytes()); + wav.extend_from_slice(&1_u16.to_le_bytes()); + wav.extend_from_slice(&2_u16.to_le_bytes()); + wav.extend_from_slice(&SAMPLE_RATE.to_le_bytes()); + wav.extend_from_slice(&(SAMPLE_RATE * 4).to_le_bytes()); + wav.extend_from_slice(&4_u16.to_le_bytes()); + wav.extend_from_slice(&16_u16.to_le_bytes()); + wav.extend_from_slice(b"data"); + wav.extend_from_slice(&data_len.to_le_bytes()); + for frame in 0..FRAMES { + let t = frame as f32 / SAMPLE_RATE as f32; + let vocal = 0.28 * (std::f32::consts::TAU * 440.0 * t).sin(); + let music = 0.22 * (std::f32::consts::TAU * 997.0 * t).sin(); + for sample in [vocal + music, vocal - music] { + wav.extend_from_slice(&((sample.clamp(-1.0, 1.0) * 32767.0) as i16).to_le_bytes()); + } + } + fs::write(path, wav).expect("write deterministic stereo fixture"); +} + +fn assert_clean_output_dir(path: &Path) { + let entries = fs::read_dir(path) + .expect("read output directory") + .collect::, _>>() + .expect("collect output directory"); + assert!( + entries.is_empty(), + "cancelled job must clean partial outputs" + ); +} + +#[test] +fn local_or_explicit_provider_selection_cancellation_provenance_and_cleanup() { + let temp = TempDir::new().expect("temp root"); + let source = temp.path().join("center-vocal.wav"); + let models = temp.path().join("models"); + let outputs = temp.path().join("outputs"); + write_stereo_fixture(&source); + fs::create_dir_all(&outputs).expect("create outputs"); + + let installed = ensure_local_stem_model(&models).expect("install bundled local model"); + assert!(installed.path.is_file()); + assert_eq!(verify_local_stem_model(&models).unwrap(), installed); + + let progress_values = Arc::new(std::sync::Mutex::new(Vec::new())); + let progress_capture = progress_values.clone(); + let progress: StemProgressCallback = Arc::new(move |done, total| { + progress_capture.lock().unwrap().push((done, total)); + }); + let result = separate_stems( + StemSeparationRequest { + source: &source, + output_dir: &outputs, + execution: StemExecution::Local { model_dir: &models }, + }, + &MediaCancelToken::new(), + Some(progress), + ) + .expect("separate local stems"); + + assert!(result.vocals.path.is_file()); + assert!(result.accompaniment.path.is_file()); + assert_eq!(result.provenance.execution, "local:opentake-center-v1"); + assert_eq!(result.provenance.source_sha256.len(), 64); + assert_eq!( + result.provenance.model_sha256, + Some(installed.sha256.clone()) + ); + assert!(result.metrics.vocal_sdr_improvement_db >= 12.0); + let spec = opentake_media::PcmSpec { + sample_rate: SAMPLE_RATE, + channels: 2, + format: opentake_media::PcmFormat::F32, + }; + let mixture = opentake_media::decode_pcm_interleaved(&source, &spec, None).unwrap(); + let separated_vocals = + opentake_media::decode_pcm_interleaved(&result.vocals.path, &spec, None).unwrap(); + let separated_accompaniment = + opentake_media::decode_pcm_interleaved(&result.accompaniment.path, &spec, None).unwrap(); + let mut reference = Vec::with_capacity(FRAMES * 2); + let mut accompaniment_reference = Vec::with_capacity(FRAMES * 2); + for frame in 0..FRAMES { + let t = frame as f32 / SAMPLE_RATE as f32; + let vocal = 0.28 * (std::f32::consts::TAU * 440.0 * t).sin(); + let music = 0.22 * (std::f32::consts::TAU * 997.0 * t).sin(); + reference.extend_from_slice(&[vocal, vocal]); + // A user-facing accompaniment stem must remain audible after a mono + // export/downmix, so the isolated side signal is emitted dual-mono. + accompaniment_reference.extend_from_slice(&[music, music]); + } + let sdr = |candidate: &[f32]| { + let signal = reference + .iter() + .map(|sample| f64::from(*sample) * f64::from(*sample)) + .sum::(); + let error = reference + .iter() + .zip(candidate) + .map(|(expected, actual)| { + let delta = f64::from(*expected - *actual); + delta * delta + }) + .sum::() + .max(1.0e-12); + 10.0 * (signal / error).log10() + }; + let measured_improvement = sdr(&separated_vocals) - sdr(&mixture); + assert!( + measured_improvement >= 12.0, + "decoded vocals must improve SDR by >= 12 dB, got {measured_improvement:.3} dB" + ); + let accompaniment_signal = accompaniment_reference + .iter() + .map(|sample| f64::from(*sample) * f64::from(*sample)) + .sum::(); + let accompaniment_error = accompaniment_reference + .iter() + .zip(&separated_accompaniment) + .map(|(expected, actual)| { + let delta = f64::from(*expected - *actual); + delta * delta + }) + .sum::() + .max(1.0e-12); + let accompaniment_sdr = 10.0 * (accompaniment_signal / accompaniment_error).log10(); + assert!( + accompaniment_sdr >= 60.0, + "decoded accompaniment must be mono-compatible, got {accompaniment_sdr:.3} dB SDR" + ); + let mut mono_compatible_mix = Vec::with_capacity(FRAMES * 2); + for frame in 0..FRAMES { + let t = frame as f32 / SAMPLE_RATE as f32; + let vocal = 0.28 * (std::f32::consts::TAU * 440.0 * t).sin(); + let music = 0.22 * (std::f32::consts::TAU * 997.0 * t).sin(); + mono_compatible_mix.extend_from_slice(&[vocal + music, vocal + music]); + } + let reconstruction_signal = mono_compatible_mix + .iter() + .map(|sample| f64::from(*sample) * f64::from(*sample)) + .sum::(); + let reconstruction_error = mono_compatible_mix + .iter() + .zip(separated_vocals.iter().zip(&separated_accompaniment)) + .map(|(expected, (vocals, accompaniment))| { + let delta = f64::from(*expected - (*vocals + *accompaniment)); + delta * delta + }) + .sum::() + .max(1.0e-12); + let reconstruction_sdr = 10.0 * (reconstruction_signal / reconstruction_error).log10(); + assert!( + reconstruction_sdr >= 60.0, + "stem sum must reconstruct the documented mono-compatible mixture at >= 60 dB SDR, got {reconstruction_sdr:.3} dB" + ); + let progress_values = progress_values.lock().unwrap(); + assert_eq!(progress_values.first().copied(), Some((0, 1000))); + assert_eq!(progress_values.last().copied(), Some((1000, 1000))); + + let corrupt = fs::read(&installed.path).expect("read installed model"); + fs::write(&installed.path, [corrupt, b"corrupt".to_vec()].concat()) + .expect("corrupt installed model"); + assert!(matches!( + verify_local_stem_model(&models), + Err(MediaError::Checksum(_)) + )); + + let cancelled_outputs = temp.path().join("cancelled"); + fs::create_dir_all(&cancelled_outputs).unwrap(); + let cancel = MediaCancelToken::new(); + cancel.cancel(); + let cancelled = separate_stems( + StemSeparationRequest { + source: &source, + output_dir: &cancelled_outputs, + execution: StemExecution::Local { model_dir: &models }, + }, + &cancel, + None, + ); + assert!(matches!(cancelled, Err(MediaError::Cancelled))); + assert_clean_output_dir(&cancelled_outputs); + + let hosted_outputs = temp.path().join("hosted"); + fs::create_dir_all(&hosted_outputs).unwrap(); + let hosted = separate_stems( + StemSeparationRequest { + source: &source, + output_dir: &hosted_outputs, + execution: StemExecution::Hosted { + provider: "".to_string(), + model: "vendor/stems-v1".to_string(), + }, + }, + &MediaCancelToken::new(), + None, + ); + assert!(matches!(hosted, Err(MediaError::ModelInstall(_)))); + assert_clean_output_dir(&hosted_outputs); +} diff --git a/crates/opentake-motion/Cargo.toml b/crates/opentake-motion/Cargo.toml index 3d206684..13a07692 100644 --- a/crates/opentake-motion/Cargo.toml +++ b/crates/opentake-motion/Cargo.toml @@ -8,6 +8,7 @@ description = "Native motion fallback: deterministic RGBA frame cache, sandbox, [dependencies] opentake-domain = { workspace = true } +opentake-process-tree = { workspace = true, optional = true } # DecodedFrame / SourceMetrics / FrameProvider — the render crate DEFINES the # clip-source contracts; we IMPLEMENT them so a motion clip is just another # texture source to the compositor (zero special handling). @@ -21,6 +22,12 @@ sha2 = "0.10" hex = "0.4" thiserror = "1" +# The live backend speaks Chrome DevTools Protocol directly. Both dependencies +# are feature-gated, so the default/offline crate keeps its existing surface. +base64 = { version = "0.22", optional = true } +image = { version = "0.25", optional = true, default-features = false, features = ["png"] } +tungstenite = { version = "0.29", optional = true, default-features = false, features = ["handshake"] } + [dev-dependencies] # Offline PNG round-trip for the stub renderer's frame-file checks (mirrors the # render crate's dev-dep; no network, no assets). @@ -30,6 +37,6 @@ tempfile = "3" [features] default = [] # Gates the real headless-Chromium (CDP) backend behind a feature so neither the -# default build nor CI tests require a Chromium binary. The skeleton compiles -# unconditionally; only the live CDP wiring is feature-gated (see renderer.rs). -chromium = [] +# default build nor CI tests require a Chromium binary. Browser discovery and +# the fail-closed API compile unconditionally; live CDP wiring is feature-gated. +chromium = ["dep:base64", "dep:image", "dep:opentake-process-tree", "dep:tungstenite"] diff --git a/crates/opentake-motion/src/cache.rs b/crates/opentake-motion/src/cache.rs index ea20afb5..a1d68a07 100644 --- a/crates/opentake-motion/src/cache.rs +++ b/crates/opentake-motion/src/cache.rs @@ -10,13 +10,19 @@ //! [`MotionCache`] is the thin directory wrapper that maps a key to a folder and //! reports hit/miss; the renderer writes frames into that folder. +use std::io::Write; use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{SystemTime, UNIX_EPOCH}; use sha2::{Digest, Sha256}; use crate::error::MotionResult; use crate::source::{MotionRenderRequest, MotionSource, ParamValue}; +const COMPLETION_MARKER_FILE: &str = ".opentake-motion-complete-v4"; +static COMPLETION_MARKER_COUNTER: AtomicU64 = AtomicU64::new(0); + /// Compute the content hash (lowercase hex SHA-256) for a render request. /// /// We feed a canonical, unambiguous byte stream into the hash — each field @@ -130,12 +136,13 @@ impl MotionCache { } /// Whether a complete render for this request is already cached. "Complete" - /// means the directory exists and holds exactly `duration_frames` frame - /// files — a partial render (crash mid-way) is treated as a miss so it gets - /// recomputed rather than served truncated. + /// means the directory holds the exact expected frame set and an atomically + /// published completion marker. A render that produced every frame but + /// failed during browser shutdown therefore remains a miss. pub fn is_cached(&self, req: &MotionRenderRequest) -> bool { let dir = self.dir_for(req); - count_frame_files(&dir) == Some(req.duration_frames as usize) + completion_marker(&dir).is_file() + && has_exact_frame_files(&dir, req.duration_frames as usize) } /// Create the cache directory for a request, returning its path. @@ -145,6 +152,56 @@ impl MotionCache { Ok(dir) } + /// Prepare a cache directory for a fresh render. Removing the marker first + /// makes every subsequent write fail-closed until completion is published. + pub(crate) fn begin_render(&self, req: &MotionRenderRequest) -> MotionResult { + let dir = self.ensure_dir(req)?; + Self::remove_completion_marker(&dir)?; + Ok(dir) + } + + /// Publish render completion with a write-sync-rename sequence so readers + /// can never observe a partially written marker. + pub(crate) fn mark_complete(dir: &Path) -> MotionResult<()> { + let marker = completion_marker(dir); + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + let counter = COMPLETION_MARKER_COUNTER.fetch_add(1, Ordering::Relaxed); + let temporary = dir.join(format!( + ".opentake-motion-complete-{pid}-{nanos}-{counter}.tmp", + pid = std::process::id() + )); + + let result = (|| -> std::io::Result<()> { + let mut file = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&temporary)?; + file.write_all(b"opentake-motion-cache/v4\n")?; + file.sync_all()?; + drop(file); + match std::fs::rename(&temporary, &marker) { + Ok(()) => Ok(()), + // Another identical renderer may have published the same key + // concurrently. Its atomic marker is equivalent completion. + Err(_) if marker.is_file() => Ok(()), + Err(error) => Err(error), + } + })(); + let _ = std::fs::remove_file(&temporary); + result.map_err(Into::into) + } + + pub(crate) fn remove_completion_marker(dir: &Path) -> MotionResult<()> { + match std::fs::remove_file(completion_marker(dir)) { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(error.into()), + } + } + /// The expected per-frame file path inside a render dir: zero-padded so /// lexical order == playback order (`frame_00000.png`). pub fn frame_file(dir: &Path, frame_index: usize) -> PathBuf { @@ -152,6 +209,15 @@ impl MotionCache { } } +fn completion_marker(dir: &Path) -> PathBuf { + dir.join(COMPLETION_MARKER_FILE) +} + +fn has_exact_frame_files(dir: &Path, expected: usize) -> bool { + count_frame_files(dir) == Some(expected) + && (0..expected).all(|index| MotionCache::frame_file(dir, index).is_file()) +} + /// Count `frame_*.png` files in a directory, or `None` if it doesn't exist. fn count_frame_files(dir: &Path) -> Option { let entries = std::fs::read_dir(dir).ok()?; @@ -323,8 +389,70 @@ mod tests { std::fs::write(MotionCache::frame_file(&dir, 1), b"x").unwrap(); assert!(!cache.is_cached(&req)); - // All three -> hit. + // All three without a completion marker are still a miss: a renderer + // may have produced every frame and then failed during shutdown. std::fs::write(MotionCache::frame_file(&dir, 2), b"x").unwrap(); + assert!(!cache.is_cached(&req)); + + MotionCache::mark_complete(&dir).unwrap(); assert!(cache.is_cached(&req)); + assert!( + std::fs::read_dir(&dir) + .unwrap() + .flatten() + .all(|entry| !entry.file_name().to_string_lossy().ends_with(".tmp")), + "atomic publication must not leave a temporary marker" + ); + + // Beginning a new render invalidates completion before any frame is + // touched, so a later renderer failure cannot reuse stale frames. + assert_eq!(cache.begin_render(&req).unwrap(), dir); + assert!(!cache.is_cached(&req)); + } + + #[test] + fn completion_marker_requires_the_exact_expected_frame_set() { + let tmp = tempfile::tempdir().unwrap(); + let cache = MotionCache::new(tmp.path()); + let req = MotionRenderRequest::new(MotionSource::code(""), 30, 2, 64, 64); + let dir = cache.ensure_dir(&req).unwrap(); + + MotionCache::mark_complete(&dir).unwrap(); + assert!(!cache.is_cached(&req), "a marker alone is not a cache hit"); + + std::fs::write(MotionCache::frame_file(&dir, 0), b"x").unwrap(); + std::fs::write(MotionCache::frame_file(&dir, 999), b"x").unwrap(); + assert!(!cache.is_cached(&req), "wrong frame names are not complete"); + + std::fs::write(MotionCache::frame_file(&dir, 1), b"x").unwrap(); + assert!(!cache.is_cached(&req), "extra frame files are not complete"); + } + + #[test] + fn completion_marker_schema_rejects_legacy_rendered_frames() { + let tmp = tempfile::tempdir().unwrap(); + let cache = MotionCache::new(tmp.path()); + let req = MotionRenderRequest::new(MotionSource::code(""), 30, 2, 64, 64); + let dir = cache.ensure_dir(&req).unwrap(); + for index in 0..2 { + std::fs::write(MotionCache::frame_file(&dir, index), b"legacy").unwrap(); + } + std::fs::write( + dir.join(".opentake-motion-complete-v3"), + b"opentake-motion-cache/v3\n", + ) + .unwrap(); + + assert!( + !cache.is_cached(&req), + "a legacy capture marker must not validate the current renderer output" + ); + MotionCache::mark_complete(&dir).unwrap(); + assert!(cache.is_cached(&req)); + assert_eq!( + cache.dir_for(&req), + dir, + "cache directory contract is stable" + ); } } diff --git a/crates/opentake-motion/src/error.rs b/crates/opentake-motion/src/error.rs index 4f107f99..d7001e68 100644 --- a/crates/opentake-motion/src/error.rs +++ b/crates/opentake-motion/src/error.rs @@ -35,6 +35,11 @@ pub enum MotionError { #[error("render timed out after {0:?}")] Timeout(std::time::Duration), + /// The caller cancelled an in-flight render. Browser/profile/output cleanup + /// is complete before this error is returned. + #[error("render cancelled")] + Cancelled, + /// A sandbox policy was violated (e.g. a disallowed network origin). #[error("sandbox violation: {0}")] Sandbox(String), diff --git a/crates/opentake-motion/src/integration.rs b/crates/opentake-motion/src/integration.rs index 29bd1d1e..6e21919d 100644 --- a/crates/opentake-motion/src/integration.rs +++ b/crates/opentake-motion/src/integration.rs @@ -11,13 +11,15 @@ //! //! Decoding a frame file back to RGBA is deliberately *not* hard-wired to a PNG //! library here. Frames may be produced by the [`StubRenderer`](crate::renderer) -//! (our tiny stored-block PNG), by the later native headless-Chromium fallback +//! (our tiny stored-block PNG), by the native headless-Chromium fallback //! (standard PNG), by Motion Canvas image-sequence output, or by a future //! raw-RGBA fast path. So [`MotionClipSource`] takes a //! `FrameDecoder` — a `Fn(&Path) -> Option` — supplied by the //! integrating layer (which already owns an image/codec stack). Tests inject the -//! stub's own decoder; the app injects `image`/ffmpeg. This keeps this crate's -//! default dependency surface free of a decoder while still being fully testable. +//! stub's own decoder, and the feature-gated Chromium acceptance decodes a live +//! browser PNG through this same boundary; the app injects `image`/ffmpeg. This +//! keeps this crate's default dependency surface free of a decoder while still +//! being fully testable. use std::path::Path; @@ -59,6 +61,8 @@ impl<'a> MotionClipSource<'a> { /// Decode the frame at a 0-based index, clamping past-the-end to the last /// frame (freeze-frame hold, consistent with [`RenderedClip::frame_path`]). + /// Missing/corrupt input remains an absent frame (`None`); this adapter does + /// not repair, replace, or otherwise mutate the frame cache. pub fn frame(&self, frame: i64) -> Option { let idx = if frame < 0 { 0usize } else { frame as usize }; let path = self.clip.frame_path(idx)?; @@ -167,9 +171,29 @@ mod tests { #[test] fn missing_decoder_result_is_none() { let (clip, _tmp) = render_clip(true); - // A decoder that always fails surfaces None (compositor treats as absent). - let src = MotionClipSource::new(clip, |_p: &Path| None); - assert!(src.decoded_frame("ref", 0).is_none()); + let valid_path = clip.frames[0].clone(); + let corrupt_path = clip.frames[1].clone(); + let missing_path = clip.frames[2].clone(); + let cache_dir = valid_path.parent().unwrap().to_path_buf(); + let src = MotionClipSource::new(clip, image_decoder); + + let valid = src + .decoded_frame("ref", 0) + .expect("valid frame remains decodable"); + assert_eq!((valid.width, valid.height), (6, 4)); + + std::fs::write(&corrupt_path, b"not a png").unwrap(); + std::fs::remove_file(&missing_path).unwrap(); + let entries_before = std::fs::read_dir(&cache_dir).unwrap().count(); + + assert!(src.decoded_frame("ref", 1).is_none()); + assert_eq!(std::fs::read(&corrupt_path).unwrap(), b"not a png"); + assert!(src.decoded_frame("ref", 2).is_none()); + assert!(!missing_path.exists()); + assert_eq!( + std::fs::read_dir(&cache_dir).unwrap().count(), + entries_before + ); } #[test] diff --git a/crates/opentake-motion/src/lib.rs b/crates/opentake-motion/src/lib.rs index 82b31d37..a9271649 100644 --- a/crates/opentake-motion/src/lib.rs +++ b/crates/opentake-motion/src/lib.rs @@ -58,7 +58,8 @@ pub use manifest::{ DurationMode, DurationSpec, FpsPolicy, MotionPlugin, MotionPluginAuthor, ParamSpec, }; pub use renderer::{ - deterministic_clock_script, HeadlessChromiumRenderer, MotionRenderer, StubRenderer, + deterministic_clock_script, HeadlessChromiumRenderer, MotionCancellationToken, MotionRenderer, + StubRenderer, }; pub use sandbox::{AllowedOrigin, SandboxPolicy}; pub use source::{limits, MotionRenderRequest, MotionSource, ParamValue, RenderedClip}; diff --git a/crates/opentake-motion/src/renderer.rs b/crates/opentake-motion/src/renderer.rs index d7c92d5b..15be845d 100644 --- a/crates/opentake-motion/src/renderer.rs +++ b/crates/opentake-motion/src/renderer.rs @@ -8,18 +8,22 @@ //! each frame a solid color derived from `(frame, content-hash)`. It exists so //! the whole pipeline (validation → cache → frame files → compositor ingest) //! is unit-testable offline with **no browser**. -//! - [`HeadlessChromiumRenderer`] — the real backend skeleton. It documents and -//! sequences the deterministic CDP flow (virtual time + per-frame screenshot -//! with alpha, docs §3) but the live Chromium calls are gated behind the -//! `chromium` cargo feature. Without that feature (the default, and in CI) it -//! returns a clear [`MotionError::RendererUnavailable`] instead of pretending -//! to render. +//! - [`HeadlessChromiumRenderer`] — the live CDP backend (virtual time + +//! per-frame screenshot with alpha), gated behind the `chromium` cargo +//! feature. Without that feature it returns a clear +//! [`MotionError::RendererUnavailable`]. //! //! Both share [`deterministic_clock_script`] — the injected JS that freezes the //! page clock and exposes `OpenTake.seek(seconds)`, the render contract authors //! animate against. +#[cfg(feature = "chromium")] +use std::path::Path; use std::path::PathBuf; +use std::sync::{ + atomic::{AtomicBool, Ordering}, + Arc, +}; use crate::cache::{content_hash, MotionCache}; use crate::error::{MotionError, MotionResult}; @@ -39,6 +43,25 @@ pub trait MotionRenderer { fn render(&self, req: &MotionRenderRequest) -> MotionResult; } +/// Cooperative cancellation shared between the caller and a live browser +/// render. Cancelling is idempotent and may happen from any thread. +#[derive(Clone, Debug, Default)] +pub struct MotionCancellationToken(Arc); + +impl MotionCancellationToken { + pub fn new() -> Self { + Self::default() + } + + pub fn cancel(&self) { + self.0.store(true, Ordering::Release); + } + + pub fn is_cancelled(&self) -> bool { + self.0.load(Ordering::Acquire) + } +} + /// The deterministic clock contract injected into every native fallback /// rendered document. It: /// 1. Pauses CSS/Web animations by pinning `document.timeline.currentTime`. @@ -54,13 +77,30 @@ pub fn deterministic_clock_script() -> &'static str { if (window.OpenTake && window.OpenTake.__installed) return; var current = 0; var listeners = []; + var randomState = 0x6d2b79f5; + try { Date.now = function () { return Math.round(current * 1000); }; } catch (e) {} + try { + Object.defineProperty(performance, 'now', { + configurable: true, + value: function () { return current * 1000; } + }); + } catch (e) {} + try { + Math.random = function () { + randomState = (randomState + 0x6d2b79f5) | 0; + var n = Math.imul(randomState ^ (randomState >>> 15), 1 | randomState); + n = (n + Math.imul(n ^ (n >>> 7), 61 | n)) ^ n; + return ((n ^ (n >>> 14)) >>> 0) / 4294967296; + }; + } catch (e) {} window.OpenTake = { __installed: true, // Current virtual time in seconds. currentTime: function () { return current; }, // Host calls this once per frame with t = frameIndex / fps. - seek: function (seconds) { + seek: async function (seconds) { current = seconds; + randomState = (0x6d2b79f5 ^ Math.round(seconds * 1000000)) | 0; try { if (document.timeline) { // Freeze the document timeline to the virtual clock (ms). @@ -70,9 +110,11 @@ pub fn deterministic_clock_script() -> &'static str { }); } } catch (e) { /* timeline may be read-only; listeners still fire */ } + var pending = []; for (var i = 0; i < listeners.length; i++) { - try { listeners[i](seconds); } catch (e) {} + try { pending.push(Promise.resolve(listeners[i](seconds))); } catch (e) {} } + await Promise.all(pending); }, // Authors register frame callbacks: OpenTake.onSeek(t => { ... }). onSeek: function (fn) { if (typeof fn === 'function') listeners.push(fn); } @@ -131,7 +173,20 @@ impl MotionRenderer for StubRenderer { } let hash = content_hash(req); - let dir = self.cache.ensure_dir(req)?; + if self.cache.is_cached(req) { + let dir = self.cache.dir_for(req); + return Ok(RenderedClip { + content_hash: hash, + frames: (0..req.duration_frames as usize) + .map(|index| MotionCache::frame_file(&dir, index)) + .collect(), + fps: req.fps, + width: req.width, + height: req.height, + transparent: req.transparent, + }); + } + let dir = self.cache.begin_render(req)?; let mut frames: Vec = Vec::with_capacity(req.duration_frames as usize); for frame in 0..req.duration_frames { @@ -140,6 +195,7 @@ impl MotionRenderer for StubRenderer { write_solid_rgba_png(&path, req.width, req.height, color)?; frames.push(path); } + MotionCache::mark_complete(&dir)?; Ok(RenderedClip { content_hash: hash, @@ -272,44 +328,139 @@ impl Crc32 { } } -/// The real headless-Chromium backend (skeleton). +/// The real headless-Chromium backend. /// -/// The deterministic fallback flow this skeleton documents, step by step, is: +/// Its deterministic fallback flow is: /// 1. Launch an offscreen Chromium with no network, an empty profile, and no /// filesystem access beyond the served document — applying [`SandboxPolicy`]. -/// 2. `Emulation.setDeviceMetricsOverride` to the requested `width`×`height`. -/// 3. `Page.addScriptToEvaluateOnNewDocument` with -/// [`deterministic_clock_script`] so the page clock is frozen before author -/// code runs. -/// 4. `Emulation.setVirtualTimePolicy { policy: "pause" }` to stop real time. -/// 5. Navigate to the document (inline `data:` URL for `Code`, or the template's -/// served `entry`). -/// 6. For each frame `i` in `0..duration_frames`: advance virtual time to -/// `i / fps` and call `OpenTake.seek(i / fps)`, then -/// `Page.captureScreenshot { format: "png", ... }` (transparent background -/// when `transparent`), writing the PNG to `cache_dir/frame_iiiii.png`. -/// 7. Return the [`RenderedClip`]. +/// 2. Create an engine-owned `(width + 1)`×`(height + 1)` host document. Author +/// markup runs in an exact-size `" + ) } - #[test] - fn chromium_applies_sandbox_size_before_unavailable() { - // A document over the ceiling fails with a Sandbox error, proving the - // policy is enforced even though no browser runs. + struct InstalledHost { + author_session_id: String, + author_context_id: u64, + } + + fn install_host_document( + cdp: &mut Cdp, + session: &str, + document: &str, + ) -> MotionResult { + cdp.command( + "Target.setAutoAttach", + json!({ + "autoAttach": true, + "waitForDebuggerOnStart": true, + "flatten": true, + "filter": [ + {"type": "iframe", "exclude": false}, + {"exclude": true} + ] + }), + Some(session), + )?; + let initial_tree = cdp.command("Page.getFrameTree", json!({}), Some(session))?; + let main_frame_id = initial_tree + .get("frameTree") + .and_then(|tree| tree.get("frame")) + .and_then(|frame| frame.get("id")) + .and_then(Value::as_str) + .map(str::to_owned) + .ok_or_else(|| { + MotionError::render_failed("Chromium about:blank frame tree has no root frame id") + })?; + cdp.command( + "Page.setDocumentContent", + json!({"frameId": main_frame_id, "html": document}), + Some(session), + )?; + + let attached = + cdp.wait_for_event_matching("Target.attachedToTarget", Some(session), |event| { + let params = event.get("params"); + params + .and_then(|params| params.get("waitingForDebugger")) + .and_then(Value::as_bool) + == Some(true) + && params + .and_then(|params| params.get("targetInfo")) + .and_then(|target| target.get("type")) + .and_then(Value::as_str) + == Some("iframe") + })?; + let author_session_id = attached + .get("params") + .and_then(|params| params.get("sessionId")) + .and_then(Value::as_str) + .map(str::to_owned) + .ok_or_else(|| { + MotionError::render_failed("Chromium author iframe attach has no child session") + })?; + let author_frame_id = attached + .get("params") + .and_then(|params| params.get("targetInfo")) + .and_then(|target| target.get("targetId")) + .and_then(Value::as_str) + .map(str::to_owned) + .ok_or_else(|| { + MotionError::render_failed("Chromium author iframe attach has no target id") + })?; + for (method, params) in [ + ("Runtime.enable", json!({})), + ("Page.enable", json!({})), + ("Log.enable", json!({})), + ( + "Fetch.enable", + json!({"patterns": [{"urlPattern": "*", "requestStage": "Request"}]}), + ), + ( + "Page.addScriptToEvaluateOnNewDocument", + json!({"source": deterministic_clock_script()}), + ), + ] { + cdp.command(method, params, Some(&author_session_id))?; + } + cdp.command( + "Runtime.runIfWaitingForDebugger", + json!({}), + Some(&author_session_id), + )?; + + let navigated = cdp.wait_for_author_event( + "Page.frameNavigated", + &author_session_id, + &author_frame_id, + |event| { + event + .get("params") + .and_then(|params| params.get("frame")) + .and_then(|frame| frame.get("id")) + .and_then(Value::as_str) + == Some(author_frame_id.as_str()) + }, + )?; + validate_author_navigation(&navigated, &author_frame_id, &main_frame_id)?; + let context = cdp.wait_for_author_event( + "Runtime.executionContextCreated", + &author_session_id, + &author_frame_id, + |event| { + let auxiliary = event + .get("params") + .and_then(|params| params.get("context")) + .and_then(|context| context.get("auxData")); + auxiliary + .and_then(|auxiliary| auxiliary.get("frameId")) + .and_then(Value::as_str) + == Some(author_frame_id.as_str()) + && auxiliary + .and_then(|auxiliary| auxiliary.get("isDefault")) + .and_then(Value::as_bool) + == Some(true) + }, + )?; + let author_context_id = context + .get("params") + .and_then(|params| params.get("context")) + .and_then(|context| context.get("id")) + .and_then(Value::as_u64) + .ok_or_else(|| { + MotionError::render_failed( + "Chromium author frame has no default JavaScript execution context", + ) + })?; + cdp.wait_for_author_event( + "Page.frameStoppedLoading", + &author_session_id, + &author_frame_id, + |event| { + event + .get("params") + .and_then(|params| params.get("frameId")) + .and_then(Value::as_str) + == Some(author_frame_id.as_str()) + }, + )?; + cdp.wait_for_event("Page.loadEventFired", Some(&author_session_id))?; + cdp.ensure_no_blocked_url()?; + + Ok(InstalledHost { + author_session_id, + author_context_id, + }) + } + + fn validate_author_navigation( + event: &Value, + author_frame_id: &str, + main_frame_id: &str, + ) -> MotionResult<()> { + let frame = event + .get("params") + .and_then(|params| params.get("frame")) + .ok_or_else(|| MotionError::render_failed("Chromium author navigation has no frame"))?; + if frame.get("id").and_then(Value::as_str) != Some(author_frame_id) { + return Err(MotionError::render_failed( + "Chromium author navigation does not match its iframe target", + )); + } + if frame.get("parentId").and_then(Value::as_str) != Some(main_frame_id) { + return Err(MotionError::render_failed( + "Chromium author iframe is not a child of the host frame", + )); + } + if !frame + .get("url") + .and_then(Value::as_str) + .is_some_and(|url| url.starts_with("data:")) + { + return Err(MotionError::render_failed( + "Chromium author iframe did not commit a data document", + )); + } + Ok(()) + } + + fn check_abort( + cancellation: &MotionCancellationToken, + deadline: Instant, + timeout: Duration, + ) -> MotionResult<()> { + check_abort_state(cancellation, deadline, timeout) + } + + fn check_abort_state( + cancellation: &MotionCancellationToken, + deadline: Instant, + timeout: Duration, + ) -> MotionResult<()> { + if cancellation.is_cancelled() { + return Err(MotionError::Cancelled); + } + if Instant::now() >= deadline { + return Err(MotionError::Timeout(timeout)); + } + Ok(()) + } + + fn publish_completed_render( + cancellation: &MotionCancellationToken, + timeout: Duration, + deadline: Instant, + dir: &Path, + partial: &mut PartialFrames, + ) -> MotionResult<()> { + check_abort(cancellation, deadline, timeout)?; + MotionCache::mark_complete(dir)?; + if let Err(error) = check_abort(cancellation, deadline, timeout) { + MotionCache::remove_completion_marker(dir)?; + return Err(error); + } + partial.commit(); + Ok(()) + } + + fn required_string(value: &Value, key: &str) -> MotionResult { + value + .get(key) + .and_then(Value::as_str) + .map(str::to_owned) + .ok_or_else(|| { + MotionError::render_failed(format!( + "Chromium CDP response is missing string field {key:?}: {value}" + )) + }) + } + + fn remove_partial_frames(dir: &Path) -> MotionResult<()> { + for entry in std::fs::read_dir(dir)? { + let entry = entry?; + let name = entry.file_name(); + let name = name.to_string_lossy(); + if name.starts_with("frame_") && name.ends_with(".png") { + std::fs::remove_file(entry.path())?; + } + } + Ok(()) + } + + struct PartialFrames { + dir: PathBuf, + committed: bool, + } + + impl PartialFrames { + fn new(dir: PathBuf) -> Self { + Self { + dir, + committed: false, + } + } + + fn commit(&mut self) { + self.committed = true; + } + } + + impl Drop for PartialFrames { + fn drop(&mut self) { + if !self.committed { + let _ = MotionCache::remove_completion_marker(&self.dir); + let _ = remove_partial_frames(&self.dir); + } + } + } + + struct BrowserProcess { + child: Option, + profile: PathBuf, + tree: ProcessTree, + shutdown_complete: bool, + } + + impl BrowserProcess { + fn launch( + executable: &Path, + deadline: Instant, + timeout: Duration, + cancellation: &MotionCancellationToken, + ) -> MotionResult<(Self, String)> { + let profile = unique_profile_dir(); + std::fs::create_dir_all(&profile)?; + let mut command = Command::new(executable); + command + .args(browser_launch_args()) + .arg(format!("--user-data-dir={}", profile.display())) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::piped()); + configure_command(&mut command); + let mut child = command.spawn().map_err(|error| { + let _ = std::fs::remove_dir_all(&profile); + if error.kind() == std::io::ErrorKind::NotFound { + MotionError::renderer_unavailable(format!( + "Chromium executable does not exist at {}", + executable.display() + )) + } else { + MotionError::render_failed(format!( + "failed to launch Chromium at {}: {error}", + executable.display() + )) + } + })?; + let tree = match ProcessTree::attach(child.id()) { + Ok(tree) => tree, + Err(error) => { + let _ = child.kill(); + let _ = child.wait(); + let _ = std::fs::remove_dir_all(&profile); + return Err(MotionError::render_failed(format!( + "failed to contain Chromium process tree: {error}" + ))); + } + }; + let mut process = BrowserProcess { + child: Some(child), + profile, + tree, + shutdown_complete: false, + }; + let Some(stderr) = process.child.as_mut().and_then(|child| child.stderr.take()) else { + let _ = process.shutdown(); + return Err(MotionError::render_failed( + "Chromium stderr was not captured", + )); + }; + let (sender, receiver) = mpsc::channel(); + thread::spawn(move || { + drain_browser_stderr(BufReader::new(stderr), sender); + }); + + loop { + if cancellation.is_cancelled() { + return Err(MotionError::Cancelled); + } + if Instant::now() >= deadline { + return Err(MotionError::Timeout(timeout)); + } + if let Some(status) = process + .child + .as_mut() + .expect("launched Chromium child is present") + .try_wait()? + { + return Err(MotionError::render_failed(format!( + "Chromium exited before CDP was ready: {status}" + ))); + } + match receiver.recv_timeout(Duration::from_millis(20)) { + Ok(line) => { + if let Some((_, url)) = line.split_once("DevTools listening on ") { + return Ok((process, url.trim().to_owned())); + } + } + Err(mpsc::RecvTimeoutError::Timeout) => {} + Err(mpsc::RecvTimeoutError::Disconnected) => { + return Err(MotionError::render_failed( + "Chromium closed stderr before publishing its CDP endpoint", + )); + } + } + } + } + + fn shutdown(&mut self) -> std::io::Result<()> { + const PROCESS_TREE_EXIT_TIMEOUT: Duration = Duration::from_secs(5); + + if self.shutdown_complete { + return Ok(()); + } + trace("browser process-tree shutdown start"); + let termination = self.tree.terminate(); + let child_wait = if let Some(mut child) = self.child.take() { + // TerminateJobObject is authoritative on Windows; `kill` is a + // fallback for an attach/termination failure and is harmless + // when the root has already exited. + let _ = child.kill(); + let result = child.wait().map(|_| ()); + // ActiveProcesses stays nonzero while an external process + // handle remains open, so release Child before the Job query. + drop(child); + result + } else { + Ok(()) + }; + + termination?; + self.tree.wait_for_exit(PROCESS_TREE_EXIT_TIMEOUT)?; + self.tree.disarm(); + self.shutdown_complete = true; + child_wait?; + trace("browser process-tree shutdown complete"); + Ok(()) + } + + fn try_wait(&mut self) -> std::io::Result> { + self.child + .as_mut() + .ok_or_else(|| std::io::Error::other("Chromium child handle is missing"))? + .try_wait() + } + + fn remove_profile(&self) { + // Chromium can keep helper processes alive for a few milliseconds + // after its root process exits. Those helpers may race a one-shot + // remove_dir_all by creating a final state file, leaving profiles + // behind on Linux timeout and cancellation paths. + const CLEANUP_ATTEMPTS: usize = 100; + for attempt in 0..CLEANUP_ATTEMPTS { + match std::fs::remove_dir_all(&self.profile) { + Ok(()) => return, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return, + Err(_) if attempt + 1 < CLEANUP_ATTEMPTS => { + thread::sleep(Duration::from_millis(20)); + } + Err(_) => return, + } + } + } + } + + impl Drop for BrowserProcess { + fn drop(&mut self) { + let _ = self.shutdown(); + self.remove_profile(); + } + } + + fn unique_profile_dir() -> PathBuf { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + let counter = PROFILE_COUNTER.fetch_add(1, Ordering::Relaxed); + std::env::temp_dir().join(format!( + "opentake-chromium-{}-{nanos}-{counter}", + std::process::id() + )) + } + + type CdpSocket = WebSocket>; + + fn set_socket_poll_timeout(socket: &CdpSocket) -> MotionResult<()> { + match socket.get_ref() { + MaybeTlsStream::Plain(stream) => stream + .set_read_timeout(Some(Duration::from_millis(50))) + .map_err(MotionError::Io), + _ => Err(MotionError::render_failed( + "the local Chromium CDP endpoint unexpectedly used TLS", + )), + } + } + + struct Cdp { + socket: CdpSocket, + next_id: u64, + policy: SandboxPolicy, + cancellation: MotionCancellationToken, + deadline: Instant, + blocked_url: Option, + pending_events: Vec, + next_capture_generation: u32, + } + + impl Cdp { + fn new( + socket: CdpSocket, + policy: SandboxPolicy, + cancellation: MotionCancellationToken, + deadline: Instant, + ) -> Self { + Self { + socket, + next_id: 1, + policy, + cancellation, + deadline, + blocked_url: None, + pending_events: Vec::new(), + next_capture_generation: 0, + } + } + + fn command( + &mut self, + method: &str, + params: Value, + session: Option<&str>, + ) -> MotionResult { + let id = self.next_id; + self.next_id += 1; + let mut message = json!({"id": id, "method": method, "params": params}); + if let Some(session) = session { + message["sessionId"] = Value::String(session.to_owned()); + } + self.send(message)?; + + loop { + let value = self.read()?; + if value.get("id").and_then(Value::as_u64) == Some(id) { + if let Some(error) = value.get("error") { + return Err(MotionError::render_failed(format!( + "Chromium CDP {method} failed: {error}" + ))); + } + return Ok(value.get("result").cloned().unwrap_or_else(|| json!({}))); + } + self.handle_event_or_queue(value)?; + } + } + + fn gpu_backend_trace(&mut self) -> MotionResult { + let id = self.next_id; + self.next_id += 1; + self.send(json!({ + "id": id, + "method": "SystemInfo.getInfo", + "params": {} + }))?; + + loop { + let value = self.read()?; + if value.get("id").and_then(Value::as_u64) == Some(id) { + if value.get("error").is_some() { + return Ok("gpu backend unavailable reason=command-rejected".to_owned()); + } + return Ok(value + .get("result") + .and_then(gpu_backend_summary) + .unwrap_or_else(|| { + "gpu backend unavailable reason=incomplete-result".to_owned() + })); + } + self.handle_event_or_queue(value)?; + } + } + + fn wait_for_event(&mut self, method: &str, session: Option<&str>) -> MotionResult { + if let Some(index) = self.pending_events.iter().position(|event| { + event.get("method").and_then(Value::as_str) == Some(method) + && session.is_none_or(|expected| { + event.get("sessionId").and_then(Value::as_str) == Some(expected) + }) + }) { + return Ok(self.pending_events.remove(index)); + } + loop { + let value = self.read()?; + if value.get("method").and_then(Value::as_str) == Some(method) + && session.is_none_or(|expected| { + value.get("sessionId").and_then(Value::as_str) == Some(expected) + }) + { + return Ok(value); + } + self.handle_event_or_queue(value)?; + } + } + + fn wait_for_event_matching( + &mut self, + method: &str, + session: Option<&str>, + mut predicate: impl FnMut(&Value) -> bool, + ) -> MotionResult { + if let Some(index) = self.pending_events.iter().position(|event| { + event.get("method").and_then(Value::as_str) == Some(method) + && session.is_none_or(|expected| { + event.get("sessionId").and_then(Value::as_str) == Some(expected) + }) + && predicate(event) + }) { + return Ok(self.pending_events.remove(index)); + } + loop { + let value = self.read()?; + if value.get("method").and_then(Value::as_str) == Some(method) + && session.is_none_or(|expected| { + value.get("sessionId").and_then(Value::as_str) == Some(expected) + }) + && predicate(&value) + { + return Ok(value); + } + self.handle_event_or_queue(value)?; + } + } + + fn wait_for_author_event( + &mut self, + method: &str, + session: &str, + author_frame_id: &str, + mut predicate: impl FnMut(&Value) -> bool, + ) -> MotionResult { + loop { + if let Some(index) = self + .pending_events + .iter() + .position(|event| is_frame_detached_event(event, session, author_frame_id)) + { + self.pending_events.remove(index); + return Err(MotionError::render_failed( + "Chromium author frame detached before loading completed", + )); + } + if let Some(index) = self.pending_events.iter().position(|event| { + event.get("method").and_then(Value::as_str) == Some(method) + && event.get("sessionId").and_then(Value::as_str) == Some(session) + && predicate(event) + }) { + return Ok(self.pending_events.remove(index)); + } + + let value = self.read()?; + if is_frame_detached_event(&value, session, author_frame_id) { + return Err(MotionError::render_failed( + "Chromium author frame detached before loading completed", + )); + } + if value.get("method").and_then(Value::as_str) == Some(method) + && value.get("sessionId").and_then(Value::as_str) == Some(session) + && predicate(&value) + { + return Ok(value); + } + self.handle_event_or_queue(value)?; + } + } + + fn ensure_no_blocked_url(&mut self) -> MotionResult<()> { + if let Some(blocked) = self.blocked_url.take() { + Err(MotionError::sandbox(format!( + "network access to {blocked:?} is not in the allowlist" + ))) + } else { + Ok(()) + } + } + + fn settle_compositor(&mut self, session: &str) -> MotionResult<()> { + trace("virtual-time advance command start"); + self.command( + "Emulation.setVirtualTimePolicy", + json!({ + "policy": "advance", + "budget": 1, + "maxVirtualTimeTaskStarvationCount": 10_000 + }), + Some(session), + )?; + trace("virtual-time advance command complete; budget expiry wait start"); + self.wait_for_event("Emulation.virtualTimeBudgetExpired", Some(session))?; + trace("virtual-time budget expired"); + Ok(()) + } + + fn set_device_metrics( + &mut self, + session: &str, + width: u32, + height: u32, + ) -> MotionResult<()> { + self.command( + "Emulation.setDeviceMetricsOverride", + device_metrics_params(width, height), + Some(session), + )?; + Ok(()) + } + + #[cfg(test)] + fn start_screencast(&mut self, session: &str, width: u32, height: u32) -> MotionResult<()> { + self.command( + "Page.startScreencast", + json!({ + "format": "png", + "maxWidth": width, + "maxHeight": height, + "everyNthFrame": 1 + }), + Some(session), + )?; + self.ensure_no_blocked_url() + } + + fn stop_screencast(&mut self, session: &str) -> MotionResult<()> { + let stopped = self.command("Page.stopScreencast", json!({}), Some(session)); + let drained = if stopped.is_ok() { + self.ack_pending_screencast_frames(session) + } else { + Ok(()) + }; + + stopped?; + drained?; + self.ensure_no_blocked_url() + } + + fn capture_isolated_viewport( + &mut self, + target_id: &str, + rgb: [u8; 3], + width: u32, + height: u32, + frame_index: usize, + background: &str, + ) -> MotionResult { + let generation = self.next_capture_generation; + self.next_capture_generation = + self.next_capture_generation.checked_add(1).ok_or_else(|| { + MotionError::render_failed("Chromium capture generation overflowed") + })?; + let (seed, transition) = capture_generation_colors(generation)?; + let guarded_width = width + .checked_add(1) + .ok_or_else(|| MotionError::render_failed("Chromium guard width overflowed"))?; + let guarded_height = height + .checked_add(1) + .ok_or_else(|| MotionError::render_failed("Chromium guard height overflowed"))?; + let attached = self.command( + "Target.attachToTarget", + json!({"targetId": target_id, "flatten": true}), + None, + )?; + let capture_session = required_string(&attached, "sessionId")?; + let mut started = false; + let captured = (|| -> MotionResult { + self.command("Page.enable", json!({}), Some(&capture_session))?; + self.command( + "Emulation.setVirtualTimePolicy", + json!({"policy": "pause"}), + Some(&capture_session), + )?; + self.set_host_background(&capture_session, seed)?; + self.command( + "Page.startScreencast", + json!({ + "format": "png", + "maxWidth": guarded_width, + "maxHeight": guarded_height, + "everyNthFrame": 1 + }), + Some(&capture_session), + )?; + started = true; + self.ensure_no_blocked_url()?; + trace(format!( + "frame {frame_index}: {background} transition guard start" + )); + self.set_host_background(&capture_session, transition)?; + // The author runs in a separately paused OOPIF target. Advancing + // only the script-free host target gives Windows Chromium a + // bounded lifecycle/compositor turn without advancing author + // timers between the black and white alpha samples. + self.settle_compositor(&capture_session)?; + let transition_image = self.receive_guarded_generation( + &capture_session, + transition, + width, + height, + frame_index, + background, + )?; + drop(transition_image); + trace(format!( + "frame {frame_index}: {background} transition guard complete; desired guard start" + )); + self.set_host_background(&capture_session, rgb)?; + self.settle_compositor(&capture_session)?; + let desired_image = self.receive_guarded_generation( + &capture_session, + rgb, + width, + height, + frame_index, + background, + )?; + trace(format!( + "frame {frame_index}: {background} desired guard complete" + )); + crop_guarded_image(desired_image, width, height, frame_index) + })(); + + let stopped = if started { + self.stop_screencast(&capture_session) + } else { + Ok(()) + }; + let detached = self + .command( + "Target.detachFromTarget", + json!({"sessionId": capture_session}), + None, + ) + .and_then(|_| self.ensure_no_blocked_url()); + self.pending_events.retain(|event| { + event.get("method").and_then(Value::as_str) != Some("Page.screencastFrame") + }); + + match captured { + Err(primary) => Err(primary), + Ok(image) => { + stopped?; + detached?; + self.ensure_no_blocked_url()?; + Ok(image) + } + } + } + + fn receive_guarded_generation( + &mut self, + session: &str, + expected_guard: [u8; 3], + width: u32, + height: u32, + frame_index: usize, + background: &str, + ) -> MotionResult { + loop { + self.check_abort()?; + let png = self.receive_and_ack_screencast_png(session, frame_index)?; + let image = decode_viewport_png(&png, background, frame_index)?; + let expected_dimensions = ( + width.checked_add(1).ok_or_else(|| { + MotionError::render_failed("Chromium guard width overflowed") + })?, + height.checked_add(1).ok_or_else(|| { + MotionError::render_failed("Chromium guard height overflowed") + })?, + ); + if image.dimensions() != expected_dimensions { + return Err(MotionError::render_failed(format!( + "Chromium guarded {background} screencast has the wrong size for frame {frame_index}: actual={:?}, expected={expected_dimensions:?}", + image.dimensions() + ))); + } + if external_guard_matches(&image, width, height, expected_guard) { + return Ok(image); + } + } + } + + fn receive_and_ack_screencast_png( + &mut self, + session: &str, + frame_index: usize, + ) -> MotionResult> { + let event = self.next_screencast_event(session)?; + let params = event.get("params").ok_or_else(|| { + MotionError::render_failed("Chromium screencast frame has no params") + })?; + let screencast_session_id = params + .get("sessionId") + .and_then(Value::as_u64) + .ok_or_else(|| { + MotionError::render_failed(format!( + "Chromium screencast frame has no integer sessionId: {event}" + )) + })?; + self.command( + "Page.screencastFrameAck", + json!({"sessionId": screencast_session_id}), + Some(session), + )?; + self.ensure_no_blocked_url()?; + let encoded = required_string(params, "data")?; + let png = base64::engine::general_purpose::STANDARD + .decode(encoded) + .map_err(|error| { + MotionError::render_failed(format!( + "Chromium returned malformed screencast data for frame {frame_index}: {error}" + )) + })?; + if !png.starts_with(b"\x89PNG\r\n\x1a\n") { + return Err(MotionError::render_failed(format!( + "Chromium returned a non-PNG screencast frame for frame {frame_index}" + ))); + } + Ok(png) + } + + fn next_screencast_event(&mut self, session: &str) -> MotionResult { + if let Some(index) = self + .pending_events + .iter() + .position(|event| is_screencast_event_for_session(event, session)) + { + return Ok(self.pending_events.remove(index)); + } + loop { + let value = self.read()?; + if is_screencast_event_for_session(&value, session) { + return Ok(value); + } + self.handle_event_or_queue(value)?; + } + } + + fn ack_pending_screencast_frames(&mut self, session: &str) -> MotionResult<()> { + while let Some(index) = self + .pending_events + .iter() + .position(|event| is_screencast_event_for_session(event, session)) + { + let event = self.pending_events.remove(index); + let screencast_session_id = event + .get("params") + .and_then(|params| params.get("sessionId")) + .and_then(Value::as_u64) + .ok_or_else(|| { + MotionError::render_failed(format!( + "Chromium screencast frame has no integer sessionId: {event}" + )) + })?; + self.command( + "Page.screencastFrameAck", + json!({"sessionId": screencast_session_id}), + Some(session), + )?; + } + Ok(()) + } + + fn capture_frame_png( + &mut self, + target_id: &str, + session: &str, + transparent: bool, + width: u32, + height: u32, + frame_index: usize, + ) -> MotionResult> { + self.check_abort()?; + if !transparent { + let white = self.capture_stable_background( + target_id, + session, + [255, 255, 255], + "opaque-white", + (width, height), + frame_index, + )?; + self.check_abort()?; + let normalized = encode_viewport_png(white, frame_index)?; + self.check_abort()?; + return Ok(normalized); + } + + // Each background uses two independent PageHandlers whose guard- + // committed images must agree exactly. This is four compositor + // candidates for transparency and two for opaque output. + let black = self.capture_stable_background( + target_id, + session, + [0, 0, 0], + "black", + (width, height), + frame_index, + )?; + let white = self.capture_stable_background( + target_id, + session, + [255, 255, 255], + "white", + (width, height), + frame_index, + )?; + self.check_abort()?; + let recovered = recover_transparent_images(black, white, frame_index)?; + self.check_abort()?; + Ok(recovered) + } + + fn check_abort(&self) -> MotionResult<()> { + check_abort_state(&self.cancellation, self.deadline, self.policy.timeout) + } + + fn close_target(&mut self, target_id: &str) -> MotionResult<()> { + let closed = + self.command("Target.closeTarget", json!({"targetId": target_id}), None)?; + if closed.get("success").and_then(Value::as_bool) != Some(true) { + return Err(MotionError::render_failed( + "Chromium did not close the render target", + )); + } + self.ensure_no_blocked_url() + } + + fn create_browser_context(&mut self) -> MotionResult { + let created = self.command( + "Target.createBrowserContext", + json!({"disposeOnDetach": true}), + None, + )?; + required_string(&created, "browserContextId") + } + + fn dispose_browser_context(&mut self, browser_context_id: &str) -> MotionResult<()> { + self.command( + "Target.disposeBrowserContext", + json!({"browserContextId": browser_context_id}), + None, + )?; + self.ensure_no_blocked_url() + } + + fn set_host_background(&mut self, session: &str, rgb: [u8; 3]) -> MotionResult<()> { + let evaluated = self.command( + "Runtime.evaluate", + json!({ + "expression": host_background_expression(rgb), + "returnByValue": true + }), + Some(session), + )?; + let updated = evaluated + .get("result") + .and_then(|result| result.get("value")) + .and_then(Value::as_bool) + == Some(true) + && evaluated.get("exceptionDetails").is_none(); + if !updated { + return Err(MotionError::render_failed( + "Chromium host background layer update failed", + )); + } + self.ensure_no_blocked_url() + } + + fn capture_stable_background( + &mut self, + target_id: &str, + session: &str, + rgb: [u8; 3], + background: &str, + size: (u32, u32), + frame_index: usize, + ) -> MotionResult { + let (width, height) = size; + self.set_host_background(session, rgb)?; + let first = self.capture_isolated_viewport( + target_id, + rgb, + width, + height, + frame_index, + background, + )?; + let second = self.capture_isolated_viewport( + target_id, + rgb, + width, + height, + frame_index, + background, + )?; + if let Err(error) = + ensure_stable_viewport_images(&first, &second, background, frame_index) + { + return Err(MotionError::render_failed(format!( + "{error}; sandbox_blocked_url_seen={}", + self.blocked_url.is_some() + ))); + } + drop(first); + self.check_abort()?; + Ok(second) + } + + fn send(&mut self, value: Value) -> MotionResult<()> { + self.socket + .send(Message::text(value.to_string())) + .map_err(|error| { + MotionError::render_failed(format!( + "failed to send Chromium CDP command: {error}" + )) + }) + } + + fn read(&mut self) -> MotionResult { + loop { + self.check_abort()?; + match self.socket.read() { + Ok(Message::Text(text)) => { + return serde_json::from_str(text.as_ref()).map_err(|error| { + MotionError::render_failed(format!( + "Chromium sent malformed CDP JSON: {error}" + )) + }); + } + Ok(Message::Ping(payload)) => { + self.socket.send(Message::Pong(payload)).map_err(|error| { + MotionError::render_failed(format!( + "failed to answer Chromium CDP ping: {error}" + )) + })?; + } + Ok(Message::Close(reason)) => { + return Err(MotionError::render_failed(format!( + "Chromium CDP connection closed unexpectedly: {reason:?}" + ))); + } + Ok(_) => {} + Err(tungstenite::Error::Io(error)) + if matches!( + error.kind(), + std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut + ) => {} + Err(error) => { + return Err(MotionError::render_failed(format!( + "failed to read Chromium CDP response: {error}" + ))); + } + } + } + } + + fn handle_event_or_queue(&mut self, value: Value) -> MotionResult<()> { + match value.get("method").and_then(Value::as_str) { + Some("Fetch.requestPaused") => self.handle_request(&value), + Some("Log.entryAdded") => { + let text = value + .get("params") + .and_then(|params| params.get("entry")) + .and_then(|entry| entry.get("text")) + .and_then(Value::as_str) + .unwrap_or_default(); + if (text.contains("Content Security Policy") + || text.contains("Refused to load") + || text.contains("Not allowed to load local resource") + || text.contains("violates the following")) + && self.blocked_url.is_none() + { + self.blocked_url = Some(text.to_owned()); + } + Ok(()) + } + Some("Inspector.targetCrashed" | "Target.targetCrashed") => { + Err(MotionError::render_failed("Chromium render target crashed")) + } + Some(_) => { + if self.pending_events.len() >= 256 { + self.pending_events.remove(0); + } + self.pending_events.push(value); + Ok(()) + } + None => Ok(()), + } + } + + fn handle_request(&mut self, event: &Value) -> MotionResult<()> { + let params = event.get("params").ok_or_else(|| { + MotionError::render_failed("Fetch.requestPaused event has no params") + })?; + let request_id = required_string(params, "requestId")?; + let url = params + .get("request") + .and_then(|request| request.get("url")) + .and_then(Value::as_str) + .ok_or_else(|| { + MotionError::render_failed("Fetch.requestPaused event has no request URL") + })?; + let session = event.get("sessionId").and_then(Value::as_str); + let allowed = url == "about:blank" || self.policy.check_url(url).is_ok(); + let id = self.next_id; + self.next_id += 1; + let (method, params) = if allowed { + ("Fetch.continueRequest", json!({"requestId": request_id})) + } else { + if self.blocked_url.is_none() { + self.blocked_url = Some(url.to_owned()); + } + ( + "Fetch.failRequest", + json!({"requestId": request_id, "errorReason": "BlockedByClient"}), + ) + }; + let mut message = json!({"id": id, "method": method, "params": params}); + if let Some(session) = session { + message["sessionId"] = Value::String(session.to_owned()); + } + trace(format!("{method}: request observed")); + self.send(message) + } + } + + fn trace_gpu_backend_if_enabled(cdp: &mut Cdp, enabled: bool) -> MotionResult<()> { + if enabled { + trace(cdp.gpu_backend_trace()?); + } + Ok(()) + } + + fn is_frame_detached_event(event: &Value, session: &str, frame_id: &str) -> bool { + event.get("method").and_then(Value::as_str) == Some("Page.frameDetached") + && event.get("sessionId").and_then(Value::as_str) == Some(session) + && event + .get("params") + .and_then(|params| params.get("frameId")) + .and_then(Value::as_str) + == Some(frame_id) + } + + fn is_screencast_event_for_session(event: &Value, session: &str) -> bool { + event.get("method").and_then(Value::as_str) == Some("Page.screencastFrame") + && event.get("sessionId").and_then(Value::as_str) == Some(session) + } + + fn gpu_backend_summary(result: &Value) -> Option { + let gpu = result.get("gpu")?; + let device = gpu.get("devices")?.as_array()?.first()?; + let vendor = + bounded_gpu_trace_field(device.get("vendorString")?.as_str()?, GPU_TRACE_FIELD_LIMIT); + let device = + bounded_gpu_trace_field(device.get("deviceString")?.as_str()?, GPU_TRACE_FIELD_LIMIT); + let gpu_compositing = bounded_gpu_trace_field( + gpu.get("featureStatus")?.get("gpu_compositing")?.as_str()?, + GPU_TRACE_STATUS_LIMIT, + ); + let backend = format!("{vendor} {device}").to_ascii_lowercase(); + let class = if backend.contains("swiftshader") { + "swiftshader" + } else if vendor.eq_ignore_ascii_case("disabled") || device.eq_ignore_ascii_case("disabled") + { + "disabled" + } else { + "driver" + }; + Some(format!( + "gpu backend class={class} vendor=\"{vendor}\" device=\"{device}\" gpu_compositing=\"{gpu_compositing}\"" + )) + } + + fn bounded_gpu_trace_field(value: &str, limit: usize) -> String { + let mut chars = value.chars(); + let mut bounded = String::with_capacity(limit + 1); + for character in chars.by_ref().take(limit) { + let safe = match character { + ' '..='!' | '#'..='[' | ']'..='~' => character, + _ => '?', + }; + bounded.push(safe); + } + if chars.next().is_some() { + bounded.push('…'); + } + bounded + } + + fn device_metrics_params(width: u32, height: u32) -> Value { + json!({ + "width": width, + "height": height, + "deviceScaleFactor": 1, + "mobile": false, + "screenWidth": width, + "screenHeight": height + }) + } + + fn recover_transparent_images( + black: image::RgbaImage, + white: image::RgbaImage, + frame_index: usize, + ) -> MotionResult> { + if black.dimensions() != white.dimensions() { + return Err(MotionError::render_failed(format!( + "Chromium returned inconsistent viewport sizes for frame {frame_index}: black={:?}, white={:?}", + black.dimensions(), + white.dimensions() + ))); + } + + let rgba = recover_straight_alpha(black.as_raw(), white.as_raw())?; + let (width, height) = black.dimensions(); + let image = image::RgbaImage::from_raw(width, height, rgba).ok_or_else(|| { + MotionError::render_failed(format!( + "recovered RGBA buffer has the wrong size for frame {frame_index}" + )) + })?; + encode_viewport_png(image, frame_index) + } + + fn ensure_stable_viewport_images( + fence: &image::RgbaImage, + captured: &image::RgbaImage, + background: &str, + frame_index: usize, + ) -> MotionResult<()> { + if fence.dimensions() != captured.dimensions() { + return Err(MotionError::render_failed(format!( + "Chromium {background}-background returned inconsistent stable-readback sizes for frame {frame_index}: fence={:?}, captured={:?}", + fence.dimensions(), + captured.dimensions() + ))); + } + if fence.as_raw() != captured.as_raw() { + let (width, height) = fence.dimensions(); + let mut differing_channels = 0usize; + let mut sample_coordinates = Vec::with_capacity(8); + for ((x, y, left), right) in fence.enumerate_pixels().zip(captured.pixels()) { + let changed_channels = left + .0 + .iter() + .zip(right.0.iter()) + .filter(|(left, right)| left != right) + .count(); + differing_channels += changed_channels; + if changed_channels > 0 && sample_coordinates.len() < 8 { + sample_coordinates.push((x, y)); + } + } + return Err(MotionError::render_failed(format!( + "Chromium {background}-background did not reach a stable readback for frame {frame_index}: dimensions=({width}, {height}), differing_channels={differing_channels}, sample_coordinates={sample_coordinates:?}" + ))); + } + Ok(()) + } + + fn decode_viewport_png( + png: &[u8], + background: &str, + frame_index: usize, + ) -> MotionResult { + let image = image::load_from_memory_with_format(png, image::ImageFormat::Png) + .map(image::DynamicImage::into_rgba8) + .map_err(|error| { + MotionError::render_failed(format!( + "Chromium returned an invalid {background}-background PNG for frame {frame_index}: {error}" + )) + })?; + let (actual_width, actual_height) = image.dimensions(); + if actual_width == 0 || actual_height == 0 { + return Err(MotionError::render_failed(format!( + "Chromium returned an empty {background}-background viewport for frame {frame_index}" + ))); + } + Ok(image) + } + + fn encode_viewport_png(image: image::RgbaImage, frame_index: usize) -> MotionResult> { + let mut encoded = Cursor::new(Vec::new()); + image::DynamicImage::ImageRgba8(image) + .write_to(&mut encoded, image::ImageFormat::Png) + .map_err(|error| { + MotionError::render_failed(format!( + "failed to encode recovered transparent frame {frame_index}: {error}" + )) + })?; + Ok(encoded.into_inner()) + } + + fn capture_generation_colors(generation: u32) -> MotionResult<([u8; 3], [u8; 3])> { + let generation = u16::try_from(generation).map_err(|_| { + MotionError::render_failed("Chromium capture generation exceeded its unique range") + })?; + let [low, high] = generation.to_le_bytes(); + Ok(([low, high, 90], [low, high, 165])) + } + + fn host_background_expression(rgb: [u8; 3]) -> String { + format!( + "(() => {{ const layer = document.getElementById('opentake-host-background'); if (!layer) return false; layer.style.backgroundColor = 'rgb({} {} {})'; return true; }})()", + rgb[0], rgb[1], rgb[2] + ) + } + + fn external_guard_matches( + image: &image::RgbaImage, + width: u32, + height: u32, + expected: [u8; 3], + ) -> bool { + let Some(guarded_width) = width.checked_add(1) else { + return false; + }; + let Some(guarded_height) = height.checked_add(1) else { + return false; + }; + if image.dimensions() != (guarded_width, guarded_height) { + return false; + } + let expected = [expected[0], expected[1], expected[2], 255]; + (0..guarded_height).all(|y| image.get_pixel(width, y).0 == expected) + && (0..guarded_width).all(|x| image.get_pixel(x, height).0 == expected) + } + + fn crop_guarded_image( + image: image::RgbaImage, + width: u32, + height: u32, + frame_index: usize, + ) -> MotionResult { + let guarded_width = width + .checked_add(1) + .ok_or_else(|| MotionError::render_failed("Chromium host guard width overflowed"))?; + let guarded_height = height + .checked_add(1) + .ok_or_else(|| MotionError::render_failed("Chromium host guard height overflowed"))?; + if image.dimensions() != (guarded_width, guarded_height) { + return Err(MotionError::render_failed(format!( + "Chromium guarded frame {frame_index} has the wrong size: actual={:?}, expected=({guarded_width}, {guarded_height})", + image.dimensions() + ))); + } + + let guarded_stride = guarded_width as usize * 4; + let content_stride = width as usize * 4; + let mut raw = image.into_raw(); + for row in 1..height as usize { + let source = row * guarded_stride; + let destination = row * content_stride; + raw.copy_within(source..source + content_stride, destination); + } + raw.truncate(content_stride * height as usize); + image::RgbaImage::from_raw(width, height, raw).ok_or_else(|| { + MotionError::render_failed(format!( + "failed to crop Chromium host guard for frame {frame_index}" + )) + }) + } + + fn recover_straight_alpha(black: &[u8], white: &[u8]) -> MotionResult> { + if black.len() != white.len() || !black.len().is_multiple_of(4) { + return Err(MotionError::render_failed( + "black/white viewport captures have incompatible RGBA buffers", + )); + } + + let mut recovered = Vec::with_capacity(black.len()); + for (black, white) in black.chunks_exact(4).zip(white.chunks_exact(4)) { + let mut deltas = [ + white[0].saturating_sub(black[0]), + white[1].saturating_sub(black[1]), + white[2].saturating_sub(black[2]), + ]; + deltas.sort_unstable(); + let alpha = 255_u8.saturating_sub(deltas[1]); + for channel in &black[..3] { + let straight = if alpha == 0 { + 0 + } else { + ((u32::from(*channel) * 255 + u32::from(alpha) / 2) / u32::from(alpha)).min(255) + as u8 + }; + recovered.push(straight); + } + recovered.push(alpha); + } + Ok(recovered) + } + + #[cfg(test)] + mod tests { + use std::net::{TcpListener, TcpStream}; + + use tungstenite::protocol::Role; + + use super::*; + + fn fake_cdp_pair() -> (Cdp, WebSocket) { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let client_stream = TcpStream::connect(listener.local_addr().unwrap()).unwrap(); + let (server_stream, _) = listener.accept().unwrap(); + let client_socket = WebSocket::from_raw_socket( + MaybeTlsStream::Plain(client_stream), + Role::Client, + None, + ); + let server_socket = WebSocket::from_raw_socket(server_stream, Role::Server, None); + ( + Cdp::new( + client_socket, + SandboxPolicy::default(), + MotionCancellationToken::new(), + Instant::now() + Duration::from_secs(1), + ), + server_socket, + ) + } + + fn validate_browser_launch_args_contract(args: &[&str]) -> Result<(), String> { + const EXPECTED: &[&str] = &[ + "--headless=new", + "--remote-debugging-port=0", + "--remote-debugging-address=127.0.0.1", + "--no-first-run", + "--no-default-browser-check", + "--disable-background-networking", + "--disable-component-update", + "--disable-client-side-phishing-detection", + "--disable-domain-reliability", + "--disable-sync", + "--disable-background-timer-throttling", + "--disable-backgrounding-occluded-windows", + "--disable-renderer-backgrounding", + "--run-all-compositor-stages-before-draw", + "--metrics-recording-only", + "--disable-breakpad", + "--disable-extensions", + "--disable-dev-shm-usage", + "--disable-features=FileSystemAccessAPI,InterestFeedContentSuggestions,OptimizationHints,MediaRouter", + "--password-store=basic", + "--use-mock-keychain", + "about:blank", + ]; + let mut keys = std::collections::BTreeSet::new(); + for argument in args.iter().filter(|argument| argument.starts_with("--")) { + let key = argument.split_once('=').map_or(*argument, |(key, _)| key); + if key == "--disable-gpu" { + return Err(format!("forbidden Chromium switch key: {key}")); + } + if !keys.insert(key) { + return Err(format!("duplicate Chromium switch key: {key}")); + } + } + if args != EXPECTED { + return Err("Chromium launch arguments differ from the expected contract".into()); + } + Ok(()) + } + + #[test] + fn browser_stderr_is_drained_after_the_endpoint_receiver_disconnects() { + let mut stderr = + b"DevTools listening on ws://127.0.0.1/devtools/browser/test\n".to_vec(); + for index in 0..512 { + stderr.extend_from_slice(format!("gpu-viz-diagnostic-{index:04}\n").as_bytes()); + } + let (sender, receiver) = mpsc::channel(); + drop(receiver); + + assert_eq!( + drain_browser_stderr(Cursor::new(stderr), sender), + 513, + "Chrome stderr must be consumed through EOF even after endpoint discovery" + ); + } + + #[test] + fn pending_browser_invalidation_covers_idle_busy_and_handoff_interleavings() { + let pool = BrowserPool::new(); + + pool.invalidate_idle(); + assert!(!pool.invalidation_pending.load(Ordering::Acquire)); + + let active_lease_seam = pool.slot.lock().unwrap(); + pool.invalidate_idle(); + assert!(pool.invalidation_pending.load(Ordering::Acquire)); + drop(active_lease_seam); + + pool.drain_pending_invalidation(); + assert!(!pool.invalidation_pending.load(Ordering::Acquire)); + + pool.invalidate_idle(); + assert!(!pool.invalidation_pending.load(Ordering::Acquire)); + } + + #[test] + fn acquire_observed_invalidation_taints_the_new_browser_lease() { + let pool = BrowserPool::new(); + let slot = pool.slot.lock().unwrap(); + pool.invalidation_pending.store(true, Ordering::Release); + let observed_invalidation = pool.invalidation_pending.swap(false, Ordering::AcqRel); + assert!(observed_invalidation); + + let mut lease = BrowserLease { + pool: &pool, + slot: Some(slot), + reusable: false, + observed_invalidation, + }; + lease.commit_reuse(); + assert!( + !lease.reusable, + "an acquire overlapping invalidation must not retain a subsequently launched browser" + ); + } + + #[test] + fn browser_launch_args_bind_remote_debugging_to_loopback() { + validate_browser_launch_args_contract(browser_launch_args()).unwrap(); + } + + #[test] + fn browser_launch_args_contract_rejects_disable_gpu() { + let mut disable_gpu = browser_launch_args().to_vec(); + disable_gpu.push("--disable-gpu=true"); + assert_eq!( + validate_browser_launch_args_contract(&disable_gpu).unwrap_err(), + "forbidden Chromium switch key: --disable-gpu" + ); + } + + #[test] + fn host_wrapper_isolates_author_in_an_exact_child_viewport() { + let wrapper = host_wrapper_document( + r#"
"#, + 48, + 32, + &SandboxPolicy::default(), + ); + assert!(wrapper.contains("sandbox=\"allow-scripts\"")); + assert!(!wrapper.contains("allow-same-origin")); + assert!(!wrapper.contains("allow-top-navigation")); + assert!(wrapper.contains("frame-src data:")); + assert!(wrapper.contains("width:48px;height:32px")); + assert!(wrapper.contains("overflow:hidden;background:transparent")); + assert!(wrapper.contains("id=\"opentake-host-background\"")); + assert!(wrapper.contains( + "#opentake-host-background{position:fixed;inset:0;z-index:0;background:transparent}" + )); + assert!(wrapper.contains("iframe{position:absolute;left:0;top:0;z-index:1")); + assert!(wrapper.contains("src=\"data:text/html;charset=utf-8,")); + assert!(!wrapper.contains("srcdoc=")); + assert!(!wrapper.contains("author", 48, 32, &SandboxPolicy::default()); + let expected_wrapper = wrapper.clone(); + let server = thread::spawn(move || { + let auto_attach = match server_socket.read().unwrap() { + Message::Text(text) => serde_json::from_str::(text.as_ref()).unwrap(), + other => panic!("expected auto-attach request, got {other:?}"), + }; + assert_eq!( + auto_attach, + json!({ + "id": 1, + "method": "Target.setAutoAttach", + "params": { + "autoAttach": true, + "waitForDebuggerOnStart": true, + "flatten": true, + "filter": [ + {"type": "iframe", "exclude": false}, + {"exclude": true} + ] + }, + "sessionId": "render-session" + }) + ); + server_socket + .send(Message::text(json!({"id": 1, "result": {}}).to_string())) + .unwrap(); + + let get_tree = match server_socket.read().unwrap() { + Message::Text(text) => serde_json::from_str::(text.as_ref()).unwrap(), + other => panic!("expected frame-tree request, got {other:?}"), + }; + assert_eq!( + get_tree, + json!({ + "id": 2, + "method": "Page.getFrameTree", + "params": {}, + "sessionId": "render-session" + }) + ); + server_socket + .send(Message::text( + json!({ + "id": 2, + "result": {"frameTree": {"frame": {"id": "main-frame"}}} + }) + .to_string(), + )) + .unwrap(); + + let set_content = match server_socket.read().unwrap() { + Message::Text(text) => serde_json::from_str::(text.as_ref()).unwrap(), + other => panic!("expected document-content request, got {other:?}"), + }; + assert_eq!( + set_content, + json!({ + "id": 3, + "method": "Page.setDocumentContent", + "params": {"frameId": "main-frame", "html": expected_wrapper}, + "sessionId": "render-session" + }) + ); + for event in [ + json!({ + "method": "Page.frameAttached", + "params": {"frameId": "provisional-frame", "parentFrameId": "main-frame"}, + "sessionId": "render-session" + }), + json!({ + "method": "Page.frameDetached", + "params": {"frameId": "provisional-frame", "reason": "swap"}, + "sessionId": "render-session" + }), + json!({ + "method": "Target.attachedToTarget", + "params": { + "sessionId": "author-session", + "targetInfo": { + "targetId": "author-frame", + "type": "iframe", + "url": "", + "attached": true + }, + "waitingForDebugger": true + }, + "sessionId": "render-session" + }), + ] { + server_socket + .send(Message::text(event.to_string())) + .unwrap(); + } + server_socket + .send(Message::text(json!({"id": 3, "result": {}}).to_string())) + .unwrap(); + + for (id, method, params) in [ + (4, "Runtime.enable", json!({})), + (5, "Page.enable", json!({})), + (6, "Log.enable", json!({})), + ( + 7, + "Fetch.enable", + json!({"patterns": [{"urlPattern": "*", "requestStage": "Request"}]}), + ), + ( + 8, + "Page.addScriptToEvaluateOnNewDocument", + json!({"source": deterministic_clock_script()}), + ), + (9, "Runtime.runIfWaitingForDebugger", json!({})), + ] { + let command = match server_socket.read().unwrap() { + Message::Text(text) => { + serde_json::from_str::(text.as_ref()).unwrap() + } + other => panic!("expected child setup request, got {other:?}"), + }; + assert_eq!( + command, + json!({ + "id": id, + "method": method, + "params": params, + "sessionId": "author-session" + }) + ); + if method == "Runtime.runIfWaitingForDebugger" { + server_socket + .send(Message::text( + json!({ + "method": "Fetch.requestPaused", + "params": { + "requestId": "author-data-request", + "request": {"url": "data:text/html,author"} + }, + "sessionId": "author-session" + }) + .to_string(), + )) + .unwrap(); + } + server_socket + .send(Message::text(json!({"id": id, "result": {}}).to_string())) + .unwrap(); + } + + let continued = match server_socket.read().unwrap() { + Message::Text(text) => serde_json::from_str::(text.as_ref()).unwrap(), + other => panic!("expected child data request continuation, got {other:?}"), + }; + assert_eq!( + continued, + json!({ + "id": 10, + "method": "Fetch.continueRequest", + "params": {"requestId": "author-data-request"}, + "sessionId": "author-session" + }) + ); + server_socket + .send(Message::text(json!({"id": 10, "result": {}}).to_string())) + .unwrap(); + + for event in [ + json!({ + "method": "Page.frameNavigated", + "params": {"frame": { + "id": "author-frame", + "parentId": "main-frame", + "url": "data:text/html" + }}, + "sessionId": "author-session" + }), + json!({ + "method": "Runtime.executionContextCreated", + "params": {"context": { + "id": 17, + "auxData": {"frameId": "author-frame", "isDefault": true} + }}, + "sessionId": "author-session" + }), + json!({ + "method": "Page.frameStoppedLoading", + "params": {"frameId": "author-frame"}, + "sessionId": "author-session" + }), + json!({ + "method": "Page.loadEventFired", + "params": {"timestamp": 2.0}, + "sessionId": "author-session" + }), + ] { + server_socket + .send(Message::text(event.to_string())) + .unwrap(); + } + }); + + let mut cdp = Cdp::new( + client_socket, + SandboxPolicy::default(), + MotionCancellationToken::new(), + Instant::now() + Duration::from_secs(1), + ); + let installed = install_host_document(&mut cdp, "render-session", &wrapper).unwrap(); + assert_eq!(installed.author_session_id, "author-session"); + assert_eq!(installed.author_context_id, 17); + server.join().unwrap(); + } + + #[test] + fn author_navigation_must_be_a_data_child_of_the_host_frame() { + let navigation = |url: &str, parent: &str| { + json!({ + "method": "Page.frameNavigated", + "params": {"frame": { + "id": "author-frame", + "parentId": parent, + "url": url + }}, + "sessionId": "author-session" + }) + }; + validate_author_navigation( + &navigation("data:text/html,author", "main-frame"), + "author-frame", + "main-frame", + ) + .unwrap(); + for rejected in [ + navigation("about:blank", "main-frame"), + navigation("http://127.0.0.1/author", "main-frame"), + navigation("data:text/html,author", "other-main-frame"), + ] { + assert!( + validate_author_navigation(&rejected, "author-frame", "main-frame").is_err() + ); + } + } + + #[test] + fn oopif_fetch_routes_loopback_policy_on_the_child_session() { + fn assert_route(policy: SandboxPolicy, expected_method: &str, should_block: bool) { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let client_stream = TcpStream::connect(listener.local_addr().unwrap()).unwrap(); + let (server_stream, _) = listener.accept().unwrap(); + let client_socket = WebSocket::from_raw_socket( + MaybeTlsStream::Plain(client_stream), + Role::Client, + None, + ); + let mut server_socket = + WebSocket::from_raw_socket(server_stream, Role::Server, None); + let expected_method = expected_method.to_owned(); + let server = thread::spawn(move || { + let command = match server_socket.read().unwrap() { + Message::Text(text) => { + serde_json::from_str::(text.as_ref()).unwrap() + } + other => panic!("expected child Fetch decision, got {other:?}"), + }; + assert_eq!(command["method"], expected_method); + assert_eq!(command["params"]["requestId"], "oopif-loopback"); + assert_eq!(command["sessionId"], "author-session"); + }); + let mut cdp = Cdp::new( + client_socket, + policy, + MotionCancellationToken::new(), + Instant::now() + Duration::from_secs(1), + ); + cdp.handle_event_or_queue(json!({ + "method": "Fetch.requestPaused", + "params": { + "requestId": "oopif-loopback", + "request": {"url": "http://127.0.0.1:51203/pixel.svg"} + }, + "sessionId": "author-session" + })) + .unwrap(); + assert_eq!(cdp.ensure_no_blocked_url().is_err(), should_block); + server.join().unwrap(); + } + + assert_route(SandboxPolicy::default(), "Fetch.failRequest", true); + assert_route( + SandboxPolicy::default().allow_origin("http://127.0.0.1:51203"), + "Fetch.continueRequest", + false, + ); + } + + #[test] + fn external_guard_validation_and_crop_preserve_the_authors_legal_edge() { + let guard = [90, 91, 92]; + let mut guarded = image::RgbaImage::from_pixel(3, 3, image::Rgba([90, 91, 92, 255])); + for (x, y, pixel) in [ + (0, 0, [1, 2, 3, 255]), + (1, 0, [4, 5, 6, 255]), + (0, 1, [7, 8, 9, 255]), + (1, 1, [10, 11, 12, 255]), + ] { + guarded.put_pixel(x, y, image::Rgba(pixel)); + } + assert!(external_guard_matches(&guarded, 2, 2, guard)); + guarded.put_pixel(1, 1, image::Rgba([200, 201, 202, 255])); + assert!( + external_guard_matches(&guarded, 2, 2, guard), + "the author's legal bottom-right pixel is content, not guard" + ); + let mut wrong_guard = guarded.clone(); + wrong_guard.put_pixel(2, 1, image::Rgba([90, 91, 93, 255])); + assert!(!external_guard_matches(&wrong_guard, 2, 2, guard)); + + let cropped = crop_guarded_image(guarded, 2, 2, 0).unwrap(); + assert_eq!(cropped.dimensions(), (2, 2)); + assert_eq!(cropped.get_pixel(0, 0).0, [1, 2, 3, 255]); + assert_eq!(cropped.get_pixel(1, 0).0, [4, 5, 6, 255]); + assert_eq!(cropped.get_pixel(0, 1).0, [7, 8, 9, 255]); + assert_eq!(cropped.get_pixel(1, 1).0, [200, 201, 202, 255]); + } + + #[test] + fn compositor_fence_advances_a_finite_budget_and_waits_for_expiry() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let client_stream = TcpStream::connect(listener.local_addr().unwrap()).unwrap(); + let (server_stream, _) = listener.accept().unwrap(); + let client_socket = WebSocket::from_raw_socket( + MaybeTlsStream::Plain(client_stream), + Role::Client, + None, + ); + let mut server_socket = WebSocket::from_raw_socket(server_stream, Role::Server, None); + let server = thread::spawn(move || { + let request = match server_socket.read().unwrap() { + Message::Text(text) => serde_json::from_str::(text.as_ref()).unwrap(), + other => panic!("expected text CDP request, got {other:?}"), + }; + assert_eq!( + request, + json!({ + "id": 1, + "method": "Emulation.setVirtualTimePolicy", + "params": { + "policy": "advance", + "budget": 1, + "maxVirtualTimeTaskStarvationCount": 10_000 + }, + "sessionId": "render-session" + }) + ); + server_socket + .send(Message::text( + json!({ + "method": "Emulation.virtualTimeBudgetExpired", + "params": {}, + "sessionId": "render-session" + }) + .to_string(), + )) + .unwrap(); + server_socket + .send(Message::text(json!({"id": 1, "result": {}}).to_string())) + .unwrap(); + }); + + let mut cdp = Cdp::new( + client_socket, + SandboxPolicy::default(), + MotionCancellationToken::new(), + Instant::now() + Duration::from_secs(1), + ); + cdp.settle_compositor("render-session").unwrap(); + assert!(cdp.pending_events.is_empty()); + server.join().unwrap(); + } + + #[test] + fn device_metrics_keep_layout_and_capture_viewport_exact() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let client_stream = TcpStream::connect(listener.local_addr().unwrap()).unwrap(); + let (server_stream, _) = listener.accept().unwrap(); + let client_socket = WebSocket::from_raw_socket( + MaybeTlsStream::Plain(client_stream), + Role::Client, + None, + ); + let mut server_socket = WebSocket::from_raw_socket(server_stream, Role::Server, None); + let server = thread::spawn(move || { + let request = match server_socket.read().unwrap() { + Message::Text(text) => serde_json::from_str::(text.as_ref()).unwrap(), + other => panic!("expected device-metrics request, got {other:?}"), + }; + assert_eq!( + request, + json!({ + "id": 1, + "method": "Emulation.setDeviceMetricsOverride", + "params": { + "width": 48, + "height": 32, + "deviceScaleFactor": 1, + "mobile": false, + "screenWidth": 48, + "screenHeight": 32 + }, + "sessionId": "render-session" + }) + ); + server_socket + .send(Message::text(json!({"id": 1, "result": {}}).to_string())) + .unwrap(); + }); + + let mut cdp = Cdp::new( + client_socket, + SandboxPolicy::default(), + MotionCancellationToken::new(), + Instant::now() + Duration::from_secs(1), + ); + cdp.set_device_metrics("render-session", 48, 32).unwrap(); + server.join().unwrap(); + } + + #[test] + fn guarded_candidate_requires_transition_then_desired_generation() { + fn guarded_png(author: [u8; 4], guard: [u8; 3]) -> String { + let mut image = image::RgbaImage::from_pixel( + 2, + 2, + image::Rgba([guard[0], guard[1], guard[2], 255]), + ); + image.put_pixel(0, 0, image::Rgba(author)); + base64::engine::general_purpose::STANDARD + .encode(encode_viewport_png(image, 0).unwrap()) + } + + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let client_stream = TcpStream::connect(listener.local_addr().unwrap()).unwrap(); + let (server_stream, _) = listener.accept().unwrap(); + let client_socket = WebSocket::from_raw_socket( + MaybeTlsStream::Plain(client_stream), + Role::Client, + None, + ); + let mut server_socket = WebSocket::from_raw_socket(server_stream, Role::Server, None); + let seed = [0, 0, 90]; + let transition = [0, 0, 165]; + let desired = [0, 0, 0]; + let wrong = guarded_png([1, 2, 3, 255], [17, 18, 19]); + let transition_frame = guarded_png([4, 5, 6, 255], transition); + let desired_frame = guarded_png([128, 0, 0, 255], desired); + let host_background = |rgb: [u8; 3]| { + json!({ + "expression": format!( + "(() => {{ const layer = document.getElementById('opentake-host-background'); if (!layer) return false; layer.style.backgroundColor = 'rgb({} {} {})'; return true; }})()", + rgb[0], rgb[1], rgb[2] + ), + "returnByValue": true + }) + }; + let server = thread::spawn(move || { + let read_json = |socket: &mut WebSocket| match socket.read().unwrap() { + Message::Text(text) => serde_json::from_str::(text.as_ref()).unwrap(), + other => panic!("expected CDP command, got {other:?}"), + }; + let send_json = |socket: &mut WebSocket, value: Value| { + socket.send(Message::text(value.to_string())).unwrap(); + }; + + let attach = read_json(&mut server_socket); + assert_eq!(attach["method"], "Target.attachToTarget"); + send_json( + &mut server_socket, + json!({"id": 1, "result": {"sessionId": "capture-session"}}), + ); + for (id, method, params) in [ + (2, "Page.enable", json!({})), + ( + 3, + "Emulation.setVirtualTimePolicy", + json!({"policy": "pause"}), + ), + (4, "Runtime.evaluate", host_background(seed)), + ( + 5, + "Page.startScreencast", + json!({ + "format": "png", + "maxWidth": 2, + "maxHeight": 2, + "everyNthFrame": 1 + }), + ), + (6, "Runtime.evaluate", host_background(transition)), + ] { + let command = read_json(&mut server_socket); + assert_eq!( + command, + json!({ + "id": id, + "method": method, + "params": params, + "sessionId": "capture-session" + }) + ); + let result = if method == "Runtime.evaluate" { + json!({"result": {"type": "boolean", "value": true}}) + } else { + json!({}) + }; + send_json(&mut server_socket, json!({"id": id, "result": result})); + } + + let transition_fence = read_json(&mut server_socket); + assert_eq!( + transition_fence, + json!({ + "id": 7, + "method": "Emulation.setVirtualTimePolicy", + "params": { + "policy": "advance", + "budget": 1, + "maxVirtualTimeTaskStarvationCount": 10_000 + }, + "sessionId": "capture-session" + }) + ); + send_json(&mut server_socket, json!({"id": 7, "result": {}})); + send_json( + &mut server_socket, + json!({ + "method": "Emulation.virtualTimeBudgetExpired", + "params": {}, + "sessionId": "capture-session" + }), + ); + + for (id, data) in [(8, wrong), (9, transition_frame.clone())] { + send_json( + &mut server_socket, + json!({ + "method": "Page.screencastFrame", + "params": {"data": data, "metadata": {}, "sessionId": 70 + id}, + "sessionId": "capture-session" + }), + ); + let ack = read_json(&mut server_socket); + assert_eq!(ack["id"], id); + assert_eq!(ack["method"], "Page.screencastFrameAck"); + send_json(&mut server_socket, json!({"id": id, "result": {}})); + } + + let desired_command = read_json(&mut server_socket); + assert_eq!( + desired_command, + json!({ + "id": 10, + "method": "Runtime.evaluate", + "params": host_background(desired), + "sessionId": "capture-session" + }) + ); + send_json( + &mut server_socket, + json!({"id": 10, "result": {"result": {"type": "boolean", "value": true}}}), + ); + + let desired_fence = read_json(&mut server_socket); + assert_eq!( + desired_fence, + json!({ + "id": 11, + "method": "Emulation.setVirtualTimePolicy", + "params": { + "policy": "advance", + "budget": 1, + "maxVirtualTimeTaskStarvationCount": 10_000 + }, + "sessionId": "capture-session" + }) + ); + send_json(&mut server_socket, json!({"id": 11, "result": {}})); + send_json( + &mut server_socket, + json!({ + "method": "Emulation.virtualTimeBudgetExpired", + "params": {}, + "sessionId": "capture-session" + }), + ); + + for (id, data) in [(12, transition_frame), (13, desired_frame)] { + send_json( + &mut server_socket, + json!({ + "method": "Page.screencastFrame", + "params": {"data": data, "metadata": {}, "sessionId": 70 + id}, + "sessionId": "capture-session" + }), + ); + let ack = read_json(&mut server_socket); + assert_eq!(ack["id"], id); + assert_eq!(ack["method"], "Page.screencastFrameAck"); + send_json(&mut server_socket, json!({"id": id, "result": {}})); + } + + for (id, method, params, session) in [ + ( + 14, + "Page.stopScreencast", + json!({}), + Some("capture-session"), + ), + ( + 15, + "Target.detachFromTarget", + json!({"sessionId": "capture-session"}), + None, + ), + ] { + let command = read_json(&mut server_socket); + assert_eq!(command["id"], id); + assert_eq!(command["method"], method); + assert_eq!(command["params"], params); + assert_eq!(command.get("sessionId").and_then(Value::as_str), session); + send_json(&mut server_socket, json!({"id": id, "result": {}})); + } + }); + + let mut cdp = Cdp::new( + client_socket, + SandboxPolicy::default(), + MotionCancellationToken::new(), + Instant::now() + Duration::from_secs(1), + ); + let image = cdp + .capture_isolated_viewport("target-id", desired, 1, 1, 0, "black") + .unwrap(); + assert_eq!(image.dimensions(), (1, 1)); + assert_eq!(image.get_pixel(0, 0).0, [128, 0, 0, 255]); + server.join().unwrap(); + } + + #[test] + fn guarded_candidate_detaches_and_preserves_a_pre_start_error() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let client_stream = TcpStream::connect(listener.local_addr().unwrap()).unwrap(); + let (server_stream, _) = listener.accept().unwrap(); + let client_socket = WebSocket::from_raw_socket( + MaybeTlsStream::Plain(client_stream), + Role::Client, + None, + ); + let mut server_socket = WebSocket::from_raw_socket(server_stream, Role::Server, None); + let server = thread::spawn(move || { + let read_json = |socket: &mut WebSocket| match socket.read().unwrap() { + Message::Text(text) => serde_json::from_str::(text.as_ref()).unwrap(), + other => panic!("expected CDP command, got {other:?}"), + }; + let attach = read_json(&mut server_socket); + assert_eq!(attach["method"], "Target.attachToTarget"); + server_socket + .send(Message::text( + json!({"id": 1, "result": {"sessionId": "capture-session"}}).to_string(), + )) + .unwrap(); + let enable = read_json(&mut server_socket); + assert_eq!(enable["method"], "Page.enable"); + server_socket + .send(Message::text( + json!({"id": 2, "error": {"code": -1, "message": "setup-primary"}}) + .to_string(), + )) + .unwrap(); + let detach = read_json(&mut server_socket); + assert_eq!(detach["method"], "Target.detachFromTarget"); + assert_eq!(detach["params"]["sessionId"], "capture-session"); + server_socket + .send(Message::text( + json!({"id": 3, "error": {"code": -2, "message": "cleanup-secondary"}}) + .to_string(), + )) + .unwrap(); + }); + + let mut cdp = Cdp::new( + client_socket, + SandboxPolicy::default(), + MotionCancellationToken::new(), + Instant::now() + Duration::from_secs(1), + ); + let error = cdp + .capture_isolated_viewport("target-id", [0, 0, 0], 1, 1, 0, "black") + .unwrap_err(); + assert!(error.to_string().contains("setup-primary")); + assert!(!error.to_string().contains("cleanup-secondary")); + server.join().unwrap(); + } + + #[test] + fn guarded_candidate_stops_detaches_and_preserves_a_post_start_error() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let client_stream = TcpStream::connect(listener.local_addr().unwrap()).unwrap(); + let (server_stream, _) = listener.accept().unwrap(); + let client_socket = WebSocket::from_raw_socket( + MaybeTlsStream::Plain(client_stream), + Role::Client, + None, + ); + let mut server_socket = WebSocket::from_raw_socket(server_stream, Role::Server, None); + let server = thread::spawn(move || { + let read_json = |socket: &mut WebSocket| match socket.read().unwrap() { + Message::Text(text) => serde_json::from_str::(text.as_ref()).unwrap(), + other => panic!("expected CDP command, got {other:?}"), + }; + let attach = read_json(&mut server_socket); + assert_eq!(attach["method"], "Target.attachToTarget"); + server_socket + .send(Message::text( + json!({"id": 1, "result": {"sessionId": "capture-session"}}).to_string(), + )) + .unwrap(); + for id in 2..=6 { + let command = read_json(&mut server_socket); + if id == 5 { + assert_eq!(command["method"], "Page.startScreencast"); + } + let result = if command["method"] == "Runtime.evaluate" { + json!({"result": {"type": "boolean", "value": true}}) + } else { + json!({}) + }; + server_socket + .send(Message::text( + json!({"id": id, "result": result}).to_string(), + )) + .unwrap(); + } + let transition_fence = read_json(&mut server_socket); + assert_eq!(transition_fence["method"], "Emulation.setVirtualTimePolicy"); + assert_eq!(transition_fence["params"]["policy"], "advance"); + server_socket + .send(Message::text(json!({"id": 7, "result": {}}).to_string())) + .unwrap(); + server_socket + .send(Message::text( + json!({ + "method": "Emulation.virtualTimeBudgetExpired", + "params": {}, + "sessionId": "capture-session" + }) + .to_string(), + )) + .unwrap(); + server_socket + .send(Message::text( + json!({ + "method": "Page.screencastFrame", + "params": {"data": "not-base64", "metadata": {}, "sessionId": 7}, + "sessionId": "capture-session" + }) + .to_string(), + )) + .unwrap(); + let ack = read_json(&mut server_socket); + assert_eq!(ack["method"], "Page.screencastFrameAck"); + server_socket + .send(Message::text(json!({"id": 8, "result": {}}).to_string())) + .unwrap(); + let stop = read_json(&mut server_socket); + assert_eq!(stop["method"], "Page.stopScreencast"); + server_socket + .send(Message::text( + json!({"id": 9, "error": {"code": -1, "message": "stop-secondary"}}) + .to_string(), + )) + .unwrap(); + let detach = read_json(&mut server_socket); + assert_eq!(detach["method"], "Target.detachFromTarget"); + server_socket + .send(Message::text( + json!({"id": 10, "error": {"code": -2, "message": "detach-secondary"}}) + .to_string(), + )) + .unwrap(); + }); + + let mut cdp = Cdp::new( + client_socket, + SandboxPolicy::default(), + MotionCancellationToken::new(), + Instant::now() + Duration::from_secs(1), + ); + let error = cdp + .capture_isolated_viewport("target-id", [0, 0, 0], 1, 1, 0, "black") + .unwrap_err(); + assert!(error.to_string().contains("malformed screencast data")); + assert!(!error.to_string().contains("stop-secondary")); + assert!(!error.to_string().contains("detach-secondary")); + server.join().unwrap(); + } + + #[test] + fn viewport_readback_uses_a_bounded_screencast_session() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let client_stream = TcpStream::connect(listener.local_addr().unwrap()).unwrap(); + let (server_stream, _) = listener.accept().unwrap(); + let client_socket = WebSocket::from_raw_socket( + MaybeTlsStream::Plain(client_stream), + Role::Client, + None, + ); + let mut server_socket = WebSocket::from_raw_socket(server_stream, Role::Server, None); + let png = base64::engine::general_purpose::STANDARD.encode( + encode_viewport_png( + image::RgbaImage::from_pixel(48, 32, image::Rgba([1, 2, 3, 255])), + 0, + ) + .unwrap(), + ); + let server = thread::spawn(move || { + let start = match server_socket.read().unwrap() { + Message::Text(text) => serde_json::from_str::(text.as_ref()).unwrap(), + other => panic!("expected text CDP request, got {other:?}"), + }; + assert_eq!( + start, + json!({ + "id": 1, + "method": "Page.startScreencast", + "params": { + "format": "png", + "maxWidth": 48, + "maxHeight": 32, + "everyNthFrame": 1 + }, + "sessionId": "render-session" + }) + ); + server_socket + .send(Message::text(json!({"id": 1, "result": {}}).to_string())) + .unwrap(); + server_socket + .send(Message::text( + json!({ + "method": "Page.screencastFrame", + "params": { + "data": png, + "metadata": {}, + "sessionId": 7 + }, + "sessionId": "render-session" + }) + .to_string(), + )) + .unwrap(); + + let ack = match server_socket.read().unwrap() { + Message::Text(text) => serde_json::from_str::(text.as_ref()).unwrap(), + other => panic!("expected screencast ack, got {other:?}"), + }; + assert_eq!( + ack, + json!({ + "id": 2, + "method": "Page.screencastFrameAck", + "params": {"sessionId": 7}, + "sessionId": "render-session" + }) + ); + server_socket + .send(Message::text(json!({"id": 2, "result": {}}).to_string())) + .unwrap(); + + let stop = match server_socket.read().unwrap() { + Message::Text(text) => serde_json::from_str::(text.as_ref()).unwrap(), + other => panic!("expected screencast stop, got {other:?}"), + }; + assert_eq!( + stop, + json!({ + "id": 3, + "method": "Page.stopScreencast", + "params": {}, + "sessionId": "render-session" + }) + ); + server_socket + .send(Message::text( + json!({ + "method": "Page.screencastFrame", + "params": { + "data": "late-frame", + "metadata": {}, + "sessionId": 7 + }, + "sessionId": "render-session" + }) + .to_string(), + )) + .unwrap(); + server_socket + .send(Message::text(json!({"id": 3, "result": {}}).to_string())) + .unwrap(); + + let late_ack = match server_socket.read().unwrap() { + Message::Text(text) => serde_json::from_str::(text.as_ref()).unwrap(), + other => panic!("expected late-frame ack, got {other:?}"), + }; + assert_eq!( + late_ack, + json!({ + "id": 4, + "method": "Page.screencastFrameAck", + "params": {"sessionId": 7}, + "sessionId": "render-session" + }) + ); + server_socket + .send(Message::text(json!({"id": 4, "result": {}}).to_string())) + .unwrap(); + }); + + let mut cdp = Cdp::new( + client_socket, + SandboxPolicy::default(), + MotionCancellationToken::new(), + Instant::now() + Duration::from_secs(1), + ); + cdp.start_screencast("render-session", 48, 32).unwrap(); + let captured = cdp + .receive_and_ack_screencast_png("render-session", 0) + .unwrap(); + assert!(captured.starts_with(b"\x89PNG\r\n\x1a\n")); + cdp.stop_screencast("render-session").unwrap(); + server.join().unwrap(); + } + + #[test] + fn screencast_readback_acks_and_stops_when_the_frame_is_invalid() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let client_stream = TcpStream::connect(listener.local_addr().unwrap()).unwrap(); + let (server_stream, _) = listener.accept().unwrap(); + let client_socket = WebSocket::from_raw_socket( + MaybeTlsStream::Plain(client_stream), + Role::Client, + None, + ); + let mut server_socket = WebSocket::from_raw_socket(server_stream, Role::Server, None); + let server = thread::spawn(move || { + let start = match server_socket.read().unwrap() { + Message::Text(text) => serde_json::from_str::(text.as_ref()).unwrap(), + other => panic!("expected screencast start, got {other:?}"), + }; + assert_eq!(start["method"], "Page.startScreencast"); + server_socket + .send(Message::text(json!({"id": 1, "result": {}}).to_string())) + .unwrap(); + server_socket + .send(Message::text( + json!({ + "method": "Page.screencastFrame", + "params": {"metadata": {}, "sessionId": 7}, + "sessionId": "render-session" + }) + .to_string(), + )) + .unwrap(); + + let ack = match server_socket.read().unwrap() { + Message::Text(text) => serde_json::from_str::(text.as_ref()).unwrap(), + other => panic!("expected invalid-frame ack, got {other:?}"), + }; + assert_eq!(ack["method"], "Page.screencastFrameAck"); + assert_eq!(ack["params"]["sessionId"], 7); + server_socket + .send(Message::text(json!({"id": 2, "result": {}}).to_string())) + .unwrap(); + + let stop = match server_socket.read().unwrap() { + Message::Text(text) => serde_json::from_str::(text.as_ref()).unwrap(), + other => panic!("expected cleanup stop, got {other:?}"), + }; + assert_eq!(stop["method"], "Page.stopScreencast"); + server_socket + .send(Message::text(json!({"id": 3, "result": {}}).to_string())) + .unwrap(); + }); + + let mut cdp = Cdp::new( + client_socket, + SandboxPolicy::default(), + MotionCancellationToken::new(), + Instant::now() + Duration::from_secs(1), + ); + cdp.start_screencast("render-session", 48, 32).unwrap(); + let captured = cdp.receive_and_ack_screencast_png("render-session", 0); + let stopped = cdp.stop_screencast("render-session"); + assert!(matches!( + captured, + Err(MotionError::RenderFailed(message)) + if message.contains("missing string field \"data\"") + )); + stopped.unwrap(); + server.join().unwrap(); + } + + #[test] + fn screencast_ack_fails_closed_on_a_late_blocked_request() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let client_stream = TcpStream::connect(listener.local_addr().unwrap()).unwrap(); + let (server_stream, _) = listener.accept().unwrap(); + let client_socket = WebSocket::from_raw_socket( + MaybeTlsStream::Plain(client_stream), + Role::Client, + None, + ); + let mut server_socket = WebSocket::from_raw_socket(server_stream, Role::Server, None); + let server = thread::spawn(move || { + let start = match server_socket.read().unwrap() { + Message::Text(text) => serde_json::from_str::(text.as_ref()).unwrap(), + other => panic!("expected screencast start, got {other:?}"), + }; + assert_eq!(start["method"], "Page.startScreencast"); + server_socket + .send(Message::text(json!({"id": 1, "result": {}}).to_string())) + .unwrap(); + server_socket + .send(Message::text( + json!({ + "method": "Page.screencastFrame", + "params": {"data": "ignored", "metadata": {}, "sessionId": 7}, + "sessionId": "render-session" + }) + .to_string(), + )) + .unwrap(); + + let ack = match server_socket.read().unwrap() { + Message::Text(text) => serde_json::from_str::(text.as_ref()).unwrap(), + other => panic!("expected screencast ack, got {other:?}"), + }; + assert_eq!(ack["method"], "Page.screencastFrameAck"); + server_socket + .send(Message::text( + json!({ + "method": "Fetch.requestPaused", + "params": { + "requestId": "late-request", + "request": {"url": "https://example.com/late-screencast"} + }, + "sessionId": "render-session" + }) + .to_string(), + )) + .unwrap(); + server_socket + .send(Message::text(json!({"id": 2, "result": {}}).to_string())) + .unwrap(); + + let failed = match server_socket.read().unwrap() { + Message::Text(text) => serde_json::from_str::(text.as_ref()).unwrap(), + other => panic!("expected late request rejection, got {other:?}"), + }; + assert_eq!(failed["id"], 3); + assert_eq!(failed["method"], "Fetch.failRequest"); + server_socket + .send(Message::text(json!({"id": 3, "result": {}}).to_string())) + .unwrap(); + + let stop = match server_socket.read().unwrap() { + Message::Text(text) => serde_json::from_str::(text.as_ref()).unwrap(), + other => panic!("expected screencast stop, got {other:?}"), + }; + assert_eq!(stop["id"], 4); + assert_eq!(stop["method"], "Page.stopScreencast"); + server_socket + .send(Message::text(json!({"id": 4, "result": {}}).to_string())) + .unwrap(); + }); + + let mut cdp = Cdp::new( + client_socket, + SandboxPolicy::default(), + MotionCancellationToken::new(), + Instant::now() + Duration::from_secs(1), + ); + cdp.start_screencast("render-session", 48, 32).unwrap(); + let captured = cdp.receive_and_ack_screencast_png("render-session", 0); + let stopped = cdp.stop_screencast("render-session"); + assert!(matches!(captured, Err(MotionError::Sandbox(_)))); + stopped.unwrap(); + server.join().unwrap(); + } + + #[test] + fn screencast_readback_filters_the_outer_target_session() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let client_stream = TcpStream::connect(listener.local_addr().unwrap()).unwrap(); + let (server_stream, _) = listener.accept().unwrap(); + let client_socket = WebSocket::from_raw_socket( + MaybeTlsStream::Plain(client_stream), + Role::Client, + None, + ); + let mut server_socket = WebSocket::from_raw_socket(server_stream, Role::Server, None); + let current_png = base64::engine::general_purpose::STANDARD.encode( + encode_viewport_png( + image::RgbaImage::from_pixel(1, 1, image::Rgba([1, 2, 3, 255])), + 0, + ) + .unwrap(), + ); + let server = thread::spawn(move || { + let start = match server_socket.read().unwrap() { + Message::Text(text) => serde_json::from_str::(text.as_ref()).unwrap(), + other => panic!("expected screencast start, got {other:?}"), + }; + assert_eq!(start["method"], "Page.startScreencast"); + server_socket + .send(Message::text(json!({"id": 1, "result": {}}).to_string())) + .unwrap(); + for (outer_session, inner_session, data) in [ + ("other-target", 99, "wrong-target".to_owned()), + ("render-session", 7, current_png), + ] { + server_socket + .send(Message::text( + json!({ + "method": "Page.screencastFrame", + "params": { + "data": data, + "metadata": {}, + "sessionId": inner_session + }, + "sessionId": outer_session + }) + .to_string(), + )) + .unwrap(); + } + + let current_ack = match server_socket.read().unwrap() { + Message::Text(text) => serde_json::from_str::(text.as_ref()).unwrap(), + other => panic!("expected current-frame ack, got {other:?}"), + }; + assert_eq!(current_ack["method"], "Page.screencastFrameAck"); + assert_eq!(current_ack["params"]["sessionId"], 7); + server_socket + .send(Message::text(json!({"id": 2, "result": {}}).to_string())) + .unwrap(); + let stop = match server_socket.read().unwrap() { + Message::Text(text) => serde_json::from_str::(text.as_ref()).unwrap(), + other => panic!("expected screencast stop, got {other:?}"), + }; + assert_eq!(stop["method"], "Page.stopScreencast"); + server_socket + .send(Message::text(json!({"id": 3, "result": {}}).to_string())) + .unwrap(); + }); + + let mut cdp = Cdp::new( + client_socket, + SandboxPolicy::default(), + MotionCancellationToken::new(), + Instant::now() + Duration::from_secs(1), + ); + cdp.start_screencast("render-session", 48, 32).unwrap(); + let captured = cdp + .receive_and_ack_screencast_png("render-session", 0) + .unwrap(); + assert!(captured.starts_with(b"\x89PNG\r\n\x1a\n")); + cdp.stop_screencast("render-session").unwrap(); + assert_eq!(cdp.pending_events.len(), 1); + assert_eq!(cdp.pending_events[0]["sessionId"], "other-target"); + server.join().unwrap(); + } + + #[test] + fn dual_background_view_capture_recovers_straight_alpha() { + let black = [ + 0, 0, 0, 255, // fully transparent + 128, 0, 0, 255, // 50% red over black + 10, 20, 30, 255, // opaque color + ]; + let white = [ + 255, 255, 255, 255, // fully transparent + 255, 127, 127, 255, // 50% red over white + 10, 20, 30, 255, // opaque color + ]; + assert_eq!( + recover_straight_alpha(&black, &white).unwrap(), + vec![ + 0, 0, 0, 0, // transparent RGB canonicalizes to zero + 255, 0, 0, 128, // straight, not premultiplied, red + 10, 20, 30, 255, + ] + ); + + assert!(matches!( + recover_straight_alpha(&black[..4], &white), + Err(MotionError::RenderFailed(_)) + )); + + let first = image::RgbaImage::from_pixel(1, 1, image::Rgba([255, 0, 0, 255])); + let changed = image::RgbaImage::from_pixel(1, 1, image::Rgba([0, 255, 0, 255])); + assert!(matches!( + ensure_stable_viewport_images(&first, &changed, "black", 0), + Err(MotionError::RenderFailed(_)) + )); + } + + #[test] + fn unstable_high_entropy_viewport_reports_bounded_coordinates_without_pixel_values() { + let mut first = image::RgbaImage::new(64, 64); + let mut second = image::RgbaImage::new(64, 64); + for y in 0..64 { + for x in 0..64 { + first.put_pixel(x, y, image::Rgba([x as u8, y as u8, (x + y) as u8, 255])); + second.put_pixel( + x, + y, + image::Rgba([(x + 101) as u8, (y + 109) as u8, (x + y + 127) as u8, 254]), + ); + } + } + + let error = ensure_stable_viewport_images(&first, &second, "black", 17) + .expect_err("different high-entropy images must fail closed"); + let MotionError::RenderFailed(message) = error else { + panic!("expected render failure, got {error:?}"); + }; + assert!(message.len() <= 512, "diagnostic must remain bounded"); + assert!(message.contains("dimensions=(64, 64)")); + assert!(message.contains("differing_channels=16384")); + assert!(message.contains( + "sample_coordinates=[(0, 0), (1, 0), (2, 0), (3, 0), (4, 0), (5, 0), (6, 0), (7, 0)]" + )); + assert!(!message.contains("(8, 0)")); + assert!(!message.contains("[0, 0, 0, 255]")); + assert!(!message.contains("[101, 109, 127, 254]")); + assert!(!message.contains("unique")); + assert!(!message.contains("corner")); + } + + #[test] + fn transparent_capture_uses_four_guard_committed_page_handlers() { + fn read_json(socket: &mut WebSocket) -> Value { + match socket.read().unwrap() { + Message::Text(text) => serde_json::from_str(text.as_ref()).unwrap(), + other => panic!("expected CDP command, got {other:?}"), + } + } + + fn send_json(socket: &mut WebSocket, value: Value) { + socket.send(Message::text(value.to_string())).unwrap(); + } + + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let client_stream = TcpStream::connect(listener.local_addr().unwrap()).unwrap(); + let (server_stream, _) = listener.accept().unwrap(); + let client_socket = WebSocket::from_raw_socket( + MaybeTlsStream::Plain(client_stream), + Role::Client, + None, + ); + let mut server_socket = WebSocket::from_raw_socket(server_stream, Role::Server, None); + let encoded = |pixel: [u8; 4], guard: [u8; 3]| { + let mut image = image::RgbaImage::from_pixel( + 2, + 2, + image::Rgba([guard[0], guard[1], guard[2], 255]), + ); + image.put_pixel(0, 0, image::Rgba(pixel)); + base64::engine::general_purpose::STANDARD + .encode(encode_viewport_png(image, 0).unwrap()) + }; + let black = encoded([128, 0, 0, 255], [0, 0, 0]); + let white = encoded([255, 127, 127, 255], [255, 255, 255]); + let server = thread::spawn(move || { + let mut next_id = 1u64; + let mut capture_index = 0usize; + for (rgb, current) in [([0, 0, 0], black), ([255, 255, 255], white)] { + let background = read_json(&mut server_socket); + assert_eq!( + background, + json!({ + "id": next_id, + "method": "Runtime.evaluate", + "params": { + "expression": host_background_expression(rgb), + "returnByValue": true + }, + "sessionId": "main-session" + }) + ); + send_json( + &mut server_socket, + json!({"id": next_id, "result": {"result": {"type": "boolean", "value": true}}}), + ); + next_id += 1; + + let mut previous_capture_session = None::; + for _ in 0..2 { + let capture_session = format!("capture-{capture_index}"); + let seed = [capture_index as u8, 0, 90]; + let transition = [capture_index as u8, 0, 165]; + let transition_frame = encoded([4, 5, 6, 255], transition); + capture_index += 1; + let attach = read_json(&mut server_socket); + assert_eq!( + attach, + json!({ + "id": next_id, + "method": "Target.attachToTarget", + "params": {"targetId": "target-id", "flatten": true} + }) + ); + send_json( + &mut server_socket, + json!({"id": next_id, "result": {"sessionId": capture_session}}), + ); + next_id += 1; + + for (method, params) in [ + ("Page.enable", json!({})), + ("Emulation.setVirtualTimePolicy", json!({"policy": "pause"})), + ( + "Runtime.evaluate", + json!({ + "expression": host_background_expression(seed), + "returnByValue": true + }), + ), + ( + "Page.startScreencast", + json!({ + "format": "png", + "maxWidth": 2, + "maxHeight": 2, + "everyNthFrame": 1 + }), + ), + ( + "Runtime.evaluate", + json!({ + "expression": host_background_expression(transition), + "returnByValue": true + }), + ), + ] { + let command = read_json(&mut server_socket); + assert_eq!( + command, + json!({ + "id": next_id, + "method": method, + "params": params, + "sessionId": capture_session + }) + ); + let result = if method == "Runtime.evaluate" { + json!({"result": {"type": "boolean", "value": true}}) + } else { + json!({}) + }; + send_json(&mut server_socket, json!({"id": next_id, "result": result})); + next_id += 1; + } + + let transition_fence = read_json(&mut server_socket); + assert_eq!( + transition_fence, + json!({ + "id": next_id, + "method": "Emulation.setVirtualTimePolicy", + "params": { + "policy": "advance", + "budget": 1, + "maxVirtualTimeTaskStarvationCount": 10_000 + }, + "sessionId": capture_session + }) + ); + send_json(&mut server_socket, json!({"id": next_id, "result": {}})); + next_id += 1; + send_json( + &mut server_socket, + json!({ + "method": "Emulation.virtualTimeBudgetExpired", + "params": {}, + "sessionId": capture_session + }), + ); + + if let Some(prior) = previous_capture_session.as_deref() { + send_json( + &mut server_socket, + json!({ + "method": "Page.screencastFrame", + "params": {"data": current.clone(), "metadata": {}, "sessionId": 6}, + "sessionId": prior + }), + ); + } + send_json( + &mut server_socket, + json!({ + "method": "Page.screencastFrame", + "params": {"data": transition_frame, "metadata": {}, "sessionId": 7}, + "sessionId": capture_session + }), + ); + + let ack = read_json(&mut server_socket); + assert_eq!( + ack, + json!({ + "id": next_id, + "method": "Page.screencastFrameAck", + "params": {"sessionId": 7}, + "sessionId": capture_session + }) + ); + send_json(&mut server_socket, json!({"id": next_id, "result": {}})); + next_id += 1; + + let desired = read_json(&mut server_socket); + assert_eq!( + desired, + json!({ + "id": next_id, + "method": "Runtime.evaluate", + "params": { + "expression": host_background_expression(rgb), + "returnByValue": true + }, + "sessionId": capture_session + }) + ); + send_json( + &mut server_socket, + json!({"id": next_id, "result": {"result": {"type": "boolean", "value": true}}}), + ); + next_id += 1; + + let desired_fence = read_json(&mut server_socket); + assert_eq!( + desired_fence, + json!({ + "id": next_id, + "method": "Emulation.setVirtualTimePolicy", + "params": { + "policy": "advance", + "budget": 1, + "maxVirtualTimeTaskStarvationCount": 10_000 + }, + "sessionId": capture_session + }) + ); + send_json(&mut server_socket, json!({"id": next_id, "result": {}})); + next_id += 1; + send_json( + &mut server_socket, + json!({ + "method": "Emulation.virtualTimeBudgetExpired", + "params": {}, + "sessionId": capture_session + }), + ); + + send_json( + &mut server_socket, + json!({ + "method": "Page.screencastFrame", + "params": {"data": current.clone(), "metadata": {}, "sessionId": 8}, + "sessionId": capture_session + }), + ); + let desired_ack = read_json(&mut server_socket); + assert_eq!( + desired_ack, + json!({ + "id": next_id, + "method": "Page.screencastFrameAck", + "params": {"sessionId": 8}, + "sessionId": capture_session + }) + ); + send_json(&mut server_socket, json!({"id": next_id, "result": {}})); + next_id += 1; + + let stop = read_json(&mut server_socket); + assert_eq!( + stop, + json!({ + "id": next_id, + "method": "Page.stopScreencast", + "params": {}, + "sessionId": capture_session + }) + ); + send_json(&mut server_socket, json!({"id": next_id, "result": {}})); + next_id += 1; + + let detach = read_json(&mut server_socket); + assert_eq!( + detach, + json!({ + "id": next_id, + "method": "Target.detachFromTarget", + "params": {"sessionId": capture_session} + }) + ); + send_json(&mut server_socket, json!({"id": next_id, "result": {}})); + next_id += 1; + previous_capture_session = Some(capture_session); + } + } + assert_eq!(capture_index, 4); + }); + + let mut cdp = Cdp::new( + client_socket, + SandboxPolicy::default(), + MotionCancellationToken::new(), + Instant::now() + Duration::from_secs(1), + ); + let png = cdp + .capture_frame_png("target-id", "main-session", true, 1, 1, 0) + .unwrap(); + assert_eq!( + image::load_from_memory(&png) + .unwrap() + .to_rgba8() + .get_pixel(0, 0) + .0, + [255, 0, 0, 128] + ); + assert!( + cdp.pending_events + .iter() + .all(|event| event["method"] != "Page.screencastFrame"), + "late frames owned by a detached PageHandler must not leak into another capture" + ); + server.join().unwrap(); + } + #[test] + fn completion_checkpoint_rejects_timeout_and_cancellation_without_cache_commit() { + fn complete_frames(dir: &Path) { + for index in 0..2 { + std::fs::write(MotionCache::frame_file(dir, index), b"png").unwrap(); + } + } + + let root = tempfile::tempdir().unwrap(); + let cache = MotionCache::new(root.path()); + let request = MotionRenderRequest::new(MotionSource::code(""), 30, 2, 8, 8); + let dir = cache.begin_render(&request).unwrap(); + complete_frames(&dir); + let mut partial = PartialFrames::new(dir.clone()); + let cancellation = MotionCancellationToken::new(); + assert!(matches!( + publish_completed_render( + &cancellation, + Duration::from_secs(1), + Instant::now(), + &dir, + &mut partial + ), + Err(MotionError::Timeout(_)) + )); + drop(partial); + assert!(!cache.is_cached(&request)); + + let cancelled_request = + MotionRenderRequest::new(MotionSource::code(""), 30, 2, 8, 8); + let cancelled_dir = cache.begin_render(&cancelled_request).unwrap(); + complete_frames(&cancelled_dir); + let mut cancelled_partial = PartialFrames::new(cancelled_dir.clone()); + let cancelled = MotionCancellationToken::new(); + cancelled.cancel(); + assert!(matches!( + publish_completed_render( + &cancelled, + Duration::from_secs(1), + Instant::now() + Duration::from_secs(1), + &cancelled_dir, + &mut cancelled_partial + ), + Err(MotionError::Cancelled) + )); + drop(cancelled_partial); + assert!(!cache.is_cached(&cancelled_request)); + } + + #[test] + fn target_close_fails_closed_on_a_late_blocked_request() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let client_stream = TcpStream::connect(listener.local_addr().unwrap()).unwrap(); + let (server_stream, _) = listener.accept().unwrap(); + let client_socket = WebSocket::from_raw_socket( + MaybeTlsStream::Plain(client_stream), + Role::Client, + None, + ); + let mut server_socket = WebSocket::from_raw_socket(server_stream, Role::Server, None); + let server = thread::spawn(move || { + let close = match server_socket.read().unwrap() { + Message::Text(text) => serde_json::from_str::(text.as_ref()).unwrap(), + other => panic!("expected closeTarget request, got {other:?}"), + }; + assert_eq!( + close, + json!({ + "id": 1, + "method": "Target.closeTarget", + "params": {"targetId": "render-target"} + }) + ); + server_socket + .send(Message::text( + json!({ + "method": "Fetch.requestPaused", + "params": { + "requestId": "late-request", + "request": {"url": "https://example.com/late"} + }, + "sessionId": "render-session" + }) + .to_string(), + )) + .unwrap(); + server_socket + .send(Message::text( + json!({"id": 1, "result": {"success": true}}).to_string(), + )) + .unwrap(); + let failed = match server_socket.read().unwrap() { + Message::Text(text) => serde_json::from_str::(text.as_ref()).unwrap(), + other => panic!("expected Fetch.failRequest, got {other:?}"), + }; + assert_eq!(failed["method"], "Fetch.failRequest"); + }); + + let mut cdp = Cdp::new( + client_socket, + SandboxPolicy::default(), + MotionCancellationToken::new(), + Instant::now() + Duration::from_secs(1), + ); + assert!(matches!( + cdp.close_target("render-target"), + Err(MotionError::Sandbox(_)) + )); + server.join().unwrap(); + } + + #[test] + fn target_close_rejects_a_false_success_result() { + let (mut cdp, mut server_socket) = fake_cdp_pair(); + let server = thread::spawn(move || { + let request = server_socket.read().unwrap(); + assert!(matches!(request, Message::Text(_))); + server_socket + .send(Message::text( + json!({"id": 1, "result": {"success": false}}).to_string(), + )) + .unwrap(); + }); + + assert!(matches!( + cdp.close_target("render-target"), + Err(MotionError::RenderFailed(message)) + if message.contains("did not close the render target") + )); + server.join().unwrap(); + } + + #[test] + fn render_browser_context_is_disposable_and_root_scoped() { + let (mut cdp, mut server_socket) = fake_cdp_pair(); + let server = thread::spawn(move || { + let create = match server_socket.read().unwrap() { + Message::Text(text) => serde_json::from_str::(text.as_ref()).unwrap(), + other => panic!("expected createBrowserContext request, got {other:?}"), + }; + assert_eq!( + create, + json!({ + "id": 1, + "method": "Target.createBrowserContext", + "params": {"disposeOnDetach": true} + }) + ); + server_socket + .send(Message::text( + json!({"id": 1, "result": {"browserContextId": "context-1"}}).to_string(), + )) + .unwrap(); + + let dispose = match server_socket.read().unwrap() { + Message::Text(text) => serde_json::from_str::(text.as_ref()).unwrap(), + other => panic!("expected disposeBrowserContext request, got {other:?}"), + }; + assert_eq!( + dispose, + json!({ + "id": 2, + "method": "Target.disposeBrowserContext", + "params": {"browserContextId": "context-1"} + }) + ); + server_socket + .send(Message::text(json!({"id": 2, "result": {}}).to_string())) + .unwrap(); + }); + + let context_id = cdp.create_browser_context().unwrap(); + assert_eq!(context_id, "context-1"); + cdp.dispose_browser_context(&context_id).unwrap(); + server.join().unwrap(); + } + + #[test] + fn gpu_backend_trace_uses_root_session_and_bounds_safe_fields() { + let (mut cdp, mut server_socket) = fake_cdp_pair(); + let server = thread::spawn(move || { + let request = match server_socket.read().unwrap() { + Message::Text(text) => serde_json::from_str::(text.as_ref()).unwrap(), + other => panic!("expected SystemInfo.getInfo request, got {other:?}"), + }; + assert_eq!( + request, + json!({ + "id": 1, + "method": "SystemInfo.getInfo", + "params": {} + }), + "GPU diagnostics must run on the root CDP session" + ); + server_socket + .send(Message::text( + json!({ + "id": 1, + "result": { + "gpu": { + "devices": [{ + "vendorString": "Google Inc. (Google)", + "deviceString": format!( + "ANGLE SwiftShader\nforged-log-line {}", + "x".repeat(160) + ) + }], + "auxAttributes": { + "commandLine": "--api-key=must-not-appear" + }, + "featureStatus": { + "gpu_compositing": "disabled_software" + } + } + } + }) + .to_string(), + )) + .unwrap(); + }); + + let summary = cdp.gpu_backend_trace().unwrap(); + assert!(summary.starts_with("gpu backend class=swiftshader ")); + assert!(summary.contains("vendor=\"Google Inc. (Google)\"")); + assert!(summary.contains("gpu_compositing=\"disabled_software\"")); + assert!(!summary.contains("must-not-appear")); + assert!(summary.contains("device=\"ANGLE SwiftShader?forged-log-line ")); + assert!(!summary.contains('\n')); + assert!(summary.contains('…')); + assert!( + summary.chars().count() <= 320, + "diagnostic summary must remain bounded: {summary}" + ); + server.join().unwrap(); + } + + #[test] + fn disabled_gpu_backend_trace_leaves_the_root_socket_untouched() { + let (mut cdp, mut server_socket) = fake_cdp_pair(); + let server = thread::spawn(move || { + let request = match server_socket.read().unwrap() { + Message::Text(text) => serde_json::from_str::(text.as_ref()).unwrap(), + other => panic!("expected Target.getTargets request, got {other:?}"), + }; + assert_eq!( + request, + json!({ + "id": 1, + "method": "Target.getTargets", + "params": {} + }) + ); + server_socket + .send(Message::text(json!({"id": 1, "result": {}}).to_string())) + .unwrap(); + }); + + trace_gpu_backend_if_enabled(&mut cdp, false).unwrap(); + cdp.command("Target.getTargets", json!({}), None).unwrap(); + server.join().unwrap(); + } + + #[test] + fn gpu_backend_trace_treats_only_the_diagnostic_rejection_as_unavailable() { + let (mut cdp, mut server_socket) = fake_cdp_pair(); + let server = thread::spawn(move || { + let request = server_socket.read().unwrap(); + assert!(matches!(request, Message::Text(_))); + server_socket + .send(Message::text( + json!({ + "id": 1, + "error": { + "code": -32601, + "message": "SystemInfo.getInfo was not found --secret" + } + }) + .to_string(), + )) + .unwrap(); + }); + + assert_eq!( + cdp.gpu_backend_trace().unwrap(), + "gpu backend unavailable reason=command-rejected" + ); + server.join().unwrap(); + + let (mut cdp, mut server_socket) = fake_cdp_pair(); + let server = thread::spawn(move || { + let request = server_socket.read().unwrap(); + assert!(matches!(request, Message::Text(_))); + server_socket + .send(Message::text( + json!({ + "method": "Inspector.targetCrashed", + "params": {"status": "crashed"} + }) + .to_string(), + )) + .unwrap(); + }); + + assert!(matches!( + cdp.gpu_backend_trace(), + Err(MotionError::RenderFailed(message)) + if message == "Chromium render target crashed" + )); + server.join().unwrap(); + + let (mut cdp, server_socket) = fake_cdp_pair(); + drop(server_socket); + assert!(matches!( + cdp.gpu_backend_trace(), + Err(MotionError::RenderFailed(_)) + )); + + let (mut cdp, mut server_socket) = fake_cdp_pair(); + cdp.cancellation.cancel(); + let server = thread::spawn(move || { + let request = server_socket.read().unwrap(); + assert!(matches!(request, Message::Text(_))); + }); + assert!(matches!( + cdp.gpu_backend_trace(), + Err(MotionError::Cancelled) + )); + server.join().unwrap(); + } + + #[test] + fn gpu_backend_trace_reports_incomplete_results_without_exposing_payloads() { + let (mut cdp, mut server_socket) = fake_cdp_pair(); + let server = thread::spawn(move || { + let request = server_socket.read().unwrap(); + assert!(matches!(request, Message::Text(_))); + server_socket + .send(Message::text( + json!({ + "id": 1, + "result": { + "gpu": { + "auxAttributes": { + "commandLine": "--password=must-not-appear" + } + } + } + }) + .to_string(), + )) + .unwrap(); + }); + + assert_eq!( + cdp.gpu_backend_trace().unwrap(), + "gpu backend unavailable reason=incomplete-result" + ); + server.join().unwrap(); + } + + #[test] + fn partial_frame_guard_only_preserves_a_published_marker_after_commit() { + let root = tempfile::tempdir().unwrap(); + let cache = MotionCache::new(root.path()); + let request = MotionRenderRequest::new(MotionSource::code(""), 30, 2, 8, 8); + let dir = cache.begin_render(&request).unwrap(); + for index in 0..2 { + std::fs::write(MotionCache::frame_file(&dir, index), b"png").unwrap(); + } + MotionCache::mark_complete(&dir).unwrap(); + assert!(cache.is_cached(&request)); + + drop(PartialFrames::new(dir.clone())); + assert!( + !cache.is_cached(&request), + "an uncommitted guard must invalidate a published marker" + ); + + let dir = cache.begin_render(&request).unwrap(); + for index in 0..2 { + std::fs::write(MotionCache::frame_file(&dir, index), b"png").unwrap(); + } + MotionCache::mark_complete(&dir).unwrap(); + let mut completed = PartialFrames::new(dir); + completed.commit(); + drop(completed); + assert!(cache.is_cached(&request)); + } + } +} + +/// Percent-encode HTML for a `data:` URL: keep unreserved chars, encode the rest. +fn percent_encode_html(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for &byte in s.as_bytes() { + let keep = byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~'); + if keep { + out.push(byte as char); + } else { + out.push('%'); + out.push(hex_upper(byte >> 4)); + out.push(hex_upper(byte & 0x0f)); + } + } + out +} + +fn hex_upper(nibble: u8) -> char { + match nibble { + 0..=9 => (b'0' + nibble) as char, + _ => (b'A' + (nibble - 10)) as char, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::source::MotionSource; + + fn stub_with_tmp() -> (StubRenderer, tempfile::TempDir) { + let tmp = tempfile::tempdir().unwrap(); + let cache = MotionCache::new(tmp.path()); + (StubRenderer::new(cache), tmp) + } + + #[test] + fn clock_script_exposes_seek_contract() { + let s = deterministic_clock_script(); + assert!(s.contains("OpenTake")); + assert!(s.contains("seek")); + assert!(s.contains("currentTime")); + assert!(s.contains("onSeek")); + } + + #[test] + fn stub_renders_expected_number_of_frames() { + let (renderer, _tmp) = stub_with_tmp(); + let req = MotionRenderRequest::new(MotionSource::code("
hi
"), 30, 5, 16, 8); + let clip = renderer.render(&req).unwrap(); + assert_eq!(clip.frame_count(), 5); + assert_eq!(clip.width, 16); + assert_eq!(clip.height, 8); + assert_eq!(clip.content_hash, content_hash(&req)); + for p in &clip.frames { + assert!(p.exists(), "frame file should exist: {p:?}"); + } + } + + #[test] + fn stub_output_is_deterministic() { + // Two separate caches, same request -> identical frame bytes. + let tmp_a = tempfile::tempdir().unwrap(); + let tmp_b = tempfile::tempdir().unwrap(); + let ra = StubRenderer::new(MotionCache::new(tmp_a.path())); + let rb = StubRenderer::new(MotionCache::new(tmp_b.path())); + let req = MotionRenderRequest::new(MotionSource::code(""), 24, 3, 8, 8); + let ca = ra.render(&req).unwrap(); + let cb = rb.render(&req).unwrap(); + for (fa, fb) in ca.frames.iter().zip(cb.frames.iter()) { + let ba = std::fs::read(fa).unwrap(); + let bb = std::fs::read(fb).unwrap(); + assert!(ba.starts_with(b"\x89PNG\r\n\x1a\n")); + assert_eq!(ba, bb, "same request must produce identical bytes"); + } + } + + #[test] + fn stub_png_decodes_with_correct_dimensions_and_alpha() { + // Validates the hand-rolled PNG encoder against a real decoder, and that + // the transparent flag actually varies alpha across frames. + let (renderer, _tmp) = stub_with_tmp(); + let req = MotionRenderRequest::new(MotionSource::code(""), 30, 3, 4, 2) + .with_transparent(true); + let clip = renderer.render(&req).unwrap(); + + let first = image::open(&clip.frames[0]).unwrap().to_rgba8(); + assert_eq!(first.dimensions(), (4, 2)); + // frame 0 alpha == 0 (ramp start), last frame alpha == 255. + assert_eq!(first.get_pixel(0, 0)[3], 0); + let last = image::open(clip.frames.last().unwrap()).unwrap().to_rgba8(); + assert_eq!(last.get_pixel(0, 0)[3], 255); + + // 200x100 RGBA scanlines exceed one 65,535-byte stored-deflate block. + // Decode a direct encoder result to prove multi-block zlib framing and + // exact RGBA values, not just the tiny single-block fixture above. + let rgba = [17, 34, 51, 68]; + let big_a = encode_solid_rgba_png(200, 100, rgba); + let big_b = encode_solid_rgba_png(200, 100, rgba); + assert_eq!(big_a, big_b); + let big = image::load_from_memory_with_format(&big_a, image::ImageFormat::Png) + .unwrap() + .to_rgba8(); + assert_eq!(big.dimensions(), (200, 100)); + assert_eq!(big.get_pixel(0, 0).0, rgba); + assert_eq!(big.get_pixel(199, 99).0, rgba); + } + + #[test] + fn stub_opaque_frames_are_fully_opaque() { + let (renderer, _tmp) = stub_with_tmp(); + let req = MotionRenderRequest::new(MotionSource::code(""), 30, 2, 3, 3) + .with_transparent(false); + let clip = renderer.render(&req).unwrap(); + let img = image::open(&clip.frames[0]).unwrap().to_rgba8(); + assert_eq!(img.get_pixel(0, 0)[3], 255); + } + + #[test] + fn chromium_skeleton_reports_unavailable_not_panic() { + let tmp = tempfile::tempdir().unwrap(); + let r = + HeadlessChromiumRenderer::new(MotionCache::new(tmp.path()), SandboxPolicy::default()); + let req = MotionRenderRequest::new(MotionSource::code(""), 30, 2, 10, 10); + #[cfg(not(feature = "chromium"))] + { + let err = r.render(&req).unwrap_err(); + assert!( + matches!(err, MotionError::RendererUnavailable(_)), + "expected RendererUnavailable, got {err:?}" + ); + } + #[cfg(feature = "chromium")] + { + if HeadlessChromiumRenderer::find_browser().is_some() { + let clip = r.render(&req).expect("feature-enabled browser render"); + assert_eq!(clip.frame_count(), 2); + } else { + assert!(matches!( + r.render(&req), + Err(MotionError::RendererUnavailable(_)) + )); + } + } + } + + #[cfg(feature = "chromium")] + #[test] + fn changing_browser_path_detaches_the_reusable_pool() { + let tmp = tempfile::tempdir().unwrap(); + let renderer = + HeadlessChromiumRenderer::new(MotionCache::new(tmp.path()), SandboxPolicy::default()); + let shared = renderer.clone(); + assert!(Arc::ptr_eq(&renderer.browser_pool, &shared.browser_pool)); + + let changed = shared.with_browser_path("different-browser"); + assert!(!Arc::ptr_eq(&renderer.browser_pool, &changed.browser_pool)); + } + + #[test] + fn chromium_applies_sandbox_size_before_unavailable() { + // Stub checks the default ceiling before creating its content-hash dir. + let stub_tmp = tempfile::tempdir().unwrap(); + let stub = StubRenderer::new(MotionCache::new(stub_tmp.path())); + let oversized = "x".repeat(crate::sandbox::DEFAULT_MAX_DOCUMENT_BYTES + 1); + let stub_req = MotionRenderRequest::new(MotionSource::code(oversized), 30, 1, 10, 10); + assert!(matches!( + stub.render(&stub_req), + Err(MotionError::Sandbox(_)) + )); + assert_eq!(std::fs::read_dir(stub_tmp.path()).unwrap().count(), 0); + + // Chromium checks its policy before browser discovery/launch and before + // creating a content-hash dir, in both feature configurations. let tmp = tempfile::tempdir().unwrap(); let policy = SandboxPolicy { max_document_bytes: 4, @@ -502,6 +4910,7 @@ mod tests { MotionRenderRequest::new(MotionSource::code(""), 30, 1, 10, 10); let err = r.render(&req).unwrap_err(); assert!(matches!(err, MotionError::Sandbox(_)), "got {err:?}"); + assert_eq!(std::fs::read_dir(tmp.path()).unwrap().count(), 0); } #[test] diff --git a/crates/opentake-motion/src/sandbox.rs b/crates/opentake-motion/src/sandbox.rs index 9585b8b2..12c34d39 100644 --- a/crates/opentake-motion/src/sandbox.rs +++ b/crates/opentake-motion/src/sandbox.rs @@ -49,12 +49,18 @@ impl AllowedOrigin { /// local dev origin) URL — plaintext remote origins are refused outright. pub fn parse(origin: &str) -> Option { let lower = origin.trim().trim_end_matches('/').to_ascii_lowercase(); - let is_https = lower.starts_with("https://"); - // Allow http only for loopback dev servers; never for remote hosts. - let is_local_http = lower.starts_with("http://localhost") - || lower.starts_with("http://127.0.0.1") - || lower.starts_with("http://[::1]"); - if (is_https || is_local_http) && lower.len() > "https://".len() { + let (scheme, authority) = lower.split_once("://")?; + if authority.is_empty() + || authority.contains(['/', '?', '#', '@']) + || authority.chars().any(char::is_whitespace) + { + return None; + } + let is_https = scheme == "https"; + // Allow http only for exact loopback hosts (with an optional port), + // never for lookalikes such as localhost.evil.example. + let is_local_http = scheme == "http" && is_loopback_authority(authority); + if is_https || is_local_http { Some(AllowedOrigin(lower)) } else { None @@ -64,6 +70,28 @@ impl AllowedOrigin { pub fn as_str(&self) -> &str { &self.0 } + + fn matches_url(&self, url: &str) -> bool { + let Some(rest) = url.strip_prefix(self.as_str()) else { + return false; + }; + rest.is_empty() || rest.starts_with(['/', '?', '#']) + } +} + +fn is_loopback_authority(authority: &str) -> bool { + for host in ["localhost", "127.0.0.1", "[::1]"] { + if authority == host { + return true; + } + if let Some(port) = authority + .strip_prefix(host) + .and_then(|rest| rest.strip_prefix(':')) + { + return !port.is_empty() && port.bytes().all(|byte| byte.is_ascii_digit()); + } + } + false } /// The sandbox policy applied to a single render. @@ -128,7 +156,7 @@ impl SandboxPolicy { let allowed = self .allowed_origins .iter() - .any(|o| lower.starts_with(o.as_str())); + .any(|origin| origin.matches_url(&lower)); if allowed { Ok(()) } else { @@ -138,7 +166,9 @@ impl SandboxPolicy { } } - /// Reject an inline document larger than the configured ceiling. + /// Reject an inline document larger than the configured byte ceiling. + /// Equality is accepted; UTF-8 text is charged by encoded bytes, matching + /// what is handed to Chromium and what consumes memory. pub fn check_document_size(&self, document: &str) -> MotionResult<()> { if document.len() > self.max_document_bytes { return Err(MotionError::sandbox(format!( @@ -175,6 +205,9 @@ mod tests { assert!(p.check_url("https://unpkg.com/thing").is_err()); // origin stored without trailing slash, case-insensitive match assert!(p.check_url("HTTPS://CDN.JSDELIVR.NET/a").is_ok()); + assert!(p + .check_url("https://cdn.jsdelivr.net.evil.example/a") + .is_err()); } #[test] @@ -183,7 +216,9 @@ mod tests { assert!(AllowedOrigin::parse("http://cdn.evil.com").is_none()); // but loopback http is allowed for local dev servers assert!(AllowedOrigin::parse("http://localhost:5173").is_some()); + assert!(AllowedOrigin::parse("http://localhost.evil.example").is_none()); assert!(AllowedOrigin::parse("https://example.com").is_some()); + assert!(AllowedOrigin::parse("https://example.com/path").is_none()); // junk assert!(AllowedOrigin::parse("ftp://x").is_none()); assert!(AllowedOrigin::parse("https://").is_none()); @@ -203,8 +238,15 @@ mod tests { max_document_bytes: 10, ..Default::default() }; - assert!(p.check_document_size("under10").is_ok()); - assert!(p.check_document_size("this is way over ten bytes").is_err()); + assert!(p.check_document_size("0123456789").is_ok()); + assert!(p.check_document_size("01234567890").is_err()); + + let utf8 = SandboxPolicy { + max_document_bytes: 3, + ..Default::default() + }; + assert!(utf8.check_document_size("é").is_ok()); // 2 UTF-8 bytes + assert!(utf8.check_document_size("éé").is_err()); // 4 UTF-8 bytes } #[test] diff --git a/crates/opentake-motion/tests/chromium.rs b/crates/opentake-motion/tests/chromium.rs new file mode 100644 index 00000000..98b8b307 --- /dev/null +++ b/crates/opentake-motion/tests/chromium.rs @@ -0,0 +1,852 @@ +#[cfg(feature = "chromium")] +static LIVE_CHROMIUM_TEST_GATE: std::sync::OnceLock> = + std::sync::OnceLock::new(); + +#[cfg(feature = "chromium")] +fn test_gate_guard( + gate: &'static std::sync::OnceLock>, +) -> std::sync::MutexGuard<'static, ()> { + gate.get_or_init(|| std::sync::Mutex::new(())) + .lock() + .expect("live Chromium test gate was poisoned by an earlier test failure") +} + +#[cfg(feature = "chromium")] +fn live_test_guard() -> std::sync::MutexGuard<'static, ()> { + test_gate_guard(&LIVE_CHROMIUM_TEST_GATE) +} + +#[cfg(feature = "chromium")] +mod live { + use std::collections::BTreeSet; + use std::fs; + use std::io::{Read, Write}; + use std::net::TcpListener; + use std::path::PathBuf; + use std::sync::{ + atomic::{AtomicBool, Ordering}, + Arc, + }; + use std::thread; + use std::time::{Duration, Instant}; + + use opentake_motion::{ + HeadlessChromiumRenderer, MotionCache, MotionCancellationToken, MotionClipSource, + MotionError, MotionRenderRequest, MotionRenderer, MotionSource, SandboxPolicy, + }; + use opentake_render::{DecodedFrame, FrameProvider}; + + fn browser() -> PathBuf { + HeadlessChromiumRenderer::find_browser() + .expect("the live chromium test requires Chrome, Chromium, or Edge") + } + + fn request(document: &str) -> MotionRenderRequest { + MotionRenderRequest::new(MotionSource::code(document), 10, 3, 48, 32) + } + + fn renderer(root: &std::path::Path) -> HeadlessChromiumRenderer { + HeadlessChromiumRenderer::new( + MotionCache::new(root), + // Generous bound: Chrome boot + virtual-time seeks + capture must + // finish within it even on a loaded CI runner. The timeout + // semantics themselves are asserted by the 500ms test below. + SandboxPolicy::offline_with_timeout(Duration::from_secs(60)), + ) + .with_browser_path(browser()) + } + + fn four_k_renderer(root: &std::path::Path) -> HeadlessChromiumRenderer { + HeadlessChromiumRenderer::new( + MotionCache::new(root), + SandboxPolicy::offline_with_timeout(Duration::from_secs(180)), + ) + .with_browser_path(browser()) + } + + fn live_profiles() -> BTreeSet { + let prefix = format!("opentake-chromium-{}-", std::process::id()); + fs::read_dir(std::env::temp_dir()) + .unwrap() + .flatten() + .filter_map(|entry| { + entry + .file_name() + .to_string_lossy() + .starts_with(&prefix) + .then(|| entry.path()) + }) + .collect() + } + + fn decoded(path: &std::path::Path) -> Option { + let rgba = image::open(path).ok()?.to_rgba8(); + Some(DecodedFrame::new( + rgba.width(), + rgba.height(), + rgba.into_raw(), + false, + )) + } + + pub(super) fn assert_gate_serializes_concurrent_callers() { + static PROBE_GATE: std::sync::OnceLock> = std::sync::OnceLock::new(); + let first = super::test_gate_guard(&PROBE_GATE); + let (attempted_tx, attempted_rx) = std::sync::mpsc::channel(); + let (entered_tx, entered_rx) = std::sync::mpsc::channel(); + let second = thread::spawn(move || { + attempted_tx.send(()).unwrap(); + let _second = super::test_gate_guard(&PROBE_GATE); + entered_tx.send(()).unwrap(); + }); + + attempted_rx + .recv_timeout(Duration::from_secs(1)) + .expect("second gate caller did not start"); + assert!( + matches!( + entered_rx.recv_timeout(Duration::from_millis(50)), + Err(std::sync::mpsc::RecvTimeoutError::Timeout) + ), + "a second live Chromium test must not enter while the first guard is held" + ); + drop(first); + entered_rx + .recv_timeout(Duration::from_secs(1)) + .expect("second gate caller did not enter after the first guard was released"); + second.join().unwrap(); + } + + pub(super) fn wrapper_probe() { + let root = tempfile::tempdir().unwrap(); + let document = r#" + + +
+ + "#; + let rendered = renderer(root.path()) + .render(&MotionRenderRequest::new( + MotionSource::code(document), + 10, + 1, + 48, + 32, + )) + .unwrap(); + let pixels = image::open(&rendered.frames[0]).unwrap().to_rgba8(); + assert_eq!(pixels.dimensions(), (48, 32)); + assert_eq!(pixels.get_pixel(0, 0).0, [12, 34, 56, 255]); + assert_eq!( + pixels.get_pixel(47, 31).0, + [7, 8, 9, 255], + "child seek must run in the unique default child context and preserve legal right/bottom-edge content" + ); + } + + pub(super) fn browser_pool_reuses_session_probe() { + let profiles_before = live_profiles(); + let root = tempfile::tempdir().unwrap(); + let renderer = renderer(root.path()); + let first_request = MotionRenderRequest::new( + MotionSource::code( + r#""#, + ), + 10, + 1, + 48, + 32, + ) + .with_transparent(false); + let second_request = MotionRenderRequest::new( + MotionSource::code( + r#""#, + ), + 10, + 1, + 48, + 32, + ) + .with_transparent(false); + + let first = renderer.render(&first_request).unwrap(); + let profiles_after_first = live_profiles(); + assert_ne!( + first.content_hash, + opentake_motion::content_hash(&second_request) + ); + assert_eq!( + profiles_after_first.difference(&profiles_before).count(), + 1, + "the first cache miss must leave exactly one renderer-owned Chromium profile alive" + ); + + let second = renderer.render(&second_request).unwrap(); + assert_ne!(first.content_hash, second.content_hash); + assert_eq!( + live_profiles(), + profiles_after_first, + "two distinct cache misses on one renderer must reuse the same Chromium process/profile" + ); + assert!(renderer.cache().is_cached(&first_request)); + assert!(renderer.cache().is_cached(&second_request)); + + drop(renderer); + assert_eq!( + live_profiles(), + profiles_before, + "dropping the renderer must remove its reusable Chromium profile" + ); + } + + pub(super) fn browser_pool_invalidation_probe() { + let profiles_before = live_profiles(); + let root = tempfile::tempdir().unwrap(); + let renderer = renderer(root.path()); + let first_request = + request(r#""#) + .with_transparent(false); + renderer.render(&first_request).unwrap(); + assert_eq!( + live_profiles().difference(&profiles_before).count(), + 1, + "a successful render must retain one reusable browser" + ); + + let blocked_request = + request(r#""#).with_transparent(false); + assert!(matches!( + renderer.render(&blocked_request), + Err(MotionError::Sandbox(_)) + )); + assert_eq!(live_profiles(), profiles_before); + assert!(!renderer.cache().is_cached(&blocked_request)); + + let second_request = + request(r#""#) + .with_transparent(false); + renderer.render(&second_request).unwrap(); + assert_eq!( + live_profiles().difference(&profiles_before).count(), + 1, + "a later explicit render may launch a new browser after invalidation" + ); + + let cancellation = MotionCancellationToken::new(); + cancellation.cancel(); + let cancelled_request = + request(r#""#) + .with_transparent(false); + assert!(matches!( + renderer.render_with_cancellation(&cancelled_request, &cancellation), + Err(MotionError::Cancelled) + )); + assert_eq!(live_profiles(), profiles_before); + assert!(!renderer.cache().is_cached(&cancelled_request)); + } + + pub(super) fn concurrent_browser_pool_invalidation_probe() { + let profiles_before = live_profiles(); + let root = tempfile::tempdir().unwrap(); + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + listener.set_nonblocking(true).unwrap(); + let origin = format!("http://{}", listener.local_addr().unwrap()); + let (request_seen_tx, request_seen_rx) = std::sync::mpsc::channel(); + let (release_response_tx, release_response_rx) = std::sync::mpsc::channel(); + let server = thread::spawn(move || { + let accept_deadline = Instant::now() + Duration::from_secs(10); + let mut stream = loop { + match listener.accept() { + Ok((stream, _)) => break stream, + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + assert!( + Instant::now() < accept_deadline, + "the active render did not request its loopback barrier resource" + ); + thread::sleep(Duration::from_millis(10)); + } + Err(error) => panic!("loopback barrier accept failed: {error}"), + } + }; + let mut request = [0u8; 2048]; + let _ = stream.read(&mut request); + request_seen_tx.send(()).unwrap(); + release_response_rx + .recv_timeout(Duration::from_secs(10)) + .expect("the invalidating caller did not release the loopback response"); + let svg = b""; + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: image/svg+xml\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + svg.len() + ); + let _ = stream.write_all(response.as_bytes()); + let _ = stream.write_all(svg); + }); + let renderer = HeadlessChromiumRenderer::new( + MotionCache::new(root.path()), + SandboxPolicy::offline_with_timeout(Duration::from_secs(60)).allow_origin(&origin), + ) + .with_browser_path(browser()); + let active_renderer = renderer.clone(); + let active_request = MotionRenderRequest::new( + MotionSource::code(format!( + r#""#, + )), + 10, + 1, + 48, + 32, + ) + .with_transparent(false); + let active_request_for_render = active_request.clone(); + let active = thread::spawn(move || active_renderer.render(&active_request_for_render)); + + request_seen_rx + .recv_timeout(Duration::from_secs(10)) + .expect("the active render did not reach the loopback barrier"); + assert_eq!(live_profiles().difference(&profiles_before).count(), 1); + assert!( + !active.is_finished(), + "the first render must still own the browser lease when invalidation races it" + ); + + let cancelled_request = + request(r#""#) + .with_transparent(false); + let cancellation = MotionCancellationToken::new(); + cancellation.cancel(); + let cancelled = renderer.render_with_cancellation(&cancelled_request, &cancellation); + release_response_tx.send(()).unwrap(); + assert!(matches!(cancelled, Err(MotionError::Cancelled))); + assert!(!renderer.cache().is_cached(&cancelled_request)); + + server.join().unwrap(); + active.join().unwrap().unwrap(); + assert!(renderer.cache().is_cached(&active_request)); + assert_eq!( + live_profiles(), + profiles_before, + "a concurrent error must prevent the successful active lease from retaining Chromium" + ); + + let later_request = + request(r#""#) + .with_transparent(false); + renderer.render(&later_request).unwrap(); + assert_eq!( + live_profiles().difference(&profiles_before).count(), + 1, + "a later explicit render may launch one new browser after invalidation" + ); + drop(renderer); + assert_eq!(live_profiles(), profiles_before); + } + + pub(super) fn four_k_budget_smoke() { + const WIDTH: u32 = 3840; + const HEIGHT: u32 = 2160; + + let root = tempfile::tempdir().unwrap(); + let renderer = four_k_renderer(root.path()); + let opaque_started = Instant::now(); + let opaque = renderer + .render( + &MotionRenderRequest::new( + MotionSource::code( + r#""#, + ), + 30, + 1, + WIDTH, + HEIGHT, + ) + .with_transparent(false), + ) + .unwrap(); + let opaque_elapsed = opaque_started.elapsed(); + let opaque_pixels = image::open(&opaque.frames[0]).unwrap().to_rgba8(); + assert_eq!(opaque_pixels.dimensions(), (WIDTH, HEIGHT)); + assert!( + opaque_pixels.pixels().all(|pixel| pixel.0[3] == 255), + "the 4K opaque smoke frame must remain fully opaque" + ); + eprintln!( + "opentake-motion 4K opaque single-frame elapsed_ms={}", + opaque_elapsed.as_millis() + ); + + let transparent_started = Instant::now(); + let transparent = renderer + .render(&MotionRenderRequest::new( + MotionSource::code( + r#"
"#, + ), + 30, + 1, + WIDTH, + HEIGHT, + )) + .unwrap(); + let transparent_elapsed = transparent_started.elapsed(); + let transparent_pixels = image::open(&transparent.frames[0]).unwrap().to_rgba8(); + assert_eq!(transparent_pixels.dimensions(), (WIDTH, HEIGHT)); + assert!( + transparent_pixels + .pixels() + .all(|pixel| pixel.0[3] > 0 && pixel.0[3] < 255), + "the 4K transparent smoke frame must retain non-trivial alpha" + ); + eprintln!( + "opentake-motion 4K transparent single-frame elapsed_ms={}", + transparent_elapsed.as_millis() + ); + } + + pub(super) fn run() { + let profiles_before = live_profiles(); + let page_background_root = tempfile::tempdir().unwrap(); + let page_background_document = r#" + + + "#; + let page_background = renderer(page_background_root.path()) + .render(&MotionRenderRequest::new( + MotionSource::code(page_background_document), + 10, + 1, + 48, + 32, + )) + .unwrap(); + let page_background_pixels = image::open(&page_background.frames[0]).unwrap().to_rgba8(); + assert!( + page_background_pixels + .pixels() + .all(|pixel| pixel.0 == [12, 34, 56, 255]), + "opaque author html/body backgrounds must render exactly without interfering with capture-session isolation" + ); + + let animation = r#" +
+ + "#; + + // The normal post-seek fence advances author time once. Background + // readback for alpha recovery must not advance it again between the + // black and white samples, or these primary colors become inconsistent. + let timer_root = tempfile::tempdir().unwrap(); + let timer_document = r#" +
+ + "#; + let timer_request = + MotionRenderRequest::new(MotionSource::code(timer_document), 10, 2, 48, 32); + let timer_frame = renderer(timer_root.path()).render(&timer_request).unwrap(); + let timer_pixels = image::open(&timer_frame.frames[0]).unwrap().to_rgba8(); + let unique_timer_pixels = timer_pixels + .pixels() + .map(|pixel| pixel.0) + .collect::>(); + assert_eq!( + unique_timer_pixels.len(), + 1, + "the full-canvas timer fixture must remain spatially uniform: {unique_timer_pixels:?}" + ); + let timer_pixel = timer_pixels.get_pixel(0, 0).0; + assert_eq!( + timer_pixels.get_pixel(47, 31).0, + timer_pixel, + "an exact-size capture must preserve valid content touching the right/bottom edge" + ); + assert!( + timer_pixel[0] > timer_pixel[1] + && timer_pixel[0] > timer_pixel[2] + && timer_pixel[3] > 0 + && timer_pixel[3] < 255, + "tick 1 must produce one uniform, red-dominant translucent state; actual={timer_pixel:?}" + ); + + let first_root = tempfile::tempdir().unwrap(); + let second_root = tempfile::tempdir().unwrap(); + let first = renderer(first_root.path()) + .render(&request(animation)) + .unwrap(); + let second = renderer(second_root.path()) + .render(&request(animation)) + .unwrap(); + + assert_eq!(first.frame_count(), 3); + assert_eq!(second.frame_count(), 3); + for (a, b) in first.frames.iter().zip(&second.frames) { + let a_bytes = fs::read(a).unwrap(); + let b_bytes = fs::read(b).unwrap(); + if a_bytes != b_bytes { + let a_pixels = image::load_from_memory(&a_bytes).unwrap().to_rgba8(); + let b_pixels = image::load_from_memory(&b_bytes).unwrap().to_rgba8(); + assert_eq!(a_pixels.dimensions(), b_pixels.dimensions()); + let differences = a_pixels + .as_raw() + .iter() + .zip(b_pixels.as_raw()) + .filter(|(left, right)| left != right) + .count(); + let max_delta = a_pixels + .as_raw() + .iter() + .zip(b_pixels.as_raw()) + .map(|(left, right)| left.abs_diff(*right)) + .max() + .unwrap_or(0); + let (width, height) = a_pixels.dimensions(); + let mut differing_pixels = 0usize; + let mut canvas_edge_pixels = 0usize; + let mut bbox = None::<(u32, u32, u32, u32)>; + let mut samples = Vec::new(); + for (x, y, left) in a_pixels.enumerate_pixels() { + let right = b_pixels.get_pixel(x, y); + if left != right { + differing_pixels += 1; + if x == 0 || y == 0 || x + 1 == width || y + 1 == height { + canvas_edge_pixels += 1; + } + bbox = Some(match bbox { + Some((min_x, min_y, max_x, max_y)) => { + (min_x.min(x), min_y.min(y), max_x.max(x), max_y.max(y)) + } + None => (x, y, x, y), + }); + if samples.len() < 20 { + samples.push((x, y, left.0, right.0)); + } + } + } + let unique_a = a_pixels + .pixels() + .map(|pixel| pixel.0) + .collect::>(); + let unique_b = b_pixels + .pixels() + .map(|pixel| pixel.0) + .collect::>(); + panic!( + "deterministic captures differ: {differences} channels, max delta {max_delta}, differing_pixels={differing_pixels}, bbox={bbox:?}, canvas_edge_pixels={canvas_edge_pixels}, interior_pixels={}, samples={samples:?}, png_bytes=({}, {}), unique_a={unique_a:?}, unique_b={unique_b:?}", + differing_pixels - canvas_edge_pixels, + a_bytes.len(), + b_bytes.len() + ); + } + } + assert_ne!( + fs::read(&first.frames[0]).unwrap(), + fs::read(&first.frames[2]).unwrap(), + "virtual time must advance the visible animation" + ); + let first_png = image::open(&first.frames[0]).unwrap().to_rgba8(); + assert_eq!(first_png.get_pixel(0, 0)[3], 255); + assert_eq!( + first_png.get_pixel(47, 31)[3], + 0, + "surface capture must preserve the transparent canvas outside content" + ); + let source = MotionClipSource::new(first.clone(), decoded); + let composited = source + .decoded_frame("motion", 2) + .expect("Chromium PNG enters MotionClipSource"); + assert_eq!((composited.width, composited.height), (48, 32)); + assert_eq!(composited.rgba.len(), 48 * 32 * 4); + + // Opaque clips use isolated stable PageHandlers without alpha recovery. + // Render twice to prove determinism across independent browser processes. + let opaque_root = tempfile::tempdir().unwrap(); + let opaque = renderer(opaque_root.path()) + .render(&request(animation).with_transparent(false)) + .unwrap(); + let opaque_again_root = tempfile::tempdir().unwrap(); + let opaque_again = renderer(opaque_again_root.path()) + .render(&request(animation).with_transparent(false)) + .unwrap(); + assert_eq!(opaque.frame_count(), 3); + assert_eq!(opaque_again.frame_count(), 3); + for (first, second) in opaque.frames.iter().zip(&opaque_again.frames) { + assert_eq!( + fs::read(first).unwrap(), + fs::read(second).unwrap(), + "opaque compositor captures must be byte-identical across browsers" + ); + } + let opaque_first = image::open(&opaque.frames[0]).unwrap().to_rgba8(); + assert_eq!(opaque_first.dimensions(), (48, 32)); + assert!(opaque_first.pixels().all(|pixel| pixel[3] == 255)); + assert_ne!( + fs::read(&opaque.frames[0]).unwrap(), + fs::read(&opaque.frames[2]).unwrap(), + "opaque view capture must retain deterministic frame animation" + ); + + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + listener.set_nonblocking(true).unwrap(); + let origin = format!("http://{}", listener.local_addr().unwrap()); + let served = Arc::new(AtomicBool::new(false)); + let server_observed = Arc::clone(&served); + let stop_server = Arc::new(AtomicBool::new(false)); + let server_stopped = Arc::clone(&stop_server); + let server = thread::spawn(move || { + while !server_stopped.load(Ordering::Acquire) { + match listener.accept() { + Ok((mut stream, _)) => { + let mut request = [0u8; 2048]; + let _ = stream.read(&mut request); + let svg = b""; + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: image/svg+xml\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + svg.len() + ); + // Chromium may close the socket as soon as the image is + // decoded and the frame is captured. A late BrokenPipe + // therefore confirms neither a server nor render + // failure; accepting the request is the network-policy + // boundary this fixture needs to prove. + server_observed.store(true, Ordering::Release); + let _ = stream.write_all(response.as_bytes()); + let _ = stream.write_all(svg); + return; + } + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + thread::sleep(Duration::from_millis(20)); + } + Err(error) => panic!("loopback server failed: {error}"), + } + } + }); + let allowed_root = tempfile::tempdir().unwrap(); + let allowed = HeadlessChromiumRenderer::new( + MotionCache::new(allowed_root.path()), + SandboxPolicy::offline_with_timeout(Duration::from_secs(60)).allow_origin(&origin), + ) + .with_browser_path(browser()) + .render(&request(&format!(""))); + stop_server.store(true, Ordering::Release); + server.join().unwrap(); + assert_eq!(allowed.unwrap().frame_count(), 3); + assert!(served.load(Ordering::Acquire)); + + let blocked_root = tempfile::tempdir().unwrap(); + let blocked = renderer(blocked_root.path()) + .render(&request( + r#""#, + )) + .unwrap_err(); + assert!(matches!(blocked, MotionError::Sandbox(_)), "{blocked:?}"); + assert!( + fs::read_dir(blocked_root.path()) + .unwrap() + .all(|entry| fs::read_dir(entry.unwrap().path()) + .unwrap() + .next() + .is_none()), + "a rejected render must not leave partial frames" + ); + + let late_blocked_root = tempfile::tempdir().unwrap(); + let late_blocked = renderer(late_blocked_root.path()) + .render(&request( + r#""#, + )) + .unwrap_err(); + assert!( + matches!(late_blocked, MotionError::Sandbox(_)), + "{late_blocked:?}" + ); + assert!( + fs::read_dir(late_blocked_root.path()) + .unwrap() + .all(|entry| fs::read_dir(entry.unwrap().path()) + .unwrap() + .next() + .is_none()), + "a timer-triggered policy failure must not leave partial frames" + ); + + let filesystem_root = tempfile::tempdir().unwrap(); + let filesystem = renderer(filesystem_root.path()) + .render(&request(r#""#)) + .unwrap_err(); + assert!( + matches!(filesystem, MotionError::Sandbox(_)), + "{filesystem:?}" + ); + + let timeout_root = tempfile::tempdir().unwrap(); + let timeout_renderer = HeadlessChromiumRenderer::new( + MotionCache::new(timeout_root.path()), + SandboxPolicy::offline_with_timeout(Duration::from_millis(500)), + ) + .with_browser_path(browser()); + assert!(matches!( + timeout_renderer.render(&request("")), + Err(MotionError::Timeout(_)) + )); + + let crash_root = tempfile::tempdir().unwrap(); + let crash_renderer = HeadlessChromiumRenderer::new( + MotionCache::new(crash_root.path()), + SandboxPolicy::default(), + ) + .with_browser_path(if cfg!(windows) { + PathBuf::from(r"C:\Windows\System32\where.exe") + } else { + PathBuf::from("/usr/bin/false") + }); + let crashed = crash_renderer + .render(&request("
crash
")) + .unwrap_err(); + assert!( + matches!(crashed, MotionError::RenderFailed(_)), + "{crashed:?}" + ); + + let malformed_root = tempfile::tempdir().unwrap(); + assert!(matches!( + renderer(malformed_root.path()).render(&request(" ")), + Err(MotionError::InvalidSource(_)) + )); + + let cancellation = MotionCancellationToken::new(); + let cancelled_root = tempfile::tempdir().unwrap(); + let cancellation_for_render = cancellation.clone(); + let cancelled_cache = cancelled_root.path().to_path_buf(); + let cancelled_browser = browser(); + let render_thread = thread::spawn(move || { + HeadlessChromiumRenderer::new( + MotionCache::new(cancelled_cache), + SandboxPolicy::offline_with_timeout(Duration::from_secs(60)), + ) + .with_browser_path(cancelled_browser) + .with_cancellation_token(cancellation_for_render) + .render(&request("")) + }); + thread::sleep(Duration::from_millis(200)); + cancellation.cancel(); + assert!(matches!( + render_thread.join().unwrap(), + Err(MotionError::Cancelled) + )); + + assert_eq!( + live_profiles(), + profiles_before, + "success, policy failure, timeout, crash, and cancellation must clean browser profiles" + ); + } +} + +#[cfg(feature = "chromium")] +#[test] +fn host_wrapper_context_csp_and_guard_probe() { + live::assert_gate_serializes_concurrent_callers(); + let _live_test_guard = live_test_guard(); + live::wrapper_probe(); +} + +#[cfg(feature = "chromium")] +#[test] +fn consecutive_cache_misses_reuse_one_chromium_session() { + let _live_test_guard = live_test_guard(); + live::browser_pool_reuses_session_probe(); +} + +#[cfg(feature = "chromium")] +#[test] +fn browser_pool_invalidates_on_blocked_or_cancelled_render() { + let _live_test_guard = live_test_guard(); + live::browser_pool_invalidation_probe(); +} + +#[cfg(feature = "chromium")] +#[test] +fn concurrent_error_invalidates_an_active_browser_lease() { + let _live_test_guard = live_test_guard(); + live::concurrent_browser_pool_invalidation_probe(); +} + +#[cfg(feature = "chromium")] +#[test] +fn four_k_single_frame_opaque_and_transparent_budget_smoke() { + let _live_test_guard = live_test_guard(); + live::four_k_budget_smoke(); +} + +#[cfg(feature = "chromium")] +#[test] +fn virtual_time_network_csp_timeout_cleanup_and_frame_identity() { + let _live_test_guard = live_test_guard(); + live::run(); +} + +#[cfg(not(feature = "chromium"))] +#[test] +fn virtual_time_network_csp_timeout_cleanup_and_frame_identity() { + use opentake_motion::{ + HeadlessChromiumRenderer, MotionCache, MotionError, MotionRenderRequest, MotionRenderer, + MotionSource, SandboxPolicy, + }; + + let root = tempfile::tempdir().unwrap(); + let renderer = + HeadlessChromiumRenderer::new(MotionCache::new(root.path()), SandboxPolicy::default()); + let request = MotionRenderRequest::new(MotionSource::code("
"), 30, 1, 16, 16); + assert!(matches!( + renderer.render(&request), + Err(MotionError::RendererUnavailable(_)) + )); +} diff --git a/crates/opentake-ops/src/command.rs b/crates/opentake-ops/src/command.rs index 57586f08..9e69854f 100644 --- a/crates/opentake-ops/src/command.rs +++ b/crates/opentake-ops/src/command.rs @@ -16,17 +16,23 @@ //! Ripple refusals (a sync-locked follower can't absorb the shift) abort like a //! validation error: `Err(EditError::Refused)`, document untouched. -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use opentake_domain::{ - ChromaKey, ClipType, ColorGrade, Crop, Effect, Interpolation, Mask, Timeline, Transform, + AudioDenoise, CaptionTranslationInput, ChromaKey, Clip, ClipType, ColorGrade, ColorMatchInput, + Crop, Effect, Interpolation, LoudnessNormalization, LutReference, Mask, MaskShape, + MediaManifestEntry, NestedSequence, ScriptAssemblyPlan, StabilizationTrack, Timeline, Track, + Transform, Transition, TransitionKind, VoiceModelRecord, MAX_MASKS_PER_CLIP, + MAX_POLYGON_MASK_POINTS, }; use crate::editor_state::EditorState; use crate::engines::FrameRange; use crate::id::IdGen; use crate::ops; +use crate::ops::duplicate::{duplicate_clips_from_plans, DuplicateClipPlan}; use crate::ops::move_clips::ClipMove; +use crate::ops::move_clips::{move_clips_from_plans, MoveClipPlan}; use crate::ops::place::PlaceSpec; use crate::ops::ripple::RippleOutcome; use crate::ops::trim::TrimEdit; @@ -40,6 +46,462 @@ pub enum EditError { Refused(String), } +#[cfg(test)] +mod motion_media_transaction_tests { + use super::*; + use crate::id::SeqIdGen; + use opentake_domain::{MediaSource, Track}; + + fn media(id: &str) -> MediaManifestEntry { + MediaManifestEntry { + id: id.into(), + name: format!("{id}.mp4"), + kind: ClipType::Video, + source: MediaSource::Project { + relative_path: format!("media/{id}.mp4"), + }, + duration: 1.0, + generation_input: None, + source_width: Some(64), + source_height: Some(36), + source_fps: Some(30.0), + has_audio: Some(false), + color: None, + proxy: None, + folder_id: None, + cached_remote_url: None, + cached_remote_url_expires_at: None, + } + } + + fn clip(media_ref: &str, track_index: usize) -> ClipEntry { + ClipEntry { + media_ref: media_ref.into(), + media_type: ClipType::Video, + source_clip_type: ClipType::Video, + track_index, + start_frame: 0, + duration_frames: 30, + trim_start_frame: None, + trim_end_frame: None, + has_audio: false, + add_linked_audio: false, + transform: None, + } + } + + #[test] + fn register_and_add_is_one_undoable_document_transaction() { + let mut state = EditorState::default(); + let ids = SeqIdGen::default(); + let result = apply( + &mut state, + EditCommand::RegisterMediaAndAddClip { + media: media("motion-a"), + entry: clip("motion-a", 0), + auto_track: true, + }, + &ids, + ) + .unwrap(); + + assert_eq!(result.action_name, "Add Motion Graphic"); + assert_eq!(state.manifest.entries.len(), 1); + assert_eq!(state.timeline.tracks.len(), 1); + assert_eq!(state.timeline.tracks[0].clips.len(), 1); + assert_eq!(state.undo_depth(), 1); + + apply(&mut state, EditCommand::Undo, &ids).unwrap(); + assert!(state.manifest.entries.is_empty()); + assert!(state.timeline.tracks.is_empty()); + } + + #[test] + fn failed_register_and_place_leaves_manifest_timeline_and_history_unchanged() { + let mut state = EditorState::default(); + state + .timeline + .tracks + .push(Track::new("audio", ClipType::Audio)); + let before = state.clone(); + let error = apply( + &mut state, + EditCommand::RegisterMediaAndAddClip { + media: media("motion-a"), + entry: clip("motion-a", 0), + auto_track: false, + }, + &SeqIdGen::default(), + ) + .unwrap_err(); + + assert!(error.to_string().contains("not compatible")); + assert_eq!(state.timeline, before.timeline); + assert_eq!(state.manifest, before.manifest); + assert_eq!(state.undo_depth(), before.undo_depth()); + assert_eq!(state.version(), before.version()); + } + + #[test] + fn register_and_swap_preserves_clip_identity_and_undo_restores_old_asset() { + let mut state = EditorState::default(); + let ids = SeqIdGen::default(); + let added = apply( + &mut state, + EditCommand::RegisterMediaAndAddClip { + media: media("motion-a"), + entry: clip("motion-a", 0), + auto_track: true, + }, + &ids, + ) + .unwrap(); + let clip_id = added.affected_clip_ids[0].clone(); + + let edited = apply( + &mut state, + EditCommand::RegisterMediaAndSwapClip { + media: media("motion-b"), + clip_id: clip_id.clone(), + }, + &ids, + ) + .unwrap(); + assert_eq!(edited.action_name, "Edit Motion Graphic"); + assert_eq!(edited.affected_clip_ids, vec![clip_id.clone()]); + assert_eq!(state.manifest.entries.len(), 2); + assert_eq!(state.timeline.tracks[0].clips[0].id, clip_id); + assert_eq!(state.timeline.tracks[0].clips[0].media_ref, "motion-b"); + + apply(&mut state, EditCommand::Undo, &ids).unwrap(); + assert_eq!(state.manifest.entries.len(), 1); + assert_eq!(state.timeline.tracks[0].clips[0].media_ref, "motion-a"); + } + + #[test] + fn register_swap_and_clear_masks_is_one_reversible_transaction() { + let mut state = EditorState::default(); + let ids = SeqIdGen::default(); + let added = apply( + &mut state, + EditCommand::RegisterMediaAndAddClip { + media: media("source-a"), + entry: clip("source-a", 0), + auto_track: true, + }, + &ids, + ) + .unwrap(); + let clip_id = added.affected_clip_ids[0].clone(); + state.timeline.tracks[0].clips[0].masks = vec![Mask::default()]; + let undo_depth_before = state.undo_depth(); + + let edited = apply( + &mut state, + EditCommand::RegisterMediaAndSwapClipClearingMasks { + media: media("object-removed-b"), + clip_id: clip_id.clone(), + }, + &ids, + ) + .unwrap(); + + assert_eq!(edited.action_name, "Remove Masked Object"); + assert_eq!(edited.affected_clip_ids, vec![clip_id]); + assert_eq!(state.undo_depth(), undo_depth_before + 1); + assert_eq!(state.manifest.entries.len(), 2); + assert_eq!( + state.timeline.tracks[0].clips[0].media_ref, + "object-removed-b" + ); + assert!(state.timeline.tracks[0].clips[0].masks.is_empty()); + + apply(&mut state, EditCommand::Undo, &ids).unwrap(); + assert_eq!(state.manifest.entries.len(), 1); + assert_eq!(state.timeline.tracks[0].clips[0].media_ref, "source-a"); + assert_eq!( + state.timeline.tracks[0].clips[0].masks, + vec![Mask::default()] + ); + + apply(&mut state, EditCommand::Redo, &ids).unwrap(); + assert_eq!(state.manifest.entries.len(), 2); + assert_eq!( + state.timeline.tracks[0].clips[0].media_ref, + "object-removed-b" + ); + assert!(state.timeline.tracks[0].clips[0].masks.is_empty()); + } +} + +#[cfg(test)] +mod color_match_command_tests { + use super::*; + use crate::id::SeqIdGen; + use opentake_domain::Rgb; + + fn input() -> ColorMatchInput { + ColorMatchInput { + reference_media_ref: "reference".into(), + reference_frame: 3, + target_frame: 12, + algorithm: "fixture-match".into(), + algorithm_version: 1, + target_mean_linear: Rgb::new(0.3, 0.2, 0.1), + reference_mean_linear: Rgb::new(0.1, 0.2, 0.3), + delta_e_before: 20.0, + delta_e_after: 1.0, + target_luma_before: 0.2, + target_luma_after: 0.2, + } + } + + #[test] + fn apply_color_match_is_one_undoable_edit_and_manual_grade_clears_provenance() { + let mut state = EditorState::default(); + let ids = SeqIdGen::default(); + apply( + &mut state, + EditCommand::InsertTrack { + kind: ClipType::Video, + at: None, + }, + &ids, + ) + .unwrap(); + let clip_id = ids.next_id(); + state.timeline.tracks[0] + .clips + .push(Clip::new(clip_id.clone(), "target", 10, 20)); + let grade = ColorGrade { + temperature: 0.2, + ..ColorGrade::default() + }; + let undo_before = state.undo_depth(); + + let result = apply( + &mut state, + EditCommand::ApplyColorMatch { + clip_id: clip_id.clone(), + grade, + input: input(), + }, + &ids, + ) + .unwrap(); + assert_eq!(result.action_name, "Match Color"); + assert_eq!(state.undo_depth(), undo_before + 1); + assert_eq!(state.timeline.tracks[0].clips[0].color_grade, Some(grade)); + assert_eq!( + state.timeline.tracks[0].clips[0] + .color_match_input + .as_ref() + .unwrap() + .reference_media_ref, + "reference" + ); + + apply(&mut state, EditCommand::Undo, &ids).unwrap(); + assert!(state.timeline.tracks[0].clips[0].color_grade.is_none()); + assert!(state.timeline.tracks[0].clips[0] + .color_match_input + .is_none()); + apply(&mut state, EditCommand::Redo, &ids).unwrap(); + apply( + &mut state, + EditCommand::SetColorGrade { + clip_ids: vec![clip_id], + grade: Some(ColorGrade::default()), + }, + &ids, + ) + .unwrap(); + assert!(state.timeline.tracks[0].clips[0] + .color_match_input + .is_none()); + } +} + +#[cfg(test)] +mod aligned_stem_track_tests { + use super::*; + use crate::id::SeqIdGen; + + fn stem(media_ref: &str) -> ClipEntry { + ClipEntry { + media_ref: media_ref.into(), + media_type: ClipType::Audio, + source_clip_type: ClipType::Audio, + track_index: 0, + start_frame: 40, + duration_frames: 100, + trim_start_frame: None, + trim_end_frame: None, + has_audio: true, + add_linked_audio: false, + transform: None, + } + } + + #[test] + fn aligned_stems_use_separate_tracks_and_one_undo_entry() { + let mut state = EditorState::default(); + let ids = SeqIdGen::default(); + let result = apply( + &mut state, + EditCommand::AddClipsToSeparateAutoTracks { + entries: vec![stem("vocals"), stem("accompaniment")], + }, + &ids, + ) + .unwrap(); + assert_eq!(result.action_name, "Import Stems To Tracks"); + assert_eq!(result.affected_clip_ids.len(), 2); + assert_eq!(state.timeline.tracks.len(), 2); + assert!(state.timeline.tracks.iter().all(|track| { + track.kind == ClipType::Audio + && track.clips.len() == 1 + && track.clips[0].start_frame == 40 + && track.clips[0].duration_frames == 100 + })); + assert_eq!(state.undo_depth(), 1); + + apply(&mut state, EditCommand::Undo, &ids).unwrap(); + assert!(state.timeline.tracks.is_empty()); + } +} + +#[cfg(test)] +mod loudness_command_tests { + use super::*; + use crate::id::SeqIdGen; + + fn normalization() -> LoudnessNormalization { + LoudnessNormalization { + target_lufs: -16.0, + true_peak_ceiling_dbtp: -1.0, + input_integrated_lufs: -23.0, + input_true_peak_dbtp: -8.0, + gain_db: 7.0, + output_integrated_lufs: -16.0, + output_true_peak_dbtp: -1.0, + } + } + + #[test] + fn loudness_apply_reset_and_undo_are_one_step_operations() { + let mut timeline = Timeline::new(); + let mut track = Track::new("a1", ClipType::Audio); + let mut clip = Clip::new("audio", "asset", 0, 90); + clip.media_type = ClipType::Audio; + clip.source_clip_type = ClipType::Audio; + track.clips.push(clip); + timeline.tracks.push(track); + let mut state = EditorState::from_timeline(timeline); + let ids = SeqIdGen::default(); + + apply( + &mut state, + EditCommand::SetLoudnessNormalization { + clip_id: "audio".to_string(), + normalization: Some(normalization()), + }, + &ids, + ) + .unwrap(); + assert_eq!( + state.timeline.tracks[0].clips[0].loudness_normalization, + Some(normalization()) + ); + assert_eq!(state.undo_depth(), 1); + + apply(&mut state, EditCommand::Undo, &ids).unwrap(); + assert!(state.timeline.tracks[0].clips[0] + .loudness_normalization + .is_none()); + apply(&mut state, EditCommand::Redo, &ids).unwrap(); + assert_eq!( + state.timeline.tracks[0].clips[0].loudness_normalization, + Some(normalization()) + ); + + apply( + &mut state, + EditCommand::SetLoudnessNormalization { + clip_id: "audio".to_string(), + normalization: None, + }, + &ids, + ) + .unwrap(); + assert!(state.timeline.tracks[0].clips[0] + .loudness_normalization + .is_none()); + apply(&mut state, EditCommand::Undo, &ids).unwrap(); + assert_eq!( + state.timeline.tracks[0].clips[0].loudness_normalization, + Some(normalization()) + ); + } +} + +#[cfg(test)] +mod denoise_command_tests { + use super::*; + use crate::id::SeqIdGen; + use opentake_domain::DenoiseMode; + + #[test] + fn denoise_apply_reset_and_undo_are_one_step_operations() { + let mut timeline = Timeline::new(); + let mut track = Track::new("a1", ClipType::Audio); + let mut clip = Clip::new("audio", "asset", 0, 90); + clip.media_type = ClipType::Audio; + clip.source_clip_type = ClipType::Audio; + track.clips.push(clip); + timeline.tracks.push(track); + let mut state = EditorState::from_timeline(timeline); + let config = AudioDenoise { + mode: DenoiseMode::Voice, + strength: 0.8, + preview_enabled: true, + }; + + apply( + &mut state, + EditCommand::SetAudioDenoise { + clip_id: "audio".to_string(), + denoise: Some(config), + }, + &SeqIdGen::default(), + ) + .unwrap(); + assert_eq!( + state.timeline.tracks[0].clips[0].audio_denoise, + Some(config) + ); + assert_eq!(state.undo_depth(), 1); + apply(&mut state, EditCommand::Undo, &SeqIdGen::default()).unwrap(); + assert!(state.timeline.tracks[0].clips[0].audio_denoise.is_none()); + apply(&mut state, EditCommand::Redo, &SeqIdGen::default()).unwrap(); + assert_eq!( + state.timeline.tracks[0].clips[0].audio_denoise, + Some(config) + ); + + apply( + &mut state, + EditCommand::SetAudioDenoise { + clip_id: "audio".to_string(), + denoise: None, + }, + &SeqIdGen::default(), + ) + .unwrap(); + assert!(state.timeline.tracks[0].clips[0].audio_denoise.is_none()); + } +} + impl std::fmt::Display for EditError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { @@ -104,39 +566,105 @@ impl ClipEntry { } } -/// One id + new-name pair for [`EditCommand::RenameMedia`] / -/// [`EditCommand::RenameFolder`]. A single rename is a one-element vec, so the -/// batch and single forms apply in the same undo group (1:1 with upstream's -/// `withUndoGroup`). -#[derive(Clone, Debug)] -pub struct RenameEntry { - pub id: String, - pub name: String, -} - -/// A text overlay entry for [`EditCommand::AddTexts`]. The transform is supplied -/// fully resolved (text measurement is a media/UI concern this leaf doesn't do). +/// Media placement payload before a destination track has been resolved. +/// `PlaceMedia` resolves the stable target inside the same transaction that may +/// also apply root project settings and/or insert a destination track. #[derive(Clone, Debug)] -pub struct TextEntry { - pub track_index: usize, +pub struct UnplacedClipEntry { + pub media_ref: String, + pub media_type: ClipType, + pub source_clip_type: ClipType, pub start_frame: i32, pub duration_frames: i32, - pub content: String, - pub text_style: opentake_domain::TextStyle, - pub transform: Transform, + pub trim_start_frame: Option, + pub trim_end_frame: Option, + pub has_audio: bool, + pub add_linked_audio: bool, + pub transform: Option, } -/// A text overlay entry for [`EditCommand::AddTextsAutoTrack`]. Identical to -/// [`TextEntry`] minus `track_index` — every entry in the batch lands on the -/// single fresh track the command creates, so there is nothing to target. -#[derive(Clone, Debug)] -pub struct TextAutoTrackEntry { - pub start_frame: i32, - pub duration_frames: i32, - pub content: String, - pub text_style: opentake_domain::TextStyle, - pub transform: Transform, -} +impl UnplacedClipEntry { + fn at_track(&self, track_index: usize) -> ClipEntry { + ClipEntry { + media_ref: self.media_ref.clone(), + media_type: self.media_type, + source_clip_type: self.source_clip_type, + track_index, + start_frame: self.start_frame, + duration_frames: self.duration_frames, + trim_start_frame: self.trim_start_frame, + trim_end_frame: self.trim_end_frame, + has_audio: self.has_audio, + add_linked_audio: self.add_linked_audio, + transform: self.transform, + } + } +} + +/// Optional root settings applied by [`EditCommand::PlaceMedia`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ProjectTimelineSettings { + pub fps: i32, + pub width: i32, + pub height: i32, +} + +/// Stable destination for one media-placement gesture. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum PlaceMediaTarget { + ExistingTrack { track_id: String }, + NewTrack { kind: ClipType, at: Option }, +} + +/// One authoritative new-track drag operation. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum NewTrackClipMode { + Move, + Duplicate, +} + +/// One deep-paste entry. `clip.id` is the source identity used for transition, +/// link-group, and caption-group remapping; a fresh id is always minted. +#[derive(Clone, Debug)] +pub struct PasteClipEntry { + pub clip: Clip, + pub target_track_id: String, + pub start_frame: i32, +} + +/// One id + new-name pair for [`EditCommand::RenameMedia`] / +/// [`EditCommand::RenameFolder`]. A single rename is a one-element vec, so the +/// batch and single forms apply in the same undo group (1:1 with upstream's +/// `withUndoGroup`). +#[derive(Clone, Debug)] +pub struct RenameEntry { + pub id: String, + pub name: String, +} + +/// A text overlay entry for [`EditCommand::AddTexts`]. The transform is supplied +/// fully resolved (text measurement is a media/UI concern this leaf doesn't do). +#[derive(Clone, Debug)] +pub struct TextEntry { + pub track_index: usize, + pub start_frame: i32, + pub duration_frames: i32, + pub content: String, + pub text_style: opentake_domain::TextStyle, + pub transform: Transform, +} + +/// A text overlay entry for [`EditCommand::AddTextsAutoTrack`]. Identical to +/// [`TextEntry`] minus `track_index` — every entry in the batch lands on the +/// single fresh track the command creates, so there is nothing to target. +#[derive(Clone, Debug)] +pub struct TextAutoTrackEntry { + pub start_frame: i32, + pub duration_frames: i32, + pub content: String, + pub text_style: opentake_domain::TextStyle, + pub transform: Transform, +} /// One built caption clip for [`EditCommand::AddCaptions`]. Like [`TextEntry`] /// but (a) has no `track_index` — every caption lands on the single fresh track @@ -154,6 +682,16 @@ pub struct CaptionEntry { pub caption_group_id: String, } +/// One reviewed caption text replacement. A batch is validated completely +/// before mutation, preserving clip identity and timing as one undo step. +#[derive(Clone, Debug)] +pub struct CaptionTranslationChange { + pub clip_id: String, + pub expected_source_text: String, + pub translated_text: String, + pub input: CaptionTranslationInput, +} + /// A single clip property assignment for [`EditCommand::SetClipProperties`]. /// `None` fields are left unchanged; setting a scalar clears the matching /// keyframe track (mirrors `applyPropertyChanges`). @@ -188,6 +726,15 @@ pub struct ClipProperties { pub reversed: Option, } +/// One clip-specific property assignment. Unlike [`EditCommand::SetClipProperties`], +/// this form permits a different fully-resolved transform per clip while keeping +/// the complete multi-clip change in one validation/undo transaction. +#[derive(Clone, Debug)] +pub struct ClipPropertyAssignment { + pub clip_id: String, + pub properties: ClipProperties, +} + /// Which keyframe track [`EditCommand::SetKeyframes`] targets. #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub enum KeyframeProperty { @@ -223,12 +770,84 @@ pub enum KeyframeValue { /// The unified editing command. Every editing surface routes through this. #[derive(Clone, Debug)] pub enum EditCommand { + /// Register an editable child timeline and place one compound clip that + /// references it. Sequence + clip creation is one undoable transaction. + CreateNestedSequence { + name: String, + timeline: Timeline, + track_index: usize, + start_frame: i32, + duration_frames: i32, + }, + /// Turn selected root clips into one editable compound clip. The child + /// timeline keeps their relative tracks/timing and source edits. + CreateNestedSequenceFromClips { name: String, clip_ids: Vec }, + /// Apply any ordinary edit command to a child timeline while preserving one + /// root-level undo snapshot and the shared media manifest. + EditNestedSequence { + sequence_id: String, + command: Box, + }, + /// Replace the editable contents of one existing nested sequence. + SetNestedSequenceTimeline { + sequence_id: String, + timeline: Timeline, + }, + /// Rename a nested sequence without changing references. + RenameNestedSequence { sequence_id: String, name: String }, + /// Replace one compound clip with clipped copies of its child timeline + /// tracks, preserving media edits and keeping the operation undoable. + DissolveNestedSequence { clip_id: String }, + /// Apply optional root project settings, resolve one stable root/child + /// destination, optionally insert its track, and place media as one + /// document transaction and one Undo step. + PlaceMedia { + sequence_id: Option, + settings: Option, + target: PlaceMediaTarget, + entry: UnplacedClipEntry, + }, /// Overwrite-place clips (clears each destination range first). AddClips { entries: Vec }, /// Overwrite-place clips on fresh shared tracks chosen by media type. /// Visual entries share one new visual track; audio entries share one new /// audio track. Track insertion and placement commit as one transaction. AddClipsAutoTrack { entries: Vec }, + /// Place each entry on its own fresh compatible track in one transaction. + /// Used for aligned stems that intentionally overlap in time. + AddClipsToSeparateAutoTracks { entries: Vec }, + /// Register one already validated project-managed video and place it in the + /// same undo snapshot. Used by deterministic external renderers so undo + /// removes both the generated clip and its manifest record. + RegisterMediaAndAddClip { + media: MediaManifestEntry, + entry: ClipEntry, + auto_track: bool, + }, + /// Register a newly rendered replacement and swap an existing clip to it in + /// one undo snapshot. Undo restores the old media ref and removes the new + /// manifest record while leaving the prior rendered asset available. + RegisterMediaAndSwapClip { + media: MediaManifestEntry, + clip_id: String, + }, + /// Register a rendered replacement, swap an existing clip to it, and + /// remove the editable masks that were baked into that derivative. The + /// three mutations share one undo snapshot so undo restores both the + /// source media and its masks. + RegisterMediaAndSwapClipClearingMasks { + media: MediaManifestEntry, + clip_id: String, + }, + /// Register one captured still and freeze the source clip to it in the + /// same undo snapshot. A rejected/stale caller can therefore never leave a + /// manifest-only capture behind, and one Undo restores both structures. + RegisterMediaAndFreezeFrame { + media: MediaManifestEntry, + clip_id: String, + at_frame: i32, + duration_frames: i32, + }, /// Ripple-insert clips at `at_frame`, pushing later clips right. InsertClips { track_index: usize, @@ -247,10 +866,29 @@ pub enum EditCommand { offset_frames: i32, target_track_indexes: Vec, }, + /// Insert a destination track and move or duplicate the authoritative clip + /// set into it in one transaction. Track placement is derived by stable ids + /// after insertion, so index shifts cannot retarget a gesture. + MoveOrDuplicateClipsToNewTrack { + clip_ids: Vec, + lead_clip_id: String, + requested_frame_delta: i32, + insert_at: usize, + mode: NewTrackClipMode, + }, + /// Deep-paste complete clip snapshots with fresh clip/link/caption ids. + PasteClips { entries: Vec }, /// Remove clips (expanded to linked partners), pruning emptied tracks. RemoveClips { clip_ids: Vec }, /// Split a clip at a frame (splits linked partners too). SplitClip { clip_id: String, at_frame: i32 }, + /// Split every requested clip at one playhead position as a single + /// transaction. Duplicate ids and members of the same link group are + /// applied once, while every requested target is preflighted. + SplitClips { + clip_ids: Vec, + at_frame: i32, + }, /// Freeze Frame: split at `at_frame`, then ripple-insert a still image clip. FreezeFrame { clip_id: String, @@ -268,6 +906,19 @@ pub enum EditCommand { clip_ids: Vec, properties: Box, }, + /// Apply clip-specific property bundles as one atomic undoable edit. This is + /// used when a shared partial transform has to be resolved against each + /// source clip's current aspect ratio before committing. + SetClipPropertiesPerClip { + assignments: Vec, + }, + /// Commit one canvas transform at an absolute timeline frame. Active + /// transform tracks receive keyframes; inactive properties remain static. + SetTransformAtFrame { + clip_id: String, + frame: i32, + transform: Transform, + }, /// Replace (or clear) a clip's keyframe track for one property. SetKeyframes { clip_id: String, @@ -322,6 +973,17 @@ pub enum EditCommand { clip_ids: Vec, grade: Option, }, + /// Apply a sampled reference match and its persisted provenance together. + ApplyColorMatch { + clip_id: String, + grade: ColorGrade, + input: ColorMatchInput, + }, + /// Set or clear one project-managed 3D LUT on one or more clips. + SetLut { + clip_ids: Vec, + lut: Option, + }, /// Set (or clear with `None`) the chroma key on one or more clips. SetChromaKey { clip_ids: Vec, @@ -337,6 +999,36 @@ pub enum EditCommand { clip_ids: Vec, effects: Vec, }, + /// Apply or reset one source analysis as an undoable audio operation. + SetLoudnessNormalization { + clip_id: String, + normalization: Option, + }, + /// Apply or reset local non-destructive denoise parameters. + SetAudioDenoise { + clip_id: String, + denoise: Option, + }, + /// Persist a source-bound, editable stabilization analysis on one video clip. + ApplyStabilization { + clip_id: String, + solution: StabilizationTrack, + }, + /// Change user-facing stabilization strength and/or safety crop margin. + AdjustStabilization { + clip_id: String, + strength: Option, + crop_margin: Option, + }, + /// Remove stabilization while preserving authored transforms and source media. + ResetStabilization { clip_id: String }, + /// Set or clear the visual transition at one exact adjacent clip boundary. + SetTransition { + from_clip_id: String, + to_clip_id: String, + kind: Option, + duration_frames: i32, + }, /// Ripple-delete project-frame ranges on a track, closing the gaps. RippleDeleteRanges { track_index: usize, @@ -371,6 +1063,20 @@ pub enum EditCommand { /// composing `InsertTrack` + `AddTexts` would be two undo steps and could not /// stamp `caption_group_id`. Empty `entries` is a no-op (no track, no change). AddCaptions { entries: Vec }, + /// Accept a reviewed batch of translated captions without changing IDs, + /// track placement, or frame ranges. + ApplyCaptionTranslations { + changes: Vec, + }, + /// Persist a reviewable script assembly plan without placing any clips. + SaveScriptAssemblyPlan { plan: ScriptAssemblyPlan }, + /// Apply one already persisted plan to fresh visual/narration tracks as a + /// single timeline transaction. + ApplyScriptAssemblyPlan { plan_id: String }, + /// Persist one consent-bearing provider voice identity. + SaveVoiceModel { record: VoiceModelRecord }, + /// Permanently mark a provider voice as revoked in project metadata. + RevokeVoiceModel { voice_model_id: String }, /// Link clips into one group. Link { clip_ids: Vec }, /// Unlink clips (and their whole groups). @@ -461,10 +1167,17 @@ pub fn apply( command: EditCommand, ids: &dyn IdGen, ) -> Result { + // Commands inspect clip ends while deriving their transaction plan. Guard + // the complete persisted graph before even that read; undo/redo additionally + // validate their candidate history snapshot before replacing live state. + validate_timeline_frame_arithmetic(&state.timeline, "timeline")?; match command { EditCommand::Undo => { let before = state.snapshot(); - let changed = state.undo(); + let mut candidate = state.clone(); + let changed = candidate.undo(); + validate_timeline_frame_arithmetic(&candidate.timeline, "undo timeline")?; + *state = candidate; let after = state.snapshot(); Ok(result( state, @@ -481,7 +1194,10 @@ pub fn apply( } EditCommand::Redo => { let before = state.snapshot(); - let changed = state.redo(); + let mut candidate = state.clone(); + let changed = candidate.redo(); + validate_timeline_frame_arithmetic(&candidate.timeline, "redo timeline")?; + *state = candidate; let after = state.snapshot(); Ok(result( state, @@ -496,111 +1212,792 @@ pub fn apply( }, )) } - - EditCommand::AddClips { entries } => add_clips(state, entries, ids), - EditCommand::AddClipsAutoTrack { entries } => add_clips_auto_track(state, entries, ids), - EditCommand::InsertClips { - track_index, - at_frame, - entries, - } => insert_clips(state, track_index, at_frame, entries, ids), - EditCommand::MoveClips { moves } => move_clips(state, moves, ids), - EditCommand::DuplicateClips { - clip_ids, - offset_frames, - target_track_indexes, - } => duplicate_clips_cmd(state, clip_ids, offset_frames, target_track_indexes, ids), - EditCommand::RemoveClips { clip_ids } => remove_clips(state, clip_ids), - EditCommand::SplitClip { clip_id, at_frame } => split(state, clip_id, at_frame, ids), - EditCommand::FreezeFrame { - clip_id, - at_frame, - duration_frames, - media_ref, - } => freeze_frame(state, clip_id, at_frame, duration_frames, media_ref, ids), - EditCommand::TrimClips { edits } => trim(state, edits), - EditCommand::SetClipProperties { - clip_ids, - properties, - } => set_clip_properties(state, clip_ids, *properties), - EditCommand::SetKeyframes { - clip_id, - property, - payload, - } => set_keyframes(state, clip_id, property, payload), - EditCommand::StampKeyframe { - clip_id, - property, - frame, - } => stamp_keyframe(state, clip_id, property, frame), - EditCommand::UpsertKeyframe { - clip_id, - property, - frame, - value, - } => upsert_keyframe(state, clip_id, property, frame, value), - EditCommand::RemoveKeyframe { - clip_id, - property, - frame, - } => remove_keyframe(state, clip_id, property, frame), - EditCommand::MoveKeyframe { - clip_id, - property, - from_frame, - to_frame, - } => move_keyframe(state, clip_id, property, from_frame, to_frame), - EditCommand::SetKeyframeInterpolation { - clip_id, - property, - frame, - interpolation, - } => set_keyframe_interpolation(state, clip_id, property, frame, interpolation), - EditCommand::SetColorGrade { clip_ids, grade } => set_color_grade(state, clip_ids, grade), - EditCommand::SetChromaKey { - clip_ids, - chroma_key, - } => set_chroma_key(state, clip_ids, chroma_key), - EditCommand::SetMasks { clip_ids, masks } => set_masks(state, clip_ids, masks), - EditCommand::SetEffects { clip_ids, effects } => set_effects(state, clip_ids, effects), - EditCommand::RippleDeleteRanges { - track_index, - ranges, - } => ripple_delete_ranges(state, track_index, ranges, ids), - EditCommand::RippleDeleteClips { clip_ids } => ripple_delete_clips(state, clip_ids), - EditCommand::AddTexts { entries } => add_texts(state, entries, ids), - EditCommand::AddTextsAutoTrack { entries } => add_texts_auto_track(state, entries, ids), - EditCommand::AddCaptions { entries } => add_captions(state, entries, ids), - EditCommand::Link { clip_ids } => link(state, clip_ids, ids), - EditCommand::Unlink { clip_ids } => unlink(state, clip_ids), - EditCommand::RemoveTracks { track_indexes } => remove_tracks(state, track_indexes), - EditCommand::SwapTracks { a, b } => swap_tracks(state, a, b), - EditCommand::SwapClips { a, b } => swap_clips(state, a, b), - EditCommand::InsertTrack { kind, at } => insert_track_cmd(state, kind, at, ids), - EditCommand::SetTrackProps { - track_index, - muted, - hidden, - sync_locked, - } => set_track_props(state, track_index, muted, hidden, sync_locked), - EditCommand::CreateFolder { - name, - parent_folder_id, - } => create_folder(state, name, parent_folder_id, ids), - EditCommand::MoveToFolder { - asset_ids, - folder_id, - } => move_to_folder(state, asset_ids, folder_id), - EditCommand::RenameMedia { entries } => rename_media(state, entries), - EditCommand::RenameFolder { entries } => rename_folder(state, entries), - EditCommand::DeleteMedia { asset_ids } => delete_media(state, asset_ids), - EditCommand::DeleteFolder { folder_ids } => delete_folder(state, folder_ids), - EditCommand::SwapMedia { clip_id, media_ref } => swap_media(state, clip_id, media_ref), - EditCommand::ResetTransform { clip_ids } => reset_transform(state, clip_ids), - EditCommand::SetTimelineSettings { fps, width, height } => { - set_timeline_settings_cmd(state, fps, width, height) + + EditCommand::CreateNestedSequence { + name, + timeline, + track_index, + start_frame, + duration_frames, + } => create_nested_sequence( + state, + name, + timeline, + track_index, + start_frame, + duration_frames, + ids, + ), + EditCommand::CreateNestedSequenceFromClips { name, clip_ids } => { + create_nested_sequence_from_clips(state, name, clip_ids, ids) + } + EditCommand::EditNestedSequence { + sequence_id, + command, + } => edit_nested_sequence(state, sequence_id, *command, ids), + EditCommand::SetNestedSequenceTimeline { + sequence_id, + timeline, + } => set_nested_sequence_timeline(state, sequence_id, timeline), + EditCommand::RenameNestedSequence { sequence_id, name } => { + rename_nested_sequence(state, sequence_id, name) + } + EditCommand::DissolveNestedSequence { clip_id } => { + dissolve_nested_sequence(state, clip_id, ids) + } + EditCommand::PlaceMedia { + sequence_id, + settings, + target, + entry, + } => place_media(state, sequence_id, settings, target, entry, ids), + EditCommand::AddClips { entries } => add_clips(state, entries, ids), + EditCommand::AddClipsAutoTrack { entries } => add_clips_auto_track(state, entries, ids), + EditCommand::AddClipsToSeparateAutoTracks { entries } => { + add_clips_to_separate_auto_tracks(state, entries, ids) + } + EditCommand::RegisterMediaAndAddClip { + media, + entry, + auto_track, + } => register_media_and_add_clip(state, media, entry, auto_track, ids), + EditCommand::RegisterMediaAndSwapClip { media, clip_id } => { + register_media_and_swap_clip(state, media, clip_id, false, ids) + } + EditCommand::RegisterMediaAndSwapClipClearingMasks { media, clip_id } => { + register_media_and_swap_clip(state, media, clip_id, true, ids) + } + EditCommand::RegisterMediaAndFreezeFrame { + media, + clip_id, + at_frame, + duration_frames, + } => register_media_and_freeze_frame(state, media, clip_id, at_frame, duration_frames, ids), + EditCommand::InsertClips { + track_index, + at_frame, + entries, + } => insert_clips(state, track_index, at_frame, entries, ids), + EditCommand::MoveClips { moves } => move_clips(state, moves, ids), + EditCommand::DuplicateClips { + clip_ids, + offset_frames, + target_track_indexes, + } => duplicate_clips_cmd(state, clip_ids, offset_frames, target_track_indexes, ids), + EditCommand::MoveOrDuplicateClipsToNewTrack { + clip_ids, + lead_clip_id, + requested_frame_delta, + insert_at, + mode, + } => move_or_duplicate_clips_to_new_track( + state, + clip_ids, + lead_clip_id, + requested_frame_delta, + insert_at, + mode, + ids, + ), + EditCommand::PasteClips { entries } => paste_clips(state, entries, ids), + EditCommand::RemoveClips { clip_ids } => remove_clips(state, clip_ids), + EditCommand::SplitClip { clip_id, at_frame } => split(state, clip_id, at_frame, ids), + EditCommand::SplitClips { clip_ids, at_frame } => { + split_clips(state, clip_ids, at_frame, ids) + } + EditCommand::FreezeFrame { + clip_id, + at_frame, + duration_frames, + media_ref, + } => freeze_frame(state, clip_id, at_frame, duration_frames, media_ref, ids), + EditCommand::TrimClips { edits } => trim(state, edits), + EditCommand::SetClipProperties { + clip_ids, + properties, + } => set_clip_properties(state, clip_ids, *properties), + EditCommand::SetClipPropertiesPerClip { assignments } => { + set_clip_properties_per_clip(state, assignments) + } + EditCommand::SetTransformAtFrame { + clip_id, + frame, + transform, + } => set_transform_at_frame(state, clip_id, frame, transform), + EditCommand::SetKeyframes { + clip_id, + property, + payload, + } => set_keyframes(state, clip_id, property, payload), + EditCommand::StampKeyframe { + clip_id, + property, + frame, + } => stamp_keyframe(state, clip_id, property, frame), + EditCommand::UpsertKeyframe { + clip_id, + property, + frame, + value, + } => upsert_keyframe(state, clip_id, property, frame, value), + EditCommand::RemoveKeyframe { + clip_id, + property, + frame, + } => remove_keyframe(state, clip_id, property, frame), + EditCommand::MoveKeyframe { + clip_id, + property, + from_frame, + to_frame, + } => move_keyframe(state, clip_id, property, from_frame, to_frame), + EditCommand::SetKeyframeInterpolation { + clip_id, + property, + frame, + interpolation, + } => set_keyframe_interpolation(state, clip_id, property, frame, interpolation), + EditCommand::SetColorGrade { clip_ids, grade } => set_color_grade(state, clip_ids, grade), + EditCommand::ApplyColorMatch { + clip_id, + grade, + input, + } => apply_color_match(state, clip_id, grade, input), + EditCommand::SetLut { clip_ids, lut } => set_lut(state, clip_ids, lut), + EditCommand::SetChromaKey { + clip_ids, + chroma_key, + } => set_chroma_key(state, clip_ids, chroma_key), + EditCommand::SetMasks { clip_ids, masks } => set_masks(state, clip_ids, masks), + EditCommand::SetEffects { clip_ids, effects } => set_effects(state, clip_ids, effects), + EditCommand::SetLoudnessNormalization { + clip_id, + normalization, + } => set_loudness_normalization(state, clip_id, normalization), + EditCommand::SetAudioDenoise { clip_id, denoise } => { + set_audio_denoise(state, clip_id, denoise) + } + EditCommand::ApplyStabilization { clip_id, solution } => { + apply_stabilization(state, clip_id, solution) + } + EditCommand::AdjustStabilization { + clip_id, + strength, + crop_margin, + } => adjust_stabilization(state, clip_id, strength, crop_margin), + EditCommand::ResetStabilization { clip_id } => reset_stabilization(state, clip_id), + EditCommand::SetTransition { + from_clip_id, + to_clip_id, + kind, + duration_frames, + } => set_transition(state, from_clip_id, to_clip_id, kind, duration_frames), + EditCommand::RippleDeleteRanges { + track_index, + ranges, + } => ripple_delete_ranges(state, track_index, ranges, ids), + EditCommand::RippleDeleteClips { clip_ids } => ripple_delete_clips(state, clip_ids), + EditCommand::AddTexts { entries } => add_texts(state, entries, ids), + EditCommand::AddTextsAutoTrack { entries } => add_texts_auto_track(state, entries, ids), + EditCommand::AddCaptions { entries } => add_captions(state, entries, ids), + EditCommand::ApplyCaptionTranslations { changes } => { + apply_caption_translations(state, changes) + } + EditCommand::SaveScriptAssemblyPlan { plan } => save_script_assembly_plan(state, plan), + EditCommand::ApplyScriptAssemblyPlan { plan_id } => { + apply_script_assembly_plan(state, plan_id, ids) + } + EditCommand::SaveVoiceModel { record } => save_voice_model(state, record), + EditCommand::RevokeVoiceModel { voice_model_id } => { + revoke_voice_model(state, voice_model_id) + } + EditCommand::Link { clip_ids } => link(state, clip_ids, ids), + EditCommand::Unlink { clip_ids } => unlink(state, clip_ids), + EditCommand::RemoveTracks { track_indexes } => remove_tracks(state, track_indexes), + EditCommand::SwapTracks { a, b } => swap_tracks(state, a, b), + EditCommand::SwapClips { a, b } => swap_clips(state, a, b), + EditCommand::InsertTrack { kind, at } => insert_track_cmd(state, kind, at, ids), + EditCommand::SetTrackProps { + track_index, + muted, + hidden, + sync_locked, + } => set_track_props(state, track_index, muted, hidden, sync_locked), + EditCommand::CreateFolder { + name, + parent_folder_id, + } => create_folder(state, name, parent_folder_id, ids), + EditCommand::MoveToFolder { + asset_ids, + folder_id, + } => move_to_folder(state, asset_ids, folder_id), + EditCommand::RenameMedia { entries } => rename_media(state, entries), + EditCommand::RenameFolder { entries } => rename_folder(state, entries), + EditCommand::DeleteMedia { asset_ids } => delete_media(state, asset_ids), + EditCommand::DeleteFolder { folder_ids } => delete_folder(state, folder_ids), + EditCommand::SwapMedia { clip_id, media_ref } => swap_media(state, clip_id, media_ref), + EditCommand::ResetTransform { clip_ids } => reset_transform(state, clip_ids), + EditCommand::SetTimelineSettings { fps, width, height } => { + set_timeline_settings_cmd(state, fps, width, height) + } + } +} + +fn create_nested_sequence( + state: &mut EditorState, + name: String, + timeline: Timeline, + track_index: usize, + start_frame: i32, + duration_frames: i32, + ids: &dyn IdGen, +) -> Result { + let name = name.trim().to_string(); + if name.is_empty() { + return Err(EditError::Invalid( + "nested sequence name must not be empty".into(), + )); + } + if track_index >= state.timeline.tracks.len() { + return Err(EditError::Invalid(format!( + "track index out of range: {track_index}" + ))); + } + if state.timeline.tracks[track_index].kind == ClipType::Audio { + return Err(EditError::Invalid( + "a compound clip requires a visual track".into(), + )); + } + let end_frame = checked_frame_arithmetic(start_frame, duration_frames, 0, 0, 1.0, "compound")?; + if !timeline.nested_sequences.is_empty() { + return Err(EditError::Invalid( + "child timelines must reference the root nested sequence registry".into(), + )); + } + validate_timeline_frame_arithmetic(&timeline, "childTimeline")?; + + transact( + state, + "Create Compound Clip", + |affected| format!("Created compound clip {}", affected.join(", ")), + |st| { + let sequence_id = ids.next_id(); + let clip_id = ids.next_id(); + st.timeline.nested_sequences.push(NestedSequence::new( + sequence_id.clone(), + name, + timeline, + )); + ops::clear_region( + &mut st.timeline, + track_index, + start_frame, + end_frame, + false, + ids, + ); + st.timeline.tracks[track_index].clips.push(Clip::new_nested( + clip_id.clone(), + sequence_id, + start_frame, + duration_frames, + )); + ops::sort_clips(&mut st.timeline.tracks[track_index]); + Ok(vec![clip_id]) + }, + ) +} + +fn create_nested_sequence_from_clips( + state: &mut EditorState, + name: String, + clip_ids: Vec, + ids: &dyn IdGen, +) -> Result { + let name = name.trim().to_string(); + if name.is_empty() { + return Err(EditError::Invalid( + "nested sequence name must not be empty".into(), + )); + } + if clip_ids.is_empty() { + return Err(EditError::Invalid( + "at least one clip is required to create a compound".into(), + )); + } + let requested: HashSet = clip_ids.iter().cloned().collect(); + if requested.len() != clip_ids.len() { + return Err(EditError::Invalid( + "compound clip selection contains duplicate ids".into(), + )); + } + // Keep linked A/V partners inside the same edit boundary. Leaving one half + // at root would create a link group spanning independent timelines, which + // child edit commands cannot preserve safely. + let selected = ops::expand_to_link_group(&state.timeline, &requested); + + let mut start_frame = i32::MAX; + let mut end_frame = i32::MIN; + let mut target_track = None; + let mut child = Timeline::new(); + child.fps = state.timeline.fps; + child.width = state.timeline.width; + child.height = state.timeline.height; + child.settings_configured = state.timeline.settings_configured; + for (track_index, track) in state.timeline.tracks.iter().enumerate() { + let clips: Vec = track + .clips + .iter() + .filter(|clip| selected.contains(&clip.id)) + .cloned() + .collect(); + if clips.is_empty() { + continue; + } + if target_track.is_none() && track.kind != ClipType::Audio { + target_track = Some(track_index); + } + for clip in &clips { + start_frame = start_frame.min(clip.start_frame); + end_frame = end_frame.max(clip.end_frame()); + } + // Track ids are allocated only after every span/output preflight has + // succeeded, inside the transaction below. + let mut child_track = Track::new("", track.kind); + child_track.muted = track.muted; + child_track.hidden = track.hidden; + child_track.sync_locked = track.sync_locked; + child_track.clips = clips; + child.tracks.push(child_track); + } + let found: usize = child.tracks.iter().map(|track| track.clips.len()).sum(); + if found != selected.len() { + return Err(EditError::Invalid( + "one or more clips selected for the compound no longer exist".into(), + )); + } + let target_track = target_track.ok_or_else(|| { + EditError::Invalid("a compound clip requires at least one visual clip".into()) + })?; + if state.timeline.tracks[target_track] + .clips + .iter() + .any(|clip| { + !selected.contains(&clip.id) + && clip.start_frame < end_frame + && start_frame < clip.end_frame() + }) + { + return Err(EditError::Invalid( + "compound selection span overlaps an unselected clip on its destination track".into(), + )); + } + for track in &mut child.tracks { + for clip in &mut track.clips { + clip.start_frame = clip.start_frame.checked_sub(start_frame).ok_or_else(|| { + EditError::Invalid(format!( + "clip {}: compound-relative start overflows", + clip.id + )) + })?; + } + } + let duration_frames = end_frame + .checked_sub(start_frame) + .ok_or_else(|| EditError::Invalid("compound selection duration overflows".into()))?; + checked_frame_arithmetic( + start_frame, + duration_frames, + 0, + 0, + 1.0, + "compound selection", + )?; + validate_timeline_frame_arithmetic(&child, "compound child")?; + + transact( + state, + "Create Compound Clip", + |affected| format!("Created compound clip {}", affected.join(", ")), + |st| { + for track in &mut child.tracks { + track.id = ids.next_id(); + } + for track in &mut st.timeline.tracks { + track.clips.retain(|clip| !selected.contains(&clip.id)); + } + let sequence_id = ids.next_id(); + let compound_id = ids.next_id(); + st.timeline.nested_sequences.push(NestedSequence::new( + sequence_id.clone(), + name, + child, + )); + ops::clear_region( + &mut st.timeline, + target_track, + start_frame, + end_frame, + false, + ids, + ); + st.timeline.tracks[target_track] + .clips + .push(Clip::new_nested( + compound_id.clone(), + sequence_id, + start_frame, + duration_frames, + )); + ops::sort_clips(&mut st.timeline.tracks[target_track]); + ops::prune_empty_tracks(&mut st.timeline); + Ok(vec![compound_id]) + }, + ) +} + +fn edit_nested_sequence( + state: &mut EditorState, + sequence_id: String, + command: EditCommand, + ids: &dyn IdGen, +) -> Result { + if matches!( + &command, + EditCommand::Undo + | EditCommand::Redo + | EditCommand::CreateNestedSequence { .. } + | EditCommand::CreateNestedSequenceFromClips { .. } + | EditCommand::EditNestedSequence { .. } + | EditCommand::SetNestedSequenceTimeline { .. } + | EditCommand::RenameNestedSequence { .. } + | EditCommand::DissolveNestedSequence { .. } + | EditCommand::PlaceMedia { .. } + | EditCommand::CreateFolder { .. } + | EditCommand::MoveToFolder { .. } + | EditCommand::RenameMedia { .. } + | EditCommand::RenameFolder { .. } + | EditCommand::DeleteMedia { .. } + | EditCommand::DeleteFolder { .. } + | EditCommand::SetTimelineSettings { .. } + | EditCommand::SaveScriptAssemblyPlan { .. } + | EditCommand::ApplyScriptAssemblyPlan { .. } + | EditCommand::SaveVoiceModel { .. } + | EditCommand::RevokeVoiceModel { .. } + ) { + return Err(EditError::Invalid( + "nested-sequence, media-library, and project-settings commands must target the root timeline".into(), + )); + } + let child = state + .timeline + .nested_sequences + .iter() + .find(|sequence| sequence.id == sequence_id) + .map(|sequence| sequence.timeline.clone()) + .ok_or_else(|| EditError::Invalid(format!("Nested sequence not found: {sequence_id}")))?; + transact( + state, + "Edit Compound Clip", + |_| format!("Edited nested sequence {sequence_id}"), + |st| { + // Supply the root registry during the inner transaction so child + // references resolve against the same identities as preview/export. + // The target sequence's stored (pre-edit) contents are blanked in + // this temporary view: `editable` already represents those clips, + // and counting both copies would manufacture duplicate clip ids. + // The enclosing root transaction validates the fully replaced graph + // (including cycles through this sequence) before it can commit. + let mut editable = child; + editable.nested_sequences = st.timeline.nested_sequences.clone(); + editable + .nested_sequences + .iter_mut() + .find(|sequence| sequence.id == sequence_id) + .expect("sequence was resolved before transaction") + .timeline = Timeline::new(); + let mut child_state = EditorState::new(editable, st.manifest.clone()); + let inner = apply(&mut child_state, command, ids)?; + child_state.timeline.nested_sequences.clear(); + let sequence = st + .timeline + .nested_sequences + .iter_mut() + .find(|sequence| sequence.id == sequence_id) + .expect("sequence was resolved before transaction"); + sequence.timeline = child_state.timeline; + st.manifest = child_state.manifest; + Ok(inner.affected_clip_ids) + }, + ) +} + +fn set_nested_sequence_timeline( + state: &mut EditorState, + sequence_id: String, + timeline: Timeline, +) -> Result { + if !timeline.nested_sequences.is_empty() { + return Err(EditError::Invalid( + "child timelines must reference the root nested sequence registry".into(), + )); + } + validate_timeline_frame_arithmetic(&timeline, "childTimeline")?; + transact( + state, + "Edit Compound Clip", + |_| format!("Edited nested sequence {sequence_id}"), + |st| { + let sequence = st + .timeline + .nested_sequences + .iter_mut() + .find(|sequence| sequence.id == sequence_id) + .ok_or_else(|| { + EditError::Invalid(format!("Nested sequence not found: {sequence_id}")) + })?; + sequence.timeline = timeline; + Ok(Vec::new()) + }, + ) +} + +fn rename_nested_sequence( + state: &mut EditorState, + sequence_id: String, + name: String, +) -> Result { + let name = name.trim().to_string(); + if name.is_empty() { + return Err(EditError::Invalid( + "nested sequence name must not be empty".into(), + )); + } + transact( + state, + "Rename Compound Clip", + |_| format!("Renamed nested sequence {sequence_id}"), + |st| { + let sequence = st + .timeline + .nested_sequences + .iter_mut() + .find(|sequence| sequence.id == sequence_id) + .ok_or_else(|| { + EditError::Invalid(format!("Nested sequence not found: {sequence_id}")) + })?; + sequence.name = name; + Ok(Vec::new()) + }, + ) +} + +fn dissolve_nested_sequence( + state: &mut EditorState, + clip_id: String, + ids: &dyn IdGen, +) -> Result { + let location = state + .find_clip(&clip_id) + .ok_or_else(|| EditError::Invalid(format!("Clip not found: {clip_id}")))?; + let compound = state.timeline.tracks[location.track_index].clips[location.clip_index].clone(); + let sequence_id = compound + .nested_sequence_id + .as_deref() + .ok_or_else(|| EditError::Invalid(format!("Clip is not a compound clip: {clip_id}")))?; + if (compound.speed - 1.0).abs() > f64::EPSILON || compound.reversed { + return Err(EditError::Invalid( + "retimed or reversed compound clips must be normalized before dissolve".into(), + )); + } + let has_parent_edits = (compound.volume - 1.0).abs() > f64::EPSILON + || compound.fade_in_frames != 0 + || compound.fade_out_frames != 0 + || (compound.opacity - 1.0).abs() > f64::EPSILON + || compound.transform != Transform::default() + || compound.crop != Crop::default() + || compound.link_group_id.is_some() + || compound.caption_group_id.is_some() + || compound.text_content.is_some() + || compound.text_style.is_some() + || compound.opacity_track.is_some() + || compound.position_track.is_some() + || compound.scale_track.is_some() + || compound.rotation_track.is_some() + || compound.crop_track.is_some() + || compound.volume_track.is_some() + || compound.color_grade.is_some() + || compound.chroma_key.is_some() + || !compound.masks.is_empty() + || !compound.effects.is_empty() + || compound.transition_out.is_some(); + if has_parent_edits { + return Err(EditError::Invalid( + "compound clips with parent-level edits must be normalized before dissolve".into(), + )); + } + let child = state + .timeline + .nested_sequences + .iter() + .find(|sequence| sequence.id == sequence_id) + .map(|sequence| sequence.timeline.clone()) + .ok_or_else(|| EditError::Invalid(format!("Nested sequence not found: {sequence_id}")))?; + let source_start = compound.trim_start_frame; + let source_end = source_start + .checked_add(compound.duration_frames) + .ok_or_else(|| EditError::Invalid("compound source span overflows".into()))?; + for child_clip in child.tracks.iter().flat_map(|track| &track.clips) { + let child_end = child_clip + .start_frame + .checked_add(child_clip.duration_frames) + .expect("child arithmetic was validated at apply entry"); + let visible_start = child_clip.start_frame.max(source_start); + let visible_end = child_end.min(source_end); + if visible_end <= visible_start { + continue; + } + let clipped_left = visible_start + .checked_sub(child_clip.start_frame) + .ok_or_else(|| EditError::Invalid("dissolve left clip delta overflows".into()))?; + let clipped_right = child_end + .checked_sub(visible_end) + .ok_or_else(|| EditError::Invalid("dissolve right clip delta overflows".into()))?; + let relative_start = visible_start + .checked_sub(source_start) + .ok_or_else(|| EditError::Invalid("dissolve relative start overflows".into()))?; + let output_start = compound + .start_frame + .checked_add(relative_start) + .ok_or_else(|| EditError::Invalid("dissolve output start overflows".into()))?; + let output_duration = visible_end + .checked_sub(visible_start) + .ok_or_else(|| EditError::Invalid("dissolve output duration overflows".into()))?; + let left_source = (clipped_left as f64 * child_clip.speed).round(); + let right_source = (clipped_right as f64 * child_clip.speed).round(); + if !(0.0..=i32::MAX as f64).contains(&left_source) + || !(0.0..=i32::MAX as f64).contains(&right_source) + { + return Err(EditError::Invalid( + "dissolve source trim delta is out of range".into(), + )); } + let trim_start = child_clip + .trim_start_frame + .checked_add(left_source as i32) + .ok_or_else(|| EditError::Invalid("dissolve trimStart overflows".into()))?; + let trim_end = child_clip + .trim_end_frame + .checked_add(right_source as i32) + .ok_or_else(|| EditError::Invalid("dissolve trimEnd overflows".into()))?; + checked_clip_frame_arithmetic( + output_start, + output_duration, + trim_start, + trim_end, + child_clip.speed, + child_clip.media_type, + &format!("dissolved clip {}", child_clip.id), + )?; } + + transact( + state, + "Dissolve Compound Clip", + |affected| format!("Dissolved compound into {} clip(s)", affected.len()), + |st| { + let mut id_map = HashMap::new(); + let mut link_counts: HashMap = HashMap::new(); + for child_clip in child.tracks.iter().flat_map(|track| &track.clips) { + let visible_start = child_clip.start_frame.max(source_start); + let child_end = child_clip + .start_frame + .checked_add(child_clip.duration_frames) + .expect("dissolve child span was prevalidated"); + let visible_end = child_end.min(source_end); + if visible_end <= visible_start { + continue; + } + id_map.insert(child_clip.id.clone(), ids.next_id()); + if let Some(group) = &child_clip.link_group_id { + *link_counts.entry(group.clone()).or_default() += 1; + } + } + let mut link_map: HashMap = HashMap::new(); + + ops::clear_region::remove_clip(&mut st.timeline, &clip_id); + let mut affected = Vec::new(); + + for child_track in child.tracks { + let requested = st.timeline.tracks.len(); + let target = ops::insert_track(&mut st.timeline, requested, child_track.kind, ids); + st.timeline.tracks[target].muted = child_track.muted; + st.timeline.tracks[target].hidden = child_track.hidden; + st.timeline.tracks[target].sync_locked = child_track.sync_locked; + + for mut child_clip in child_track.clips { + let visible_start = child_clip.start_frame.max(source_start); + let child_end = child_clip + .start_frame + .checked_add(child_clip.duration_frames) + .expect("dissolve child span was prevalidated"); + let visible_end = child_end.min(source_end); + if visible_end <= visible_start { + continue; + } + let clipped_left = visible_start - child_clip.start_frame; + let clipped_right = child_end + .checked_sub(visible_end) + .expect("dissolve right delta was prevalidated"); + let old_id = child_clip.id.clone(); + child_clip.id = id_map + .get(&old_id) + .expect("visible child clip received a replacement id") + .clone(); + child_clip.link_group_id = child_clip.link_group_id.take().and_then(|group| { + (link_counts.get(&group).copied().unwrap_or(0) > 1).then(|| { + link_map + .entry(group) + .or_insert_with(|| ids.next_id()) + .clone() + }) + }); + child_clip.transition_out = + child_clip.transition_out.take().and_then(|mut transition| { + id_map.get(&transition.to_clip_id).map(|to_id| { + transition.from_clip_id = child_clip.id.clone(); + transition.to_clip_id = to_id.clone(); + transition + }) + }); + child_clip.start_frame = compound + .start_frame + .checked_add( + visible_start + .checked_sub(source_start) + .expect("dissolve relative start was prevalidated"), + ) + .expect("dissolve output start was prevalidated"); + child_clip.duration_frames = visible_end + .checked_sub(visible_start) + .expect("dissolve duration was prevalidated"); + child_clip.trim_start_frame = child_clip + .trim_start_frame + .checked_add((clipped_left as f64 * child_clip.speed).round() as i32) + .expect("dissolve trimStart was prevalidated"); + child_clip.trim_end_frame = child_clip + .trim_end_frame + .checked_add((clipped_right as f64 * child_clip.speed).round() as i32) + .expect("dissolve trimEnd was prevalidated"); + affected.push(child_clip.id.clone()); + st.timeline.tracks[target].clips.push(child_clip); + } + ops::sort_clips(&mut st.timeline.tracks[target]); + } + ops::prune_empty_tracks(&mut st.timeline); + Ok(affected) + }, + ) } // MARK: - Transaction helper @@ -614,6 +2011,10 @@ fn transact( summarize: impl FnOnce(&[String]) -> String, work: impl FnOnce(&mut EditorState) -> Result, EditError>, ) -> Result { + // Every edit eventually traverses the complete root/nested graph while + // pruning transitions. Reject malformed persisted arithmetic before any + // command work can mutate a track or consume an id. + validate_timeline_frame_arithmetic(&state.timeline, "timeline")?; let before = state.snapshot(); let affected = match work(state) { Ok(affected) => affected, @@ -622,12 +2023,21 @@ fn transact( return Err(error); } }; + if let Err(error) = validate_timeline_frame_arithmetic(&state.timeline, "timeline") { + state.restore(before); + return Err(error); + } + prune_invalid_transitions(&mut state.timeline); + if let Err(reason) = state.timeline.validate_nested_sequences() { + state.restore(before); + return Err(EditError::Invalid(reason)); + } let after = state.snapshot(); let timeline_changed = before.timeline != after.timeline; let manifest_changed = before.manifest != after.manifest; let changed = timeline_changed || manifest_changed; if changed { - state.commit(before); + state.commit(before, action_name); } let summary = summarize(&affected); Ok(result( @@ -640,6 +2050,65 @@ fn transact( )) } +/// Keep transition pair identity aligned with the actual cut graph after every +/// transactional edit. A move/delete/trim must never leave a dormant transition +/// that could later bind to a different neighbor. +fn prune_invalid_transitions(timeline: &mut Timeline) { + for sequence in &mut timeline.nested_sequences { + prune_invalid_transitions(&mut sequence.timeline); + } + for track in &mut timeline.tracks { + if track.kind == ClipType::Audio { + for clip in &mut track.clips { + clip.transition_out = None; + } + continue; + } + let mut order: Vec = (0..track.clips.len()).collect(); + order.sort_by_key(|&index| { + ( + track.clips[index].start_frame, + track.clips[index].id.clone(), + ) + }); + let mut valid: HashMap = HashMap::new(); + for pair in order.windows(2) { + let from = &track.clips[pair[0]]; + let to = &track.clips[pair[1]]; + if from.end_frame() != to.start_frame + || matches!(from.media_type, ClipType::Audio | ClipType::Text) + || matches!(to.media_type, ClipType::Audio | ClipType::Text) + { + continue; + } + valid.insert( + from.id.clone(), + ( + to.id.clone(), + (from.duration_frames.min(to.duration_frames) / 2).max(1), + ), + ); + } + for clip in &mut track.clips { + let Some(transition) = &mut clip.transition_out else { + continue; + }; + let Some((to_id, maximum)) = valid.get(&clip.id) else { + clip.transition_out = None; + continue; + }; + if (!transition.from_clip_id.is_empty() && transition.from_clip_id != clip.id) + || transition.to_clip_id != *to_id + { + clip.transition_out = None; + continue; + } + transition.from_clip_id = clip.id.clone(); + transition.duration_frames = transition.duration_frames.clamp(1, *maximum); + } + } +} + fn result( state: &EditorState, timeline_changed: bool, @@ -690,10 +2159,427 @@ mod transaction_tests { assert!(!state.can_undo()); assert!(!state.can_redo()); } + + #[test] + fn undo_and_redo_refuse_invalid_history_snapshots_atomically() { + let ids = crate::id::SeqIdGen::new("history-preflight-"); + + // Inject an invalid snapshot as the next undo target while keeping the + // live document valid. Public `apply(Undo)` must validate the cloned + // history candidate before replacing the live state. + let mut undo_state = EditorState::default(); + let mut invalid = undo_state.snapshot(); + let mut invalid_track = Track::new("invalid", ClipType::Video); + invalid_track + .clips + .push(Clip::new("overflow", "asset", i32::MAX, 1)); + invalid.timeline.tracks.push(invalid_track); + undo_state.commit(invalid, "Injected Invalid Undo"); + let before_undo = undo_state.clone(); + + let undo_result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + apply(&mut undo_state, EditCommand::Undo, &ids) + })); + assert!(undo_result.is_ok()); + assert!(undo_result.unwrap().is_err()); + assert_eq!(undo_state.snapshot(), before_undo.snapshot()); + assert_eq!(undo_state.version(), before_undo.version()); + assert_eq!(undo_state.undo_depth(), before_undo.undo_depth()); + assert_eq!(undo_state.can_redo(), before_undo.can_redo()); + + // Build the mirror case: a direct test-only undo moves an invalid live + // document onto redo, leaving the current document valid. Public redo + // must reject that candidate without consuming history or ids. + let mut redo_state = EditorState::default(); + let valid = redo_state.snapshot(); + redo_state.timeline.tracks.push({ + let mut track = Track::new("invalid", ClipType::Video); + track + .clips + .push(Clip::new("overflow", "asset", i32::MAX, 1)); + track + }); + redo_state.commit(valid, "Injected Invalid Redo"); + assert!(redo_state.undo()); + let before_redo = redo_state.clone(); + + let redo_result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + apply(&mut redo_state, EditCommand::Redo, &ids) + })); + assert!(redo_result.is_ok()); + assert!(redo_result.unwrap().is_err()); + assert_eq!(redo_state.snapshot(), before_redo.snapshot()); + assert_eq!(redo_state.version(), before_redo.version()); + assert_eq!(redo_state.undo_depth(), before_redo.undo_depth()); + assert_eq!(redo_state.can_redo(), before_redo.can_redo()); + assert_eq!(ids.count(), 0); + } } // MARK: - Command implementations +fn checked_frame_arithmetic( + start_frame: i32, + duration_frames: i32, + trim_start_frame: i32, + trim_end_frame: i32, + speed: f64, + label: &str, +) -> Result { + if start_frame < 0 || duration_frames < 1 { + return Err(EditError::Invalid(format!( + "{label}: startFrame must be >= 0 and durationFrames >= 1" + ))); + } + if !speed.is_finite() || speed <= 0.0 { + return Err(EditError::Invalid(format!( + "{label}: speed must be finite and > 0" + ))); + } + let end_frame = start_frame.checked_add(duration_frames).ok_or_else(|| { + EditError::Invalid(format!("{label}: startFrame + durationFrames overflows")) + })?; + duration_frames + .checked_add(trim_start_frame) + .and_then(|value| value.checked_add(trim_end_frame)) + .ok_or_else(|| { + EditError::Invalid(format!("{label}: durationFrames + trim frames overflows")) + })?; + let consumed = (duration_frames as f64 * speed).round(); + if !(0.0..=i32::MAX as f64).contains(&consumed) { + return Err(EditError::Invalid(format!( + "{label}: visible source-frame extent is out of range" + ))); + } + let consumed = consumed as i32; + trim_start_frame.checked_add(consumed).ok_or_else(|| { + EditError::Invalid(format!("{label}: trimStart source-frame extent overflows")) + })?; + trim_end_frame.checked_add(consumed).ok_or_else(|| { + EditError::Invalid(format!("{label}: trimEnd source-frame extent overflows")) + })?; + trim_start_frame + .checked_add(consumed) + .and_then(|value| value.checked_add(trim_end_frame)) + .ok_or_else(|| EditError::Invalid(format!("{label}: source-frame extent overflows")))?; + Ok(end_frame) +} + +fn checked_clip_frame_arithmetic( + start_frame: i32, + duration_frames: i32, + trim_start_frame: i32, + trim_end_frame: i32, + speed: f64, + media_type: ClipType, + label: &str, +) -> Result { + if !matches!(media_type, ClipType::Image | ClipType::Text) + && (trim_start_frame < 0 || trim_end_frame < 0) + { + return Err(EditError::Invalid(format!( + "{label}: trim frames must be >= 0 for audio/video clips" + ))); + } + checked_frame_arithmetic( + start_frame, + duration_frames, + trim_start_frame, + trim_end_frame, + speed, + label, + ) +} + +fn validate_clip_frame_arithmetic_at( + clip: &Clip, + start_frame: i32, + label: &str, +) -> Result { + checked_clip_frame_arithmetic( + start_frame, + clip.duration_frames, + clip.trim_start_frame, + clip.trim_end_frame, + clip.speed, + clip.media_type, + label, + ) +} + +fn validate_clip_frame_arithmetic(clip: &Clip, label: &str) -> Result { + validate_clip_frame_arithmetic_at(clip, clip.start_frame, label) +} + +fn validate_timeline_frame_arithmetic(timeline: &Timeline, label: &str) -> Result<(), EditError> { + for (track_index, track) in timeline.tracks.iter().enumerate() { + for (clip_index, clip) in track.clips.iter().enumerate() { + validate_clip_frame_arithmetic( + clip, + &format!("{label}.tracks[{track_index}].clips[{clip_index}]"), + )?; + } + } + for (sequence_index, sequence) in timeline.nested_sequences.iter().enumerate() { + validate_timeline_frame_arithmetic( + &sequence.timeline, + &format!("{label}.nestedSequences[{sequence_index}].timeline"), + )?; + } + Ok(()) +} + +fn validate_settings_frame_projection( + timeline: &Timeline, + fps: i32, + label: &str, +) -> Result<(), EditError> { + for (sequence_index, sequence) in timeline.nested_sequences.iter().enumerate() { + validate_settings_frame_projection( + &sequence.timeline, + fps, + &format!("{label}.nestedSequences[{sequence_index}].timeline"), + )?; + } + if timeline.fps <= 0 || timeline.fps == fps { + return Ok(()); + } + let scale = fps as f64 / timeline.fps as f64; + for (track_index, track) in timeline.tracks.iter().enumerate() { + let mut order: Vec = (0..track.clips.len()).collect(); + order.sort_by_key(|&index| track.clips[index].start_frame); + let mut previous_end = None; + for clip_index in order { + let clip = &track.clips[clip_index]; + let source_end = clip + .start_frame + .checked_add(clip.duration_frames) + .ok_or_else(|| { + EditError::Invalid(format!( + "{label}.tracks[{track_index}].clips[{clip_index}]: source end overflows" + )) + })?; + let scaled_start = (clip.start_frame as f64 * scale).round() as i32; + let scaled_end = (source_end as f64 * scale).round() as i32; + let start_frame = scaled_start.max(previous_end.unwrap_or(scaled_start)); + let duration_frames = scaled_end + .checked_sub(start_frame) + .ok_or_else(|| { + EditError::Invalid(format!( + "{label}.tracks[{track_index}].clips[{clip_index}]: projected duration overflows" + )) + })? + .max(1); + let trim_start_frame = (clip.trim_start_frame as f64 * scale).round() as i32; + let trim_end_frame = (clip.trim_end_frame as f64 * scale).round() as i32; + previous_end = Some(checked_clip_frame_arithmetic( + start_frame, + duration_frames, + trim_start_frame, + trim_end_frame, + clip.speed, + clip.media_type, + &format!("{label}.tracks[{track_index}].clips[{clip_index}] projected"), + )?); + } + } + Ok(()) +} + +fn validate_unplaced_entry(entry: &UnplacedClipEntry, label: &str) -> Result<(), EditError> { + checked_clip_frame_arithmetic( + entry.start_frame, + entry.duration_frames, + entry.trim_start_frame.unwrap_or(0), + entry.trim_end_frame.unwrap_or(0), + 1.0, + entry.media_type, + label, + )?; + Ok(()) +} + +fn validate_place_media_manifest( + state: &EditorState, + entry: &UnplacedClipEntry, +) -> Result<(), EditError> { + let media = state + .manifest + .entries + .iter() + .find(|media| media.id == entry.media_ref) + .ok_or_else(|| { + EditError::Invalid(format!("Media not found in manifest: {}", entry.media_ref)) + })?; + if entry.source_clip_type != media.kind { + return Err(EditError::Invalid(format!( + "sourceClipType {:?} does not match manifest type {:?}", + entry.source_clip_type, media.kind + ))); + } + if entry.media_type != media.kind { + return Err(EditError::Invalid(format!( + "mediaType {:?} does not match manifest type {:?}", + entry.media_type, media.kind + ))); + } + let manifest_has_audio = media.has_audio.unwrap_or(false); + if entry.has_audio != manifest_has_audio { + return Err(EditError::Invalid(format!( + "hasAudio {} does not match manifest value {}", + entry.has_audio, manifest_has_audio + ))); + } + if entry.add_linked_audio + && !(media.kind == ClipType::Video + && manifest_has_audio + && entry.media_type == ClipType::Video) + { + return Err(EditError::Invalid( + "linked audio requires a video manifest with an audio stream".into(), + )); + } + Ok(()) +} + +fn timeline_for_sequence_mut<'a>( + timeline: &'a mut Timeline, + sequence_id: Option<&str>, +) -> Result<&'a mut Timeline, EditError> { + let Some(sequence_id) = sequence_id else { + return Ok(timeline); + }; + timeline + .nested_sequences + .iter_mut() + .find(|sequence| sequence.id == sequence_id) + .map(|sequence| &mut sequence.timeline) + .ok_or_else(|| EditError::Invalid(format!("Nested sequence not found: {sequence_id}"))) +} + +fn timeline_for_sequence<'a>( + timeline: &'a Timeline, + sequence_id: Option<&str>, +) -> Result<&'a Timeline, EditError> { + let Some(sequence_id) = sequence_id else { + return Ok(timeline); + }; + timeline + .nested_sequences + .iter() + .find(|sequence| sequence.id == sequence_id) + .map(|sequence| &sequence.timeline) + .ok_or_else(|| EditError::Invalid(format!("Nested sequence not found: {sequence_id}"))) +} + +fn place_media( + state: &mut EditorState, + sequence_id: Option, + settings: Option, + target: PlaceMediaTarget, + entry: UnplacedClipEntry, + ids: &dyn IdGen, +) -> Result { + validate_unplaced_entry(&entry, "entry")?; + validate_place_media_manifest(state, &entry)?; + if let Some(settings) = settings { + if settings.fps <= 0 || settings.width <= 0 || settings.height <= 0 { + return Err(EditError::Invalid(format!( + "timeline settings must be positive (got fps={}, width={}, height={})", + settings.fps, settings.width, settings.height + ))); + } + } + validate_timeline_frame_arithmetic(&state.timeline, "timeline")?; + if let Some(settings) = settings { + validate_settings_frame_projection(&state.timeline, settings.fps, "timeline")?; + } + let target_timeline = timeline_for_sequence(&state.timeline, sequence_id.as_deref())?; + match &target { + PlaceMediaTarget::ExistingTrack { track_id } => { + let track = target_timeline + .tracks + .iter() + .find(|track| track.id == *track_id) + .ok_or_else(|| EditError::Invalid(format!("Track not found: {track_id}")))?; + if !entry.media_type.is_compatible(track.kind) { + return Err(EditError::Invalid( + "media is not compatible with the destination track".into(), + )); + } + } + PlaceMediaTarget::NewTrack { kind, .. } => { + let expected_kind = if entry.media_type == ClipType::Audio { + ClipType::Audio + } else { + ClipType::Video + }; + if *kind != expected_kind { + return Err(EditError::Invalid(format!( + "new track type {kind:?} is incompatible with media type {:?}", + entry.media_type + ))); + } + } + } + let entry_end = entry + .start_frame + .checked_add(entry.duration_frames) + .expect("entry arithmetic was validated above"); + + transact( + state, + "Place Media", + |affected| format!("Placed media as {} clip(s)", affected.len()), + move |current| { + if let Some(settings) = settings { + ops::set_timeline_settings( + &mut current.timeline, + settings.fps, + settings.width, + settings.height, + ); + } + let target_timeline = + timeline_for_sequence_mut(&mut current.timeline, sequence_id.as_deref())?; + let track_index = match &target { + PlaceMediaTarget::ExistingTrack { track_id } => target_timeline + .tracks + .iter() + .position(|track| track.id == *track_id) + .expect("preflight pinned an existing destination track"), + PlaceMediaTarget::NewTrack { kind, at } => { + let requested = at.unwrap_or(target_timeline.tracks.len()); + ops::insert_track(target_timeline, requested, *kind, ids) + } + }; + let resolved = entry.at_track(track_index); + debug_assert!(resolved + .media_type + .is_compatible(target_timeline.tracks[track_index].kind)); + let track_id = target_timeline.tracks[track_index].id.clone(); + ops::clear_region( + target_timeline, + track_index, + resolved.start_frame, + entry_end, + false, + ids, + ); + let track_index = target_timeline + .tracks + .iter() + .position(|track| track.id == track_id) + .expect("clear without pruning preserves the destination track"); + let affected = + ops::place_clip(target_timeline, &resolved.to_spec(), track_index, None, ids); + debug_assert!(!affected.is_empty()); + ops::prune_empty_tracks(target_timeline); + Ok(affected) + }, + ) +} + fn add_clips( state: &mut EditorState, entries: Vec, @@ -704,9 +2590,11 @@ fn add_clips( "Missing or empty 'entries' array".into(), )); } - for (i, e) in entries.iter().enumerate() { - validate_entry(state, e, i)?; - } + let entry_ends: Vec = entries + .iter() + .enumerate() + .map(|(index, entry)| validate_entry(state, entry, index)) + .collect::>()?; let action_name = if entries.len() == 1 { "Add Clip" } else { @@ -718,18 +2606,11 @@ fn add_clips( |added| format!("Added {} clip(s): {}", added.len(), added.join(", ")), |st| { let mut added = Vec::new(); - for e in &entries { + for (e, end_frame) in entries.iter().zip(&entry_ends) { let track_id = st.timeline.tracks[e.track_index].id.clone(); - // Pin by id: clearRegion may prune/shift indices. - if let Some(ti) = st.track_index(&track_id) { - ops::clear_region( - &mut st.timeline, - ti, - e.start_frame, - e.start_frame + e.duration_frames, - false, - ids, - ); + // Pin by id: clearRegion may prune/shift indices. + if let Some(ti) = st.track_index(&track_id) { + ops::clear_region(&mut st.timeline, ti, e.start_frame, *end_frame, false, ids); } if let Some(ti) = st.track_index(&track_id) { let placed = ops::place_clip(&mut st.timeline, &e.to_spec(), ti, None, ids); @@ -752,9 +2633,11 @@ fn add_clips_auto_track( "Missing or empty 'entries' array".into(), )); } - for (i, e) in entries.iter().enumerate() { - validate_auto_track_entry(e, i)?; - } + let entry_ends: Vec = entries + .iter() + .enumerate() + .map(|(index, entry)| validate_auto_track_entry(entry, index)) + .collect::>()?; let has_visual = entries .iter() .any(|entry| entry.source_clip_type != ClipType::Audio); @@ -780,7 +2663,7 @@ fn add_clips_auto_track( ops::insert_track(&mut st.timeline, at, ClipType::Audio, ids) }); let mut placed = Vec::new(); - for entry in &entries { + for (entry, end_frame) in entries.iter().zip(&entry_ends) { let track_index = if entry.source_clip_type == ClipType::Audio { audio_track_index } else { @@ -795,7 +2678,7 @@ fn add_clips_auto_track( &mut st.timeline, ti, entry.start_frame, - entry.start_frame + entry.duration_frames, + *end_frame, false, ids, ); @@ -815,6 +2698,54 @@ fn add_clips_auto_track( ) } +fn add_clips_to_separate_auto_tracks( + state: &mut EditorState, + entries: Vec, + ids: &dyn IdGen, +) -> Result { + if entries.is_empty() { + return Err(EditError::Invalid( + "Missing or empty 'entries' array".into(), + )); + } + for (index, entry) in entries.iter().enumerate() { + validate_auto_track_entry(entry, index)?; + } + transact( + state, + "Import Stems To Tracks", + |added| { + format!( + "Imported {} aligned stem(s): {}", + added.len(), + added.join(", ") + ) + }, + |current| { + let mut placed = Vec::with_capacity(entries.len()); + for entry in &entries { + let kind = if entry.source_clip_type == ClipType::Audio { + ClipType::Audio + } else { + ClipType::Video + }; + let at = current.timeline.tracks.len(); + let track_index = ops::insert_track(&mut current.timeline, at, kind, ids); + let mut entry = entry.clone(); + entry.track_index = track_index; + placed.extend(ops::place_clip( + &mut current.timeline, + &entry.to_spec(), + track_index, + None, + ids, + )); + } + Ok(placed) + }, + ) +} + fn insert_track_cmd( state: &mut EditorState, kind: ClipType, @@ -887,12 +2818,16 @@ fn swap_tracks(state: &mut EditorState, a: usize, b: usize) -> Result Result { - if state.find_clip(&a).is_none() { - return Err(EditError::Invalid(format!("Clip not found: {a}"))); - } - if state.find_clip(&b).is_none() { - return Err(EditError::Invalid(format!("Clip not found: {b}"))); - } + let a_location = state + .find_clip(&a) + .ok_or_else(|| EditError::Invalid(format!("Clip not found: {a}")))?; + let b_location = state + .find_clip(&b) + .ok_or_else(|| EditError::Invalid(format!("Clip not found: {b}")))?; + let a_clip = &state.timeline.tracks[a_location.track_index].clips[a_location.clip_index]; + let b_clip = &state.timeline.tracks[b_location.track_index].clips[b_location.clip_index]; + validate_clip_frame_arithmetic_at(a_clip, b_clip.start_frame, &format!("clip {a}"))?; + validate_clip_frame_arithmetic_at(b_clip, a_clip.start_frame, &format!("clip {b}"))?; transact( state, "Swap Clips", @@ -904,6 +2839,182 @@ fn swap_clips(state: &mut EditorState, a: String, b: String) -> Result Result<(), EditError> { + if media.id.trim().is_empty() { + return Err(EditError::Invalid( + "generated media id must not be empty".into(), + )); + } + if state + .manifest + .entries + .iter() + .any(|entry| entry.id == media.id) + { + return Err(EditError::Invalid(format!( + "Media already exists: {}", + media.id + ))); + } + if state + .manifest + .entries + .iter() + .any(|entry| entry.source == media.source) + { + return Err(EditError::Invalid( + "generated media source is already registered".into(), + )); + } + Ok(()) +} + +fn register_media_and_add_clip( + state: &mut EditorState, + media: MediaManifestEntry, + entry: ClipEntry, + auto_track: bool, + ids: &dyn IdGen, +) -> Result { + validate_registered_media(state, &media)?; + if media.kind != entry.media_type || media.kind != entry.source_clip_type { + return Err(EditError::Invalid( + "generated media type must match the placed clip type".into(), + )); + } + if entry.media_ref != media.id { + return Err(EditError::Invalid( + "placed clip must reference the generated media id".into(), + )); + } + + // Run the existing placement command against a disposable state so all of + // its overlap, track, duration, and manifest validation remains the single + // source of truth. Only its resulting document is copied into the one outer + // transaction; its temporary undo/version bookkeeping is discarded. + let mut candidate = state.clone(); + candidate.manifest.entries.push(media); + let placement = if auto_track { + add_clips_auto_track(&mut candidate, vec![entry], ids)? + } else { + add_clips(&mut candidate, vec![entry], ids)? + }; + let timeline = candidate.timeline; + let manifest = candidate.manifest; + let affected = placement.affected_clip_ids; + + transact( + state, + "Add Motion Graphic", + |ids| format!("Added motion graphic: {}", ids.join(", ")), + move |current| { + current.timeline = timeline; + current.manifest = manifest; + Ok(affected) + }, + ) +} + +fn register_media_and_swap_clip( + state: &mut EditorState, + media: MediaManifestEntry, + clip_id: String, + clear_masks: bool, + ids: &dyn IdGen, +) -> Result { + validate_registered_media(state, &media)?; + let mut candidate = state.clone(); + let media_ref = media.id.clone(); + candidate.manifest.entries.push(media); + let seed_clip_id = clip_id.clone(); + let replacement = swap_media(&mut candidate, clip_id, media_ref)?; + if clear_masks { + let clip = candidate + .timeline + .tracks + .iter_mut() + .flat_map(|track| track.clips.iter_mut()) + .find(|clip| clip.id == seed_clip_id) + .ok_or_else(|| EditError::Invalid("replacement clip disappeared".into()))?; + clip.masks.clear(); + } + let timeline = candidate.timeline; + let manifest = candidate.manifest; + let affected = replacement.affected_clip_ids; + + // `ids` is intentionally accepted to keep the external-render commands on + // the same command signature; SwapMedia itself does not mint ids. + let _ = ids; + transact( + state, + if clear_masks { + "Remove Masked Object" + } else { + "Edit Motion Graphic" + }, + move |ids| { + if clear_masks { + format!("Removed masked object: {}", ids.join(", ")) + } else { + format!("Edited motion graphic: {}", ids.join(", ")) + } + }, + move |current| { + current.timeline = timeline; + current.manifest = manifest; + Ok(affected) + }, + ) +} + +fn register_media_and_freeze_frame( + state: &mut EditorState, + media: MediaManifestEntry, + clip_id: String, + at_frame: i32, + duration_frames: i32, + ids: &dyn IdGen, +) -> Result { + validate_registered_media(state, &media)?; + if media.kind != ClipType::Image { + return Err(EditError::Invalid( + "freeze-frame capture must be an image".into(), + )); + } + + // Validate and build the complete timeline+manifest result on a disposable + // state. Only those two documents enter the outer transaction, so the + // nested command's temporary version/history bookkeeping cannot leak. + let mut candidate = state.clone(); + let media_ref = media.id.clone(); + candidate.manifest.entries.push(media); + let frozen = freeze_frame( + &mut candidate, + clip_id, + at_frame, + duration_frames, + media_ref, + ids, + )?; + let timeline = candidate.timeline; + let manifest = candidate.manifest; + let affected = frozen.affected_clip_ids; + + transact( + state, + "Freeze Frame", + |ids| format!("Froze frame: {}", ids.join(", ")), + move |current| { + current.timeline = timeline; + current.manifest = manifest; + Ok(affected) + }, + ) +} + fn insert_clips( state: &mut EditorState, track_index: usize, @@ -928,19 +3039,15 @@ fn insert_clips( } let target_type = state.timeline.tracks[track_index].kind; for (i, e) in entries.iter().enumerate() { - if !e.source_clip_type.is_compatible(target_type) { + if !e.media_type.is_compatible(target_type) { return Err(EditError::Invalid(format!( "entries[{i}]: asset type is not compatible with the target track" ))); } - if e.duration_frames < 1 { - return Err(EditError::Invalid(format!( - "entries[{i}]: durationFrames must be >= 1 (got {})", - e.duration_frames - ))); - } } let specs: Vec = entries.iter().map(|e| e.to_spec()).collect(); + ops::ripple::validate_ripple_insert(&state.timeline, &specs, track_index, at_frame) + .map_err(EditError::Invalid)?; let action_name = if entries.len() == 1 { "Ripple Insert Clip" } else { @@ -970,6 +3077,45 @@ fn move_clips( if moves.is_empty() { return Err(EditError::Invalid("Missing or empty 'moves' array".into())); } + validate_timeline_frame_arithmetic(&state.timeline, "timeline")?; + let mut unique = HashSet::with_capacity(moves.len()); + let mut plans = Vec::with_capacity(moves.len()); + for (index, movement) in moves.iter().enumerate() { + if !unique.insert(movement.clip_id.as_str()) { + return Err(EditError::Invalid(format!( + "moves[{index}]: clip ids must be unique" + ))); + } + let location = state + .find_clip(&movement.clip_id) + .ok_or_else(|| EditError::Invalid(format!("Clip not found: {}", movement.clip_id)))?; + let clip = state.timeline.tracks[location.track_index].clips[location.clip_index].clone(); + let target = state + .timeline + .tracks + .get(movement.to_track) + .ok_or_else(|| { + EditError::Invalid(format!( + "moves[{index}]: target track index {} out of range", + movement.to_track + )) + })?; + if !clip.media_type.is_compatible(target.kind) { + return Err(EditError::Invalid(format!( + "moves[{index}]: clip is incompatible with destination track" + ))); + } + let to_frame = movement.to_frame.max(0); + let to_end_frame = to_frame.checked_add(clip.duration_frames).ok_or_else(|| { + EditError::Invalid(format!("moves[{index}]: destination end overflows")) + })?; + plans.push(MoveClipPlan { + clip, + to_track_id: target.id.clone(), + to_frame, + to_end_frame, + }); + } let action_name = if moves.len() == 1 { "Move Clip" } else { @@ -981,7 +3127,8 @@ fn move_clips( action_name, move |_| format!("Moved {} clip(s)", moved_ids.len()), |st| { - ops::move_clips(&mut st.timeline, &moves, ids); + let moved = move_clips_from_plans(&mut st.timeline, &plans, ids); + debug_assert_eq!(moved, plans.len()); Ok(moves.iter().map(|m| m.clip_id.clone()).collect()) }, ) @@ -1008,10 +3155,46 @@ fn duplicate_clips_cmd( clip_ids.len() ))); } - for id in &clip_ids { - if state.find_clip(id).is_none() { - return Err(EditError::Invalid(format!("Clip not found: {id}"))); + validate_timeline_frame_arithmetic(&state.timeline, "timeline")?; + let mut unique = HashSet::with_capacity(clip_ids.len()); + let mut plans = Vec::with_capacity(clip_ids.len()); + for (index, (id, &target_track_index)) in clip_ids.iter().zip(&target_track_indexes).enumerate() + { + if !unique.insert(id.as_str()) { + return Err(EditError::Invalid("clipIds must be unique".into())); + } + let location = state + .find_clip(id) + .ok_or_else(|| EditError::Invalid(format!("Clip not found: {id}")))?; + let clip = state.timeline.tracks[location.track_index].clips[location.clip_index].clone(); + let target = state + .timeline + .tracks + .get(target_track_index) + .ok_or_else(|| { + EditError::Invalid(format!( + "targetTrackIndexes[{index}] out of range: {target_track_index}" + )) + })?; + if !clip.media_type.is_compatible(target.kind) { + return Err(EditError::Invalid(format!( + "targetTrackIndexes[{index}] is incompatible with clip {id}" + ))); } + let shifted = clip + .start_frame + .checked_add(offset_frames) + .ok_or_else(|| EditError::Invalid(format!("clip {id}: destination start overflows")))?; + let to_frame = shifted.max(0); + let to_end_frame = to_frame + .checked_add(clip.duration_frames) + .ok_or_else(|| EditError::Invalid(format!("clip {id}: destination end overflows")))?; + plans.push(DuplicateClipPlan { + clip, + to_track_id: target.id.clone(), + to_frame, + to_end_frame, + }); } let action_name = if clip_ids.len() == 1 { "Duplicate Clip" @@ -1023,14 +3206,420 @@ fn duplicate_clips_cmd( state, action_name, move |_| format!("Duplicated {n} clip(s)"), - |st| { - Ok(ops::duplicate_clips( - &mut st.timeline, - &clip_ids, - offset_frames, - &target_track_indexes, - ids, - )) + move |st| { + let created = duplicate_clips_from_plans(&mut st.timeline, plans, ids); + debug_assert_eq!(created.len(), n); + Ok(created) + }, + ) +} + +fn move_or_duplicate_clips_to_new_track( + state: &mut EditorState, + clip_ids: Vec, + lead_clip_id: String, + requested_frame_delta: i32, + insert_at: usize, + mode: NewTrackClipMode, + ids: &dyn IdGen, +) -> Result { + if clip_ids.is_empty() { + return Err(EditError::Invalid( + "Missing or empty 'clipIds' array".into(), + )); + } + let unique: HashSet<_> = clip_ids.iter().collect(); + if unique.len() != clip_ids.len() { + return Err(EditError::Invalid("clipIds must be unique".into())); + } + if !unique.contains(&lead_clip_id) { + return Err(EditError::Invalid( + "leadClipId must be present in clipIds".into(), + )); + } + validate_timeline_frame_arithmetic(&state.timeline, "timeline")?; + + #[derive(Clone)] + struct Source { + clip: Clip, + id: String, + track_id: String, + start_frame: i32, + end_frame: i32, + duration_frames: i32, + destination_start: i32, + destination_end: i32, + media_type: ClipType, + link_group_id: Option, + } + + let mut sources = Vec::with_capacity(clip_ids.len()); + for clip_id in &clip_ids { + let location = state + .find_clip(clip_id) + .ok_or_else(|| EditError::Invalid(format!("Clip not found: {clip_id}")))?; + let track = &state.timeline.tracks[location.track_index]; + let clip = &track.clips[location.clip_index]; + sources.push(Source { + clip: clip.clone(), + id: clip_id.clone(), + track_id: track.id.clone(), + start_frame: clip.start_frame, + end_frame: clip + .start_frame + .checked_add(clip.duration_frames) + .expect("timeline arithmetic was validated above"), + duration_frames: clip.duration_frames, + destination_start: 0, + destination_end: 0, + media_type: clip.media_type, + link_group_id: clip.link_group_id.clone(), + }); + } + let lead = sources + .iter() + .find(|source| source.id == lead_clip_id) + .expect("lead was validated above") + .clone(); + let lead_track_kind = state + .timeline + .tracks + .iter() + .find(|track| track.id == lead.track_id) + .map(|track| track.kind) + .ok_or_else(|| EditError::Invalid("lead track disappeared".into()))?; + let new_track_kind = if lead.media_type == ClipType::Audio { + ClipType::Audio + } else { + ClipType::Video + }; + let min_start = sources + .iter() + .map(|source| source.start_frame) + .min() + .unwrap_or(0); + let minimum_delta = min_start + .checked_neg() + .ok_or_else(|| EditError::Invalid("source start cannot be negated".into()))?; + let frame_delta = requested_frame_delta.max(minimum_delta); + for source in &mut sources { + source.destination_start = + source.start_frame.checked_add(frame_delta).ok_or_else(|| { + EditError::Invalid(format!("clip {}: destination start overflows", source.id)) + })?; + if source.destination_start < 0 { + return Err(EditError::Invalid(format!( + "clip {}: destination start must be >= 0", + source.id + ))); + } + source.destination_end = source + .destination_start + .checked_add(source.duration_frames) + .ok_or_else(|| { + EditError::Invalid(format!("clip {}: destination end overflows", source.id)) + })?; + } + let lead_link = lead.link_group_id.clone(); + + let action_name = match mode { + NewTrackClipMode::Move => "Move Clips To New Track", + NewTrackClipMode::Duplicate => "Duplicate Clips To New Track", + }; + transact( + state, + action_name, + move |affected| format!("{action_name}: {} clip(s)", affected.len()), + move |current| { + let inserted_index = + ops::insert_track(&mut current.timeline, insert_at, new_track_kind, ids); + let inserted_track_id = current.timeline.tracks[inserted_index].id.clone(); + // Duplicate must preserve every source clip. Pinned companions + // cannot use the lead's new lane (linked A/V audio is the common + // case), and an overlapping copy on a retained source lane would + // let overwrite placement trim/delete the original. Give each + // affected source lane one fresh compatible destination, pinned by + // id before any duplicate clearing occurs. + let mut duplicate_track_ids: HashMap = HashMap::new(); + if mode == NewTrackClipMode::Duplicate { + let mut needs_fresh_track = HashSet::new(); + for source in &sources { + let linked_to_lead = source.id != lead.id + && lead_link.is_some() + && source.link_group_id == lead_link; + let incompatible_companion = !source.media_type.is_compatible(lead_track_kind); + let pinned = linked_to_lead || incompatible_companion; + if !pinned && source.track_id == lead.track_id { + continue; + } + let overlaps_preserved_source = sources.iter().any(|preserved| { + preserved.track_id == source.track_id + && preserved.start_frame < source.destination_end + && preserved.end_frame > source.destination_start + }); + if pinned || overlaps_preserved_source { + needs_fresh_track.insert(source.track_id.clone()); + } + } + for source in &sources { + if !needs_fresh_track.contains(&source.track_id) + || duplicate_track_ids.contains_key(&source.track_id) + { + continue; + } + let source_kind = current + .timeline + .tracks + .iter() + .find(|track| track.id == source.track_id) + .map(|track| track.kind) + .expect("preflight pinned every source track"); + let requested = current.timeline.tracks.len(); + let fresh_index = + ops::insert_track(&mut current.timeline, requested, source_kind, ids); + duplicate_track_ids.insert( + source.track_id.clone(), + current.timeline.tracks[fresh_index].id.clone(), + ); + } + } + let mut target_track_ids = Vec::with_capacity(sources.len()); + for source in &sources { + let linked_to_lead = source.id != lead.id + && lead_link.is_some() + && source.link_group_id == lead_link; + let incompatible_companion = !source.media_type.is_compatible(lead_track_kind); + let pinned = linked_to_lead || incompatible_companion; + let target_track_id = + if let Some(fresh_track_id) = duplicate_track_ids.get(&source.track_id) { + fresh_track_id + } else if !pinned && source.track_id == lead.track_id { + &inserted_track_id + } else { + &source.track_id + }; + let target = current + .timeline + .tracks + .iter() + .find(|track| track.id == *target_track_id) + .expect("derived destination track exists after insertion"); + debug_assert!(source.media_type.is_compatible(target.kind)); + target_track_ids.push(target_track_id.clone()); + } + + let affected = match mode { + NewTrackClipMode::Move => { + let plans: Vec<_> = sources + .iter() + .zip(&target_track_ids) + .map(|(source, target_track_id)| MoveClipPlan { + clip: source.clip.clone(), + to_track_id: target_track_id.clone(), + to_frame: source.destination_start, + to_end_frame: source.destination_end, + }) + .collect(); + let moved = move_clips_from_plans(&mut current.timeline, &plans, ids); + debug_assert_eq!(moved, plans.len()); + sources.iter().map(|source| source.id.clone()).collect() + } + NewTrackClipMode::Duplicate => { + let plans: Vec<_> = sources + .iter() + .zip(&target_track_ids) + .map(|(source, target_track_id)| DuplicateClipPlan { + clip: source.clip.clone(), + to_track_id: target_track_id.clone(), + to_frame: source.destination_start, + to_end_frame: source.destination_end, + }) + .collect(); + let created = duplicate_clips_from_plans(&mut current.timeline, plans, ids); + debug_assert_eq!(created.len(), sources.len()); + created + } + }; + Ok(affected) + }, + ) +} + +fn validate_paste_media(state: &EditorState, clip: &Clip) -> Result<(), EditError> { + if let Some(sequence_id) = clip.nested_sequence_id.as_deref() { + if !state + .timeline + .nested_sequences + .iter() + .any(|sequence| sequence.id == sequence_id) + { + return Err(EditError::Invalid(format!( + "Nested sequence not found: {sequence_id}" + ))); + } + if !clip.media_ref.is_empty() { + return Err(EditError::Invalid( + "compound clips must not carry a mediaRef".into(), + )); + } + return Ok(()); + } + if clip.media_type == ClipType::Text { + if clip.source_clip_type != ClipType::Text { + return Err(EditError::Invalid( + "text clip sourceClipType must be text".into(), + )); + } + return Ok(()); + } + let media = state + .manifest + .entries + .iter() + .find(|media| media.id == clip.media_ref) + .ok_or_else(|| { + EditError::Invalid(format!("Media not found in manifest: {}", clip.media_ref)) + })?; + if clip.source_clip_type != media.kind { + return Err(EditError::Invalid(format!( + "clip {} sourceClipType {:?} does not match manifest type {:?}", + clip.id, clip.source_clip_type, media.kind + ))); + } + let direct = clip.media_type == media.kind; + let linked_audio = clip.media_type == ClipType::Audio + && media.kind == ClipType::Video + && media.has_audio.unwrap_or(false); + if !direct && !linked_audio { + return Err(EditError::Invalid(format!( + "clip {} mediaType {:?} is not valid for manifest type {:?}", + clip.id, clip.media_type, media.kind + ))); + } + Ok(()) +} + +fn paste_clips( + state: &mut EditorState, + entries: Vec, + ids: &dyn IdGen, +) -> Result { + if entries.is_empty() { + return Err(EditError::Invalid( + "Missing or empty 'entries' array".into(), + )); + } + validate_timeline_frame_arithmetic(&state.timeline, "timeline")?; + let mut old_ids = HashSet::with_capacity(entries.len()); + let mut destination_ends = Vec::with_capacity(entries.len()); + for (index, entry) in entries.iter().enumerate() { + if entry.clip.id.trim().is_empty() || !old_ids.insert(entry.clip.id.clone()) { + return Err(EditError::Invalid(format!( + "entries[{index}]: source clip ids must be non-empty and unique" + ))); + } + validate_clip_frame_arithmetic(&entry.clip, &format!("entries[{index}].clip"))?; + destination_ends.push(validate_clip_frame_arithmetic_at( + &entry.clip, + entry.start_frame, + &format!("entries[{index}]"), + )?); + let target = state + .timeline + .tracks + .iter() + .find(|track| track.id == entry.target_track_id) + .ok_or_else(|| { + EditError::Invalid(format!("Track not found: {}", entry.target_track_id)) + })?; + if !entry.clip.media_type.is_compatible(target.kind) { + return Err(EditError::Invalid(format!( + "entries[{index}]: clip is incompatible with destination track" + ))); + } + validate_paste_media(state, &entry.clip)?; + } + + transact( + state, + "Paste Clips", + |affected| format!("Pasted {} clip(s)", affected.len()), + move |current| { + let new_ids: Vec = entries.iter().map(|_| ids.next_id()).collect(); + let id_map: HashMap = entries + .iter() + .zip(&new_ids) + .map(|(entry, new_id)| (entry.clip.id.clone(), new_id.clone())) + .collect(); + let mut link_map: HashMap = HashMap::new(); + let mut caption_map: HashMap = HashMap::new(); + for entry in &entries { + if let Some(group) = entry.clip.link_group_id.as_ref() { + link_map + .entry(group.clone()) + .or_insert_with(|| ids.next_id()); + } + if let Some(group) = entry.clip.caption_group_id.as_ref() { + caption_map + .entry(group.clone()) + .or_insert_with(|| ids.next_id()); + } + } + + // Clear every destination before inserting any copy. A later entry + // therefore cannot overwrite an earlier entry from the same batch. + for (entry, &destination_end) in entries.iter().zip(&destination_ends) { + let track_index = current + .timeline + .tracks + .iter() + .position(|track| track.id == entry.target_track_id) + .expect("preflight pinned every paste destination track"); + ops::clear_region( + &mut current.timeline, + track_index, + entry.start_frame, + destination_end, + false, + ids, + ); + } + + for (entry, new_id) in entries.iter().zip(&new_ids) { + let track_index = current + .timeline + .tracks + .iter() + .position(|track| track.id == entry.target_track_id) + .expect("clear without pruning preserves paste destination tracks"); + let mut clip = entry.clip.clone(); + let old_id = clip.id.clone(); + clip.id = new_id.clone(); + clip.start_frame = entry.start_frame; + clip.link_group_id = clip + .link_group_id + .as_ref() + .and_then(|group| link_map.get(group).cloned()); + clip.caption_group_id = clip + .caption_group_id + .as_ref() + .and_then(|group| caption_map.get(group).cloned()); + clip.transition_out = clip.transition_out.take().and_then(|mut transition| { + let to_id = id_map.get(&transition.to_clip_id)?.clone(); + if !transition.from_clip_id.is_empty() && transition.from_clip_id != old_id { + return None; + } + transition.from_clip_id = new_id.clone(); + transition.to_clip_id = to_id; + Some(transition) + }); + current.timeline.tracks[track_index].clips.push(clip); + } + for track in &mut current.timeline.tracks { + ops::sort_clips(track); + } + ops::prune_empty_tracks(&mut current.timeline); + Ok(new_ids) }, ) } @@ -1104,6 +3693,102 @@ fn split( ) } +fn split_clips( + state: &mut EditorState, + clip_ids: Vec, + at_frame: i32, + ids: &dyn IdGen, +) -> Result { + if clip_ids.is_empty() { + return Err(EditError::Invalid( + "Missing or empty 'clipIds' array".into(), + )); + } + + let mut seen_ids = HashSet::with_capacity(clip_ids.len()); + let mut requested = Vec::with_capacity(clip_ids.len()); + for clip_id in clip_ids { + if !seen_ids.insert(clip_id.clone()) { + continue; + } + let location = state + .find_clip(&clip_id) + .ok_or_else(|| EditError::Invalid(format!("Clip not found: {clip_id}")))?; + let clip = &state.timeline.tracks[location.track_index].clips[location.clip_index]; + validate_split_candidate(clip, at_frame)?; + requested.push(clip_id); + } + + let mut seen_groups = HashSet::new(); + let mut seeds = Vec::with_capacity(requested.len()); + for clip_id in requested { + let location = state + .find_clip(&clip_id) + .expect("requested split target was preflighted"); + let clip = &state.timeline.tracks[location.track_index].clips[location.clip_index]; + match &clip.link_group_id { + Some(group_id) if !seen_groups.insert(group_id.clone()) => {} + _ => seeds.push(clip_id), + } + } + + // `ops::split_clip` expands a seed to its complete link group. Preflight + // every partner that will actually be split before any right-half id is + // minted, so a malformed partner cannot consume ids before rollback. + for seed in &seeds { + let location = state.find_clip(seed).expect("split seed was preflighted"); + let group_id = state.timeline.tracks[location.track_index].clips[location.clip_index] + .link_group_id + .as_deref(); + if let Some(group_id) = group_id { + for clip in state.timeline.tracks.iter().flat_map(|track| &track.clips) { + if clip.link_group_id.as_deref() == Some(group_id) + && at_frame > clip.start_frame + && at_frame < clip.end_frame() + { + validate_split_candidate(clip, at_frame)?; + } + } + } + } + + transact( + state, + "Split Clips", + move |rights| { + if rights.is_empty() { + "Split (no-op)".to_string() + } else { + format!("Split at {at_frame} -> {}", rights.join(", ")) + } + }, + move |state| { + let mut rights = Vec::new(); + for seed in &seeds { + rights.extend(ops::split_clip(&mut state.timeline, seed, at_frame, ids)); + } + Ok(rights) + }, + ) +} + +fn validate_split_candidate(clip: &Clip, at_frame: i32) -> Result<(), EditError> { + if !(at_frame > clip.start_frame && at_frame < clip.end_frame()) { + return Err(EditError::Invalid(format!( + "Frame {at_frame} is outside clip range ({}..{})", + clip.start_frame, + clip.end_frame() + ))); + } + if opentake_domain::split_clip(clip, at_frame, "preflight").is_none() { + return Err(EditError::Invalid(format!( + "Clip {} cannot be split at frame {at_frame}", + clip.id + ))); + } + Ok(()) +} + fn freeze_frame( state: &mut EditorState, clip_id: String, @@ -1134,6 +3819,19 @@ fn freeze_frame( clip.media_type ))); } + let spec = PlaceSpec::new( + media_ref.clone(), + ClipType::Image, + at_frame, + duration_frames, + ); + ops::ripple::validate_ripple_insert( + &state.timeline, + std::slice::from_ref(&spec), + loc.track_index, + at_frame, + ) + .map_err(EditError::Invalid)?; let track_id = state.timeline.tracks[loc.track_index].id.clone(); transact( state, @@ -1144,7 +3842,6 @@ fn freeze_frame( return Err(EditError::Invalid("Track vanished".into())); }; let mut affected = ops::split_clip(&mut st.timeline, &clip_id, at_frame, ids); - let spec = PlaceSpec::new(media_ref, ClipType::Image, at_frame, duration_frames); affected.extend(ops::ripple::ripple_insert( &mut st.timeline, std::slice::from_ref(&spec), @@ -1166,18 +3863,106 @@ fn trim(state: &mut EditorState, edits: Vec) -> Result Result<(), EditError> { + let location = state + .find_clip(clip_id) + .ok_or_else(|| EditError::Invalid(format!("Clip not found: {clip_id}")))?; + let clip = &state.timeline.tracks[location.track_index].clips[location.clip_index]; + if clip.nested_sequence_id.is_some() + && (props + .speed + .is_some_and(|speed| (speed - 1.0).abs() > f64::EPSILON) + || props.reversed == Some(true) + || props.crop.is_some_and(|crop| crop != Crop::default()) + || props.text_content.is_some() + || props.text_style.is_some()) + { + return Err(EditError::Invalid(format!( + "compound clip {clip_id} does not support retime, reverse, crop, or text properties" + ))); + } + validate_effective_clip_timing(clip, props, &format!("clip {clip_id}")) +} + +fn validate_effective_clip_timing( + clip: &Clip, + props: &ClipProperties, + label: &str, +) -> Result<(), EditError> { + let mut duration_frames = props.duration_frames.unwrap_or(clip.duration_frames); + let trim_start_frame = props.trim_start_frame.unwrap_or(clip.trim_start_frame); + let trim_end_frame = props.trim_end_frame.unwrap_or(clip.trim_end_frame); + let speed = props.speed.unwrap_or(clip.speed); + if !speed.is_finite() || speed <= 0.0 { + return Err(EditError::Invalid(format!( + "{label}: speed must be finite and > 0" + ))); + } + if props.speed.is_some() && props.duration_frames.is_none() { + let source_consumed = clip.duration_frames as f64 * clip.speed; + let projected_duration = (source_consumed / speed).round(); + if !projected_duration.is_finite() || projected_duration > i32::MAX as f64 { + return Err(EditError::Invalid(format!( + "{label}: retimed duration is out of range" + ))); + } + duration_frames = (projected_duration as i32).max(1); + } + checked_clip_frame_arithmetic( + clip.start_frame, + duration_frames, + trim_start_frame, + trim_end_frame, + speed, + clip.media_type, + label, + )?; + Ok(()) +} + +fn timing_properties(props: &ClipProperties, is_text: bool) -> ClipProperties { + ClipProperties { + duration_frames: if is_text { None } else { props.duration_frames }, + trim_start_frame: if is_text { + None + } else { + props.trim_start_frame + }, + trim_end_frame: if is_text { None } else { props.trim_end_frame }, + speed: if is_text { None } else { props.speed }, + ..Default::default() + } +} + +fn same_timing_properties(left: &ClipProperties, right: &ClipProperties) -> bool { + left.duration_frames == right.duration_frames + && left.trim_start_frame == right.trim_start_frame + && left.trim_end_frame == right.trim_end_frame + && left.speed == right.speed +} + fn set_clip_properties( state: &mut EditorState, clip_ids: Vec, @@ -1189,16 +3974,7 @@ fn set_clip_properties( )); } for id in &clip_ids { - if state.find_clip(id).is_none() { - return Err(EditError::Invalid(format!("Clip not found: {id}"))); - } - } - if let Some(df) = props.duration_frames { - if df < 1 { - return Err(EditError::Invalid(format!( - "durationFrames must be >= 1 (got {df})" - ))); - } + validate_clip_property_target(state, id, &props)?; } // Timing changes propagate to linked partners (trim/speed dropped for text). let propagates_timing = props.duration_frames.is_some() @@ -1210,6 +3986,14 @@ fn set_clip_properties( } else { HashSet::new() }; + for partner_id in &partners { + let location = state + .find_clip(partner_id) + .expect("timing propagation returned an existing clip"); + let clip = &state.timeline.tracks[location.track_index].clips[location.clip_index]; + let partner_props = timing_properties(&props, clip.media_type == ClipType::Text); + validate_effective_clip_timing(clip, &partner_props, &format!("linked clip {partner_id}"))?; + } let n = clip_ids.len(); transact( state, @@ -1229,17 +4013,7 @@ fn set_clip_properties( .map(|l| st.timeline.tracks[l.track_index].clips[l.clip_index].media_type) == Some(ClipType::Text); // Partners receive only timing (and drop it when text). - let partner_props = ClipProperties { - duration_frames: if is_text { None } else { props.duration_frames }, - trim_start_frame: if is_text { - None - } else { - props.trim_start_frame - }, - trim_end_frame: if is_text { None } else { props.trim_end_frame }, - speed: if is_text { None } else { props.speed }, - ..Default::default() - }; + let partner_props = timing_properties(&props, is_text); apply_property_changes(&mut st.timeline, pid, &partner_props, true); } Ok(clip_ids.clone()) @@ -1247,6 +4021,209 @@ fn set_clip_properties( ) } +fn set_clip_properties_per_clip( + state: &mut EditorState, + assignments: Vec, +) -> Result { + if assignments.is_empty() { + return Err(EditError::Invalid( + "Missing or empty clip property assignments".into(), + )); + } + + let mut direct_ids = HashSet::with_capacity(assignments.len()); + for assignment in &assignments { + if !direct_ids.insert(assignment.clip_id.clone()) { + return Err(EditError::Invalid(format!( + "Duplicate clip property assignment: {}", + assignment.clip_id + ))); + } + validate_clip_property_target(state, &assignment.clip_id, &assignment.properties)?; + } + + // Resolve timing propagation before mutation. A linked partner that is not + // itself a direct target may receive timing from at most one distinct bundle; + // conflicting deferred assignments are rejected without touching history. + let mut partner_properties: HashMap = HashMap::new(); + for assignment in &assignments { + let props = &assignment.properties; + let propagates_timing = props.duration_frames.is_some() + || props.trim_start_frame.is_some() + || props.trim_end_frame.is_some() + || props.speed.is_some(); + if !propagates_timing { + continue; + } + let source = HashSet::from([assignment.clip_id.clone()]); + for partner_id in ops::timing_propagation_partners(&state.timeline, &source) { + if direct_ids.contains(&partner_id) { + continue; + } + let is_text = state.find_clip(&partner_id).map(|location| { + state.timeline.tracks[location.track_index].clips[location.clip_index].media_type + }) == Some(ClipType::Text); + let candidate = timing_properties(props, is_text); + if let Some(existing) = partner_properties.get(&partner_id) { + if !same_timing_properties(existing, &candidate) { + return Err(EditError::Invalid(format!( + "Conflicting timing assignments for linked clip: {partner_id}" + ))); + } + } else { + partner_properties.insert(partner_id, candidate); + } + } + } + for (partner_id, props) in &partner_properties { + let location = state + .find_clip(partner_id) + .expect("timing propagation returned an existing clip"); + let clip = &state.timeline.tracks[location.track_index].clips[location.clip_index]; + validate_effective_clip_timing(clip, props, &format!("linked clip {partner_id}"))?; + } + + let count = assignments.len(); + transact( + state, + if count == 1 { + "Set Clip Property" + } else { + "Set Clip Properties" + }, + move |_| format!("Updated {count} clip(s)"), + move |state| { + let mut affected = Vec::with_capacity(assignments.len()); + for assignment in &assignments { + apply_property_changes( + &mut state.timeline, + &assignment.clip_id, + &assignment.properties, + false, + ); + affected.push(assignment.clip_id.clone()); + } + for (partner_id, props) in &partner_properties { + apply_property_changes(&mut state.timeline, partner_id, props, true); + } + Ok(affected) + }, + ) +} + +fn set_transform_at_frame( + state: &mut EditorState, + clip_id: String, + frame: i32, + transform: Transform, +) -> Result { + let finite = [ + transform.center_x, + transform.center_y, + transform.width, + transform.height, + transform.rotation, + ] + .into_iter() + .all(f64::is_finite); + if !finite { + return Err(EditError::Invalid( + "Transform values must all be finite".into(), + )); + } + let target_left = transform.center_x - transform.width / 2.0; + let target_top = transform.center_y - transform.height / 2.0; + if !target_left.is_finite() || !target_top.is_finite() { + return Err(EditError::Invalid( + "Derived transform position must be finite".into(), + )); + } + + let location = state + .find_clip(&clip_id) + .ok_or_else(|| EditError::Invalid(format!("Clip not found: {clip_id}")))?; + let clip = &state.timeline.tracks[location.track_index].clips[location.clip_index]; + let position_active = clip + .position_track + .as_ref() + .is_some_and(|track| track.is_active()); + let scale_active = clip + .scale_track + .as_ref() + .is_some_and(|track| track.is_active()); + let rotation_active = clip + .rotation_track + .as_ref() + .is_some_and(|track| track.is_active()); + let has_active_track = position_active || scale_active || rotation_active; + if has_active_track && !clip.contains(frame) { + return Err(EditError::Invalid(format!( + "Frame {frame} is outside clip range ({}..{})", + clip.start_frame, + clip.end_frame() + ))); + } + let relative_frame = if has_active_track { + frame + .checked_sub(clip.start_frame) + .expect("animated transform frame was checked against the clip") + } else { + 0 + }; + + let summary = format!("Set transform on {clip_id}"); + transact( + state, + "Change Transform", + move |_| summary, + move |state| { + let location = state + .find_clip(&clip_id) + .expect("transform target was preflighted"); + let clip = &mut state.timeline.tracks[location.track_index].clips[location.clip_index]; + + if position_active { + let mut track = clip.position_track.take().unwrap_or_default(); + track.upsert(opentake_domain::Keyframe::new( + relative_frame, + opentake_domain::AnimPair::new(target_left, target_top), + )); + clip.position_track = empty_to_none(track); + } else { + clip.transform.center_x = transform.center_x; + clip.transform.center_y = transform.center_y; + } + + if scale_active { + let mut track = clip.scale_track.take().unwrap_or_default(); + track.upsert(opentake_domain::Keyframe::new( + relative_frame, + opentake_domain::AnimPair::new(transform.width, transform.height), + )); + clip.scale_track = empty_to_none(track); + } else { + clip.transform.width = transform.width; + clip.transform.height = transform.height; + } + + if rotation_active { + let mut track = clip.rotation_track.take().unwrap_or_default(); + track.upsert(opentake_domain::Keyframe::new( + relative_frame, + transform.rotation, + )); + clip.rotation_track = empty_to_none(track); + } else { + clip.transform.rotation = transform.rotation; + } + + clip.transform.flip_horizontal = transform.flip_horizontal; + clip.transform.flip_vertical = transform.flip_vertical; + Ok(vec![clip_id]) + }, + ) +} + /// Apply a property bundle to one clip in place. `partner` marks the call as a /// linked-partner propagation (only timing fields are set then). 1:1 port of /// `applyPropertyChanges`. @@ -1261,6 +4238,15 @@ fn apply_property_changes( }; let clip = &mut timeline.tracks[ti].clips[ci]; + if props.duration_frames.is_some() + || props.trim_start_frame.is_some() + || props.trim_end_frame.is_some() + || props.speed.is_some() + || props.reversed.is_some() + { + clip.loudness_normalization = None; + } + if let Some(v) = props.duration_frames { clip.duration_frames = v; clip.clamp_keyframes_to_duration(); @@ -1303,31 +4289,484 @@ fn apply_property_changes( clip.fade_in_frames = v.max(0); clip.clamp_fades_to_duration(); } - if let Some(v) = props.fade_out_frames { - clip.fade_out_frames = v.max(0); - clip.clamp_fades_to_duration(); + if let Some(v) = props.fade_out_frames { + clip.fade_out_frames = v.max(0); + clip.clamp_fades_to_duration(); + } + if let Some(i) = props.fade_in_interpolation { + clip.fade_in_interpolation = i; + } + if let Some(i) = props.fade_out_interpolation { + clip.fade_out_interpolation = i; + } + if let Some(f) = props.flip_horizontal { + clip.transform.flip_horizontal = f; + } + if let Some(f) = props.flip_vertical { + clip.transform.flip_vertical = f; + } + if let Some(reversed) = props.reversed { + clip.reversed = reversed; + } + if let Some(c) = &props.text_content { + clip.text_content = Some(c.clone()); + clip.caption_translation_input = None; + } + if let Some(s) = &props.text_style { + clip.text_style = Some(s.clone()); + } +} + +fn apply_caption_translations( + state: &mut EditorState, + changes: Vec, +) -> Result { + if changes.is_empty() { + return Err(EditError::Invalid( + "Missing or empty caption translation changes".into(), + )); + } + let mut seen = HashSet::new(); + for change in &changes { + if !seen.insert(change.clip_id.as_str()) { + return Err(EditError::Invalid(format!( + "Duplicate caption clip: {}", + change.clip_id + ))); + } + if change.translated_text.trim().is_empty() { + return Err(EditError::Invalid(format!( + "Translated text is empty for clip {}", + change.clip_id + ))); + } + let location = state + .find_clip(&change.clip_id) + .ok_or_else(|| EditError::Invalid(format!("Clip not found: {}", change.clip_id)))?; + let clip = &state.timeline.tracks[location.track_index].clips[location.clip_index]; + if clip.media_type != ClipType::Text || clip.caption_group_id.is_none() { + return Err(EditError::Invalid(format!( + "Clip is not a caption: {}", + change.clip_id + ))); + } + if clip.text_content.as_deref() != Some(change.expected_source_text.as_str()) { + return Err(EditError::Invalid(format!( + "Caption text changed during review: {}", + change.clip_id + ))); + } + if change.input.source_text != change.expected_source_text { + return Err(EditError::Invalid(format!( + "Caption provenance does not match source text: {}", + change.clip_id + ))); + } + } + let n = changes.len(); + transact( + state, + "Translate Captions", + move |_| format!("Translated {n} caption(s)"), + move |st| { + for change in &changes { + let (track_index, clip_index) = find(&st.timeline, &change.clip_id) + .expect("caption translations were prevalidated"); + let clip = &mut st.timeline.tracks[track_index].clips[clip_index]; + clip.text_content = Some(change.translated_text.clone()); + clip.caption_translation_input = Some(change.input.clone()); + } + Ok(changes + .iter() + .map(|change| change.clip_id.clone()) + .collect()) + }, + ) +} + +fn validate_script_assembly_plan(plan: &ScriptAssemblyPlan) -> Result<(), EditError> { + if plan.id.is_empty() + || plan.id.len() > 128 + || plan.plan_hash.len() != 64 + || !plan.plan_hash.bytes().all(|byte| byte.is_ascii_hexdigit()) + || plan.planner.trim().is_empty() + || plan.planner.len() > 128 + || plan.planner_version == 0 + || plan.start_frame < 0 + || plan.segments.is_empty() + || plan.segments.len() > 100 + { + return Err(EditError::Invalid("invalid script assembly plan".into())); + } + let mut cursor = plan.start_frame; + for (index, segment) in plan.segments.iter().enumerate() { + if segment.script.trim().is_empty() + || segment.script.len() > 20_000 + || segment.media_ref.is_empty() + || segment.media_ref.len() > 256 + || segment + .narration_media_ref + .as_ref() + .is_some_and(|value| value.is_empty() || value.len() > 256) + || !(1..=36_000).contains(&segment.duration_frames) + { + return Err(EditError::Invalid(format!( + "invalid script assembly segment {index}" + ))); + } + if segment.transition.is_some() + && (index + 1 == plan.segments.len() + || segment.duration_frames < 2 + || plan.segments[index + 1].duration_frames < 2) + { + return Err(EditError::Invalid(format!( + "segment {index} transition requires a following segment with at least two frames" + ))); + } + cursor = cursor.checked_add(segment.duration_frames).ok_or_else(|| { + EditError::Invalid(format!("script segment {index} timeline span overflows")) + })?; + } + Ok(()) +} + +fn save_script_assembly_plan( + state: &mut EditorState, + plan: ScriptAssemblyPlan, +) -> Result { + validate_script_assembly_plan(&plan)?; + let plan_id = plan.id.clone(); + transact( + state, + "Plan Script Video", + |_| format!("Saved script assembly plan {plan_id}"), + move |st| { + if let Some(existing) = st + .timeline + .script_assembly_plans + .iter_mut() + .find(|existing| existing.id == plan.id) + { + *existing = plan.clone(); + } else { + if st.timeline.script_assembly_plans.len() >= 20 { + st.timeline.script_assembly_plans.remove(0); + } + st.timeline.script_assembly_plans.push(plan.clone()); + } + Ok(Vec::new()) + }, + ) +} + +fn apply_script_assembly_plan( + state: &mut EditorState, + plan_id: String, + ids: &dyn IdGen, +) -> Result { + let plan = state + .timeline + .script_assembly_plans + .iter() + .find(|plan| plan.id == plan_id) + .cloned() + .ok_or_else(|| EditError::Invalid(format!("script assembly plan not found: {plan_id}")))?; + validate_script_assembly_plan(&plan)?; + let fps = state.timeline.fps.max(1) as f64; + let mut cursor = plan.start_frame; + let mut segment_starts = Vec::with_capacity(plan.segments.len()); + for (index, segment) in plan.segments.iter().enumerate() { + checked_frame_arithmetic( + cursor, + segment.duration_frames, + 0, + 0, + 1.0, + &format!("script segment {index}"), + )?; + segment_starts.push(cursor); + cursor = cursor.checked_add(segment.duration_frames).ok_or_else(|| { + EditError::Invalid(format!("script segment {index} timeline span overflows")) + })?; + let visual = state + .manifest + .entries + .iter() + .find(|entry| entry.id == segment.media_ref) + .ok_or_else(|| { + EditError::Invalid(format!( + "script segment {index} visual media not found: {}", + segment.media_ref + )) + })?; + if !matches!( + visual.kind, + ClipType::Image | ClipType::Video | ClipType::Lottie + ) { + return Err(EditError::Invalid(format!( + "script segment {index} media must be visual" + ))); + } + if visual.kind == ClipType::Video { + if let Some(visual_frames) = checked_media_duration_frames( + visual.duration, + fps, + &format!("script segment {index} visual media"), + )? { + let minimum_source_frames = + segment.duration_frames.checked_sub(1).ok_or_else(|| { + EditError::Invalid(format!("script segment {index} duration is invalid")) + })?; + if visual_frames < minimum_source_frames { + return Err(EditError::Invalid(format!( + "script segment {index} is longer than its video source" + ))); + } + } + } + if let Some(narration_ref) = &segment.narration_media_ref { + let narration = state + .manifest + .entries + .iter() + .find(|entry| entry.id == *narration_ref) + .ok_or_else(|| { + EditError::Invalid(format!( + "script segment {index} narration media not found: {narration_ref}" + )) + })?; + if !narration + .has_audio + .unwrap_or(narration.kind == ClipType::Audio) + || !matches!(narration.kind, ClipType::Audio | ClipType::Video) + { + return Err(EditError::Invalid(format!( + "script segment {index} narration must contain audio" + ))); + } + if let Some(narration_frames) = checked_media_duration_frames( + narration.duration, + fps, + &format!("script segment {index} narration media"), + )? { + let difference = i64::from(narration_frames) - i64::from(segment.duration_frames); + if difference.abs() > 1 { + return Err(EditError::Invalid(format!( + "script segment {index} narration duration must match within one frame" + ))); + } + } + } + } + + transact( + state, + "Build Script Video", + |affected| format!("Built script video with {} clip(s)", affected.len()), + move |st| { + let visual_track_id = ids.next_id(); + let mut visual_track = Track::new(visual_track_id, ClipType::Video); + let mut narration_track = plan + .segments + .iter() + .any(|segment| segment.narration_media_ref.is_some()) + .then(|| Track::new(ids.next_id(), ClipType::Audio)); + let mut affected = Vec::new(); + for (segment, &start_frame) in plan.segments.iter().zip(&segment_starts) { + let media = st + .manifest + .entries + .iter() + .find(|entry| entry.id == segment.media_ref) + .expect("script assembly media was prevalidated"); + let mut clip = Clip::new( + ids.next_id(), + segment.media_ref.clone(), + start_frame, + segment.duration_frames, + ); + clip.media_type = media.kind; + clip.source_clip_type = media.kind; + if segment.narration_media_ref.is_some() { + clip.volume = 0.0; + } + affected.push(clip.id.clone()); + visual_track.clips.push(clip); + if let (Some(track), Some(narration_ref)) = + (&mut narration_track, &segment.narration_media_ref) + { + let narration = st + .manifest + .entries + .iter() + .find(|entry| entry.id == *narration_ref) + .expect("script narration was prevalidated"); + let mut clip = Clip::new( + ids.next_id(), + narration_ref.clone(), + start_frame, + segment.duration_frames, + ); + clip.media_type = ClipType::Audio; + clip.source_clip_type = narration.kind; + affected.push(clip.id.clone()); + track.clips.push(clip); + } + } + for index in 0..visual_track.clips.len().saturating_sub(1) { + let Some(kind) = plan.segments[index].transition else { + continue; + }; + let from = &visual_track.clips[index]; + let to = &visual_track.clips[index + 1]; + let duration_frames = 12 + .min(from.duration_frames / 2) + .min(to.duration_frames / 2) + .max(1); + visual_track.clips[index].transition_out = Some(Transition { + from_clip_id: from.id.clone(), + to_clip_id: to.id.clone(), + kind, + duration_frames, + }); + } + st.timeline.tracks.insert(0, visual_track); + if let Some(track) = narration_track { + st.timeline.tracks.push(track); + } + Ok(affected) + }, + ) +} + +fn checked_media_duration_frames( + duration_seconds: f64, + fps: f64, + label: &str, +) -> Result, EditError> { + if duration_seconds == 0.0 { + return Ok(None); } - if let Some(i) = props.fade_in_interpolation { - clip.fade_in_interpolation = i; + if !duration_seconds.is_finite() || duration_seconds < 0.0 { + return Err(EditError::Invalid(format!( + "{label}: duration must be finite and nonnegative" + ))); } - if let Some(i) = props.fade_out_interpolation { - clip.fade_out_interpolation = i; + let frames = (duration_seconds * fps).round(); + if !frames.is_finite() || !(0.0..=i32::MAX as f64).contains(&frames) { + return Err(EditError::Invalid(format!( + "{label}: duration is out of frame range" + ))); } - if let Some(f) = props.flip_horizontal { - clip.transform.flip_horizontal = f; + Ok(Some(frames as i32)) +} + +fn valid_sha256(value: &str) -> bool { + value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_hexdigit()) +} + +fn validate_voice_model(record: &VoiceModelRecord) -> Result<(), EditError> { + let valid_token = |value: &str, max: usize| { + !value.trim().is_empty() + && value.len() <= max + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || b"-_.:/".contains(&byte)) + }; + if !valid_token(&record.id, 128) + || !valid_token(&record.provider, 64) + || record.provider_voice_id.is_empty() + || record.provider_voice_id.len() > 256 + || !record + .provider_voice_id + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')) + || !valid_token(&record.model, 256) + || !valid_token(&record.consent_id, 256) + || !valid_token(&record.source_audio_asset_id, 256) + || !valid_sha256(&record.source_audio_sha256) + || !valid_sha256(&record.request_hash) + || record.voice_name.trim().is_empty() + || record.voice_name.len() > 128 + { + return Err(EditError::Invalid("invalid cloned voice metadata".into())); } - if let Some(f) = props.flip_vertical { - clip.transform.flip_vertical = f; + Ok(()) +} + +fn save_voice_model( + state: &mut EditorState, + record: VoiceModelRecord, +) -> Result { + validate_voice_model(&record)?; + let source = state + .manifest + .entries + .iter() + .find(|entry| entry.id == record.source_audio_asset_id) + .ok_or_else(|| EditError::Invalid("voice reference audio does not exist".into()))?; + if source.kind != ClipType::Audio || !source.has_audio.unwrap_or(true) { + return Err(EditError::Invalid( + "voice reference must be an audio asset".into(), + )); } - if let Some(reversed) = props.reversed { - clip.reversed = reversed; + if state.timeline.voice_models.iter().any(|existing| { + existing.id == record.id + || (existing.provider == record.provider + && existing.provider_voice_id == record.provider_voice_id) + }) { + return Err(EditError::Invalid( + "provider voice identity is already registered".into(), + )); } - if let Some(c) = &props.text_content { - clip.text_content = Some(c.clone()); + if state.timeline.voice_models.len() >= 100 { + return Err(EditError::Invalid( + "project voice model limit reached".into(), + )); } - if let Some(s) = &props.text_style { - clip.text_style = Some(s.clone()); + let record_id = record.id.clone(); + state.timeline.voice_models.push(record); + state.commit_irreversible(); + Ok(result( + state, + true, + false, + "Enroll Voice Clone", + Vec::new(), + &format!("Enrolled voice clone {record_id}"), + )) +} + +fn revoke_voice_model( + state: &mut EditorState, + voice_model_id: String, +) -> Result { + let record = state + .timeline + .voice_models + .iter_mut() + .find(|record| record.id == voice_model_id) + .ok_or_else(|| EditError::Invalid("voice model not found".into()))?; + if record.revoked { + return Ok(result( + state, + false, + false, + "Revoke Voice Clone", + Vec::new(), + "Voice clone was already revoked", + )); } + record.revoked = true; + state.commit_irreversible(); + Ok(result( + state, + true, + false, + "Revoke Voice Clone", + Vec::new(), + &format!("Revoked voice clone {voice_model_id}"), + )) } fn set_keyframes( @@ -1336,8 +4775,17 @@ fn set_keyframes( property: KeyframeProperty, payload: KeyframePayload, ) -> Result { - if state.find_clip(&clip_id).is_none() { - return Err(EditError::Invalid(format!("Clip not found: {clip_id}"))); + let location = state + .find_clip(&clip_id) + .ok_or_else(|| EditError::Invalid(format!("Clip not found: {clip_id}")))?; + if property == KeyframeProperty::Crop + && state.timeline.tracks[location.track_index].clips[location.clip_index] + .nested_sequence_id + .is_some() + { + return Err(EditError::Invalid( + "compound clips do not support crop keyframes".into(), + )); } // Type/property agreement check. let ok = matches!( @@ -1398,6 +4846,11 @@ fn stamp_keyframe( .find_clip(&clip_id) .ok_or_else(|| EditError::Invalid(format!("Clip not found: {clip_id}")))?; let clip = &state.timeline.tracks[loc.track_index].clips[loc.clip_index]; + if property == KeyframeProperty::Crop && clip.nested_sequence_id.is_some() { + return Err(EditError::Invalid( + "compound clips do not support crop keyframes".into(), + )); + } if !clip.contains(frame) { return Err(EditError::Invalid(format!( "Frame {frame} is outside clip range ({}..{})", @@ -1405,6 +4858,9 @@ fn stamp_keyframe( clip.end_frame() ))); } + let rel = frame + .checked_sub(clip.start_frame) + .expect("contained keyframe frame is at or after clip start"); let summary = format!("Stamp keyframe on {clip_id}"); transact( state, @@ -1413,7 +4869,6 @@ fn stamp_keyframe( move |st| { let loc = st.find_clip(&clip_id).expect("validated above"); let clip = &mut st.timeline.tracks[loc.track_index].clips[loc.clip_index]; - let rel = frame - clip.start_frame; match property { KeyframeProperty::Opacity => { let v = clip.raw_opacity_at(frame); @@ -1481,6 +4936,11 @@ fn upsert_keyframe( .find_clip(&clip_id) .ok_or_else(|| EditError::Invalid(format!("Clip not found: {clip_id}")))?; let clip = &state.timeline.tracks[loc.track_index].clips[loc.clip_index]; + if property == KeyframeProperty::Crop && clip.nested_sequence_id.is_some() { + return Err(EditError::Invalid( + "compound clips do not support crop keyframes".into(), + )); + } if !clip.contains(frame) { return Err(EditError::Invalid(format!( "Frame {frame} is outside clip range ({}..{})", @@ -1488,6 +4948,9 @@ fn upsert_keyframe( clip.end_frame() ))); } + let rel = frame + .checked_sub(clip.start_frame) + .expect("contained keyframe frame is at or after clip start"); // Type/property agreement check (mirrors `set_keyframes`). let ok = matches!( (property, value), @@ -1511,7 +4974,6 @@ fn upsert_keyframe( move |st| { let loc = st.find_clip(&clip_id).expect("validated above"); let clip = &mut st.timeline.tracks[loc.track_index].clips[loc.clip_index]; - let rel = frame - clip.start_frame; match (property, value) { (KeyframeProperty::Opacity, KeyframeValue::Scalar(v)) => { let mut track = clip.opacity_track.take().unwrap_or_default(); @@ -1560,7 +5022,11 @@ fn remove_keyframe( .find_clip(&clip_id) .ok_or_else(|| EditError::Invalid(format!("Clip not found: {clip_id}")))?; let clip = &state.timeline.tracks[loc.track_index].clips[loc.clip_index]; - let rel = frame - clip.start_frame; + let rel = frame.checked_sub(clip.start_frame).ok_or_else(|| { + EditError::Invalid(format!( + "Keyframe frame {frame} is outside the supported range" + )) + })?; let has_kf = match property { KeyframeProperty::Opacity => has_keyframe_at(&clip.opacity_track, rel), KeyframeProperty::Volume => has_keyframe_at(&clip.volume_track, rel), @@ -1582,7 +5048,6 @@ fn remove_keyframe( move |st| { let loc = st.find_clip(&clip_id).expect("validated above"); let clip = &mut st.timeline.tracks[loc.track_index].clips[loc.clip_index]; - let rel = frame - clip.start_frame; match property { KeyframeProperty::Opacity => { if let Some(mut t) = clip.opacity_track.take() { @@ -1637,8 +5102,16 @@ fn move_keyframe( .find_clip(&clip_id) .ok_or_else(|| EditError::Invalid(format!("Clip not found: {clip_id}")))?; let clip = &state.timeline.tracks[loc.track_index].clips[loc.clip_index]; - let from_rel = from_frame - clip.start_frame; - let to_rel = to_frame - clip.start_frame; + let from_rel = from_frame.checked_sub(clip.start_frame).ok_or_else(|| { + EditError::Invalid(format!( + "Keyframe frame {from_frame} is outside the supported range" + )) + })?; + let to_rel = to_frame.checked_sub(clip.start_frame).ok_or_else(|| { + EditError::Invalid(format!( + "Target frame {to_frame} is outside the supported range" + )) + })?; let has_source = match property { KeyframeProperty::Opacity => has_keyframe_at(&clip.opacity_track, from_rel), KeyframeProperty::Volume => has_keyframe_at(&clip.volume_track, from_rel), @@ -1683,8 +5156,6 @@ fn move_keyframe( move |st| { let loc = st.find_clip(&clip_id).expect("validated above"); let clip = &mut st.timeline.tracks[loc.track_index].clips[loc.clip_index]; - let from_rel = from_frame - clip.start_frame; - let to_rel = to_frame - clip.start_frame; match property { KeyframeProperty::Opacity => { if let Some(mut t) = clip.opacity_track.take() { @@ -1739,7 +5210,11 @@ fn set_keyframe_interpolation( .find_clip(&clip_id) .ok_or_else(|| EditError::Invalid(format!("Clip not found: {clip_id}")))?; let clip = &state.timeline.tracks[loc.track_index].clips[loc.clip_index]; - let rel = frame - clip.start_frame; + let rel = frame.checked_sub(clip.start_frame).ok_or_else(|| { + EditError::Invalid(format!( + "Keyframe frame {frame} is outside the supported range" + )) + })?; let has_kf = match property { KeyframeProperty::Opacity => has_keyframe_at(&clip.opacity_track, rel), KeyframeProperty::Volume => has_keyframe_at(&clip.volume_track, rel), @@ -1761,7 +5236,6 @@ fn set_keyframe_interpolation( move |st| { let loc = st.find_clip(&clip_id).expect("validated above"); let clip = &mut st.timeline.tracks[loc.track_index].clips[loc.clip_index]; - let rel = frame - clip.start_frame; match property { KeyframeProperty::Opacity => { set_kf_interp(&mut clip.opacity_track, rel, interpolation) @@ -1825,13 +5299,76 @@ fn set_clip_effect_field( ) } +fn reject_compound_effect_targets( + state: &EditorState, + clip_ids: &[String], + adding_effect: bool, +) -> Result<(), EditError> { + if !adding_effect { + return Ok(()); + } + for id in clip_ids { + let location = state + .find_clip(id) + .ok_or_else(|| EditError::Invalid(format!("Clip not found: {id}")))?; + if state.timeline.tracks[location.track_index].clips[location.clip_index] + .nested_sequence_id + .is_some() + { + return Err(EditError::Invalid(format!( + "compound clip {id} does not support direct pixel effects" + ))); + } + } + Ok(()) +} + fn set_color_grade( state: &mut EditorState, clip_ids: Vec, grade: Option, ) -> Result { + if let Some(grade) = grade { + grade + .validate() + .map_err(|error| EditError::Invalid(format!("invalid color grade: {error}")))?; + } + reject_compound_effect_targets(state, &clip_ids, grade.is_some())?; set_clip_effect_field(state, clip_ids, "Set Color Grade", move |clip| { clip.color_grade = grade; + clip.color_match_input = None; + }) +} + +fn apply_color_match( + state: &mut EditorState, + clip_id: String, + grade: ColorGrade, + input: ColorMatchInput, +) -> Result { + grade + .validate() + .map_err(|error| EditError::Invalid(format!("invalid color match grade: {error}")))?; + reject_compound_effect_targets(state, std::slice::from_ref(&clip_id), true)?; + set_clip_effect_field(state, vec![clip_id], "Match Color", move |clip| { + clip.color_grade = Some(grade); + clip.color_match_input = Some(input.clone()); + }) +} + +fn set_lut( + state: &mut EditorState, + clip_ids: Vec, + lut: Option, +) -> Result { + if let Some(reference) = &lut { + reference + .validate() + .map_err(|error| EditError::Invalid(format!("invalid LUT reference: {error}")))?; + } + reject_compound_effect_targets(state, &clip_ids, lut.is_some())?; + set_clip_effect_field(state, clip_ids, "Set LUT", move |clip| { + clip.lut = lut.clone(); }) } @@ -1840,6 +5377,7 @@ fn set_chroma_key( clip_ids: Vec, chroma_key: Option, ) -> Result { + reject_compound_effect_targets(state, &clip_ids, chroma_key.is_some())?; set_clip_effect_field(state, clip_ids, "Set Chroma Key", move |clip| { clip.chroma_key = chroma_key; }) @@ -1850,6 +5388,21 @@ fn set_masks( clip_ids: Vec, masks: Vec, ) -> Result { + if masks.len() > MAX_MASKS_PER_CLIP { + return Err(EditError::Invalid(format!( + "a clip supports at most {MAX_MASKS_PER_CLIP} masks" + ))); + } + for (index, mask) in masks.iter().enumerate() { + if let MaskShape::Poly { points } = &mask.shape { + if points.len() < 3 || points.len() > MAX_POLYGON_MASK_POINTS { + return Err(EditError::Invalid(format!( + "mask {index} polygon must contain 3..={MAX_POLYGON_MASK_POINTS} points" + ))); + } + } + } + reject_compound_effect_targets(state, &clip_ids, !masks.is_empty())?; set_clip_effect_field(state, clip_ids, "Set Masks", move |clip| { clip.masks = masks.clone(); }) @@ -1860,11 +5413,287 @@ fn set_effects( clip_ids: Vec, effects: Vec, ) -> Result { + opentake_domain::validate_effect_chain(&effects) + .map_err(|error| EditError::Invalid(error.to_string()))?; + reject_compound_effect_targets(state, &clip_ids, !effects.is_empty())?; set_clip_effect_field(state, clip_ids, "Set Effects", move |clip| { clip.effects = effects.clone(); }) } +fn set_loudness_normalization( + state: &mut EditorState, + clip_id: String, + normalization: Option, +) -> Result { + let location = state + .find_clip(&clip_id) + .ok_or_else(|| EditError::Invalid(format!("Clip not found: {clip_id}")))?; + let clip = &state.timeline.tracks[location.track_index].clips[location.clip_index]; + if !matches!(clip.media_type, ClipType::Audio | ClipType::Video) + || clip.nested_sequence_id.is_some() + { + return Err(EditError::Invalid( + "loudness normalization requires an ordinary audio-bearing clip".to_string(), + )); + } + if let Some(value) = normalization { + value.validate().map_err(|error| { + EditError::Invalid(format!("invalid loudness normalization: {error}")) + })?; + } + transact( + state, + if normalization.is_some() { + "Normalize Loudness" + } else { + "Reset Loudness" + }, + |_| "Updated clip loudness".to_string(), + move |st| { + let (track_index, clip_index) = find(&st.timeline, &clip_id) + .ok_or_else(|| EditError::Invalid(format!("Clip not found: {clip_id}")))?; + st.timeline.tracks[track_index].clips[clip_index].loudness_normalization = + normalization; + Ok(vec![clip_id.clone()]) + }, + ) +} + +fn set_audio_denoise( + state: &mut EditorState, + clip_id: String, + denoise: Option, +) -> Result { + let location = state + .find_clip(&clip_id) + .ok_or_else(|| EditError::Invalid(format!("Clip not found: {clip_id}")))?; + let clip = &state.timeline.tracks[location.track_index].clips[location.clip_index]; + if !matches!(clip.media_type, ClipType::Audio | ClipType::Video) + || clip.nested_sequence_id.is_some() + { + return Err(EditError::Invalid( + "audio denoise requires an ordinary audio-bearing clip".to_string(), + )); + } + if let Some(value) = denoise { + value + .validate() + .map_err(|error| EditError::Invalid(format!("invalid audio denoise: {error}")))?; + } + transact( + state, + if denoise.is_some() { + "Apply Audio Denoise" + } else { + "Reset Audio Denoise" + }, + |_| "Updated clip audio denoise".to_string(), + move |st| { + let (track_index, clip_index) = find(&st.timeline, &clip_id) + .ok_or_else(|| EditError::Invalid(format!("Clip not found: {clip_id}")))?; + st.timeline.tracks[track_index].clips[clip_index].audio_denoise = denoise; + Ok(vec![clip_id.clone()]) + }, + ) +} + +fn stabilization_clip<'a>(state: &'a EditorState, clip_id: &str) -> Result<&'a Clip, EditError> { + let location = state + .find_clip(clip_id) + .ok_or_else(|| EditError::Invalid(format!("Clip not found: {clip_id}")))?; + let clip = &state.timeline.tracks[location.track_index].clips[location.clip_index]; + if clip.media_type != ClipType::Video || clip.nested_sequence_id.is_some() { + return Err(EditError::Invalid(format!( + "stabilization requires an ordinary video clip: {clip_id}" + ))); + } + Ok(clip) +} + +fn apply_stabilization( + state: &mut EditorState, + clip_id: String, + solution: StabilizationTrack, +) -> Result { + solution.validate().map_err(EditError::Invalid)?; + let clip = stabilization_clip(state, &clip_id)?; + if solution.source_identity != clip.media_ref { + return Err(EditError::Invalid(format!( + "stabilization source identity {} does not match clip source {}", + solution.source_identity, clip.media_ref + ))); + } + set_clip_effect_field(state, vec![clip_id], "Apply Stabilization", move |clip| { + clip.stabilization = Some(solution.clone()); + }) +} + +fn adjust_stabilization( + state: &mut EditorState, + clip_id: String, + strength: Option, + crop_margin: Option, +) -> Result { + if strength.is_none() && crop_margin.is_none() { + return Err(EditError::Invalid( + "strength or cropMargin is required".to_string(), + )); + } + if strength.is_some_and(|value| !value.is_finite() || !(0.0..=1.0).contains(&value)) { + return Err(EditError::Invalid( + "stabilization strength must be finite and within 0..=1".to_string(), + )); + } + if crop_margin.is_some_and(|value| !value.is_finite() || !(0.0..=0.5).contains(&value)) { + return Err(EditError::Invalid( + "stabilization crop margin must be finite and within 0..=0.5".to_string(), + )); + } + let clip = stabilization_clip(state, &clip_id)?; + if clip.stabilization.is_none() { + return Err(EditError::Invalid(format!( + "Clip has no stabilization analysis: {clip_id}" + ))); + } + set_clip_effect_field(state, vec![clip_id], "Adjust Stabilization", move |clip| { + if let Some(solution) = &mut clip.stabilization { + if let Some(value) = strength { + solution.strength = value; + } + if let Some(value) = crop_margin { + solution.crop_margin = value; + } + } + }) +} + +fn reset_stabilization(state: &mut EditorState, clip_id: String) -> Result { + stabilization_clip(state, &clip_id)?; + set_clip_effect_field(state, vec![clip_id], "Reset Stabilization", |clip| { + clip.stabilization = None; + }) +} + +fn set_transition( + state: &mut EditorState, + from_clip_id: String, + to_clip_id: String, + kind: Option, + duration_frames: i32, +) -> Result { + let from_location = state + .find_clip(&from_clip_id) + .ok_or_else(|| EditError::Invalid(format!("Clip not found: {from_clip_id}")))?; + + if kind.is_some() + && state.timeline.tracks[from_location.track_index].clips[from_location.clip_index] + .nested_sequence_id + .is_some() + { + return Err(EditError::Invalid( + "compound clips do not support direct transitions".into(), + )); + } + + // Clearing is allowed even after the pair stopped being adjacent so stale + // metadata can always be removed safely. Pair identity must still match. + if kind.is_none() { + return transact( + state, + "Remove Transition", + |_| "Removed transition".to_string(), + |st| { + let clip = &mut st.timeline.tracks[from_location.track_index].clips + [from_location.clip_index]; + if clip.transition_out.as_ref().is_some_and(|transition| { + (transition.from_clip_id.is_empty() || transition.from_clip_id == from_clip_id) + && transition.to_clip_id == to_clip_id + }) { + clip.transition_out = None; + } + Ok(vec![from_clip_id.clone(), to_clip_id.clone()]) + }, + ); + } + + if duration_frames < 1 { + return Err(EditError::Invalid(format!( + "durationFrames must be >= 1 (got {duration_frames})" + ))); + } + let to_location = state + .find_clip(&to_clip_id) + .ok_or_else(|| EditError::Invalid(format!("Clip not found: {to_clip_id}")))?; + if state.timeline.tracks[to_location.track_index].clips[to_location.clip_index] + .nested_sequence_id + .is_some() + { + return Err(EditError::Invalid( + "compound clips do not support direct transitions".into(), + )); + } + if from_location.track_index != to_location.track_index { + return Err(EditError::Invalid( + "A transition requires clips on the same track".into(), + )); + } + let track = &state.timeline.tracks[from_location.track_index]; + if track.kind == ClipType::Audio { + return Err(EditError::Invalid( + "Visual transitions are unavailable on audio tracks".into(), + )); + } + let from = &track.clips[from_location.clip_index]; + let to = &track.clips[to_location.clip_index]; + if matches!(from.media_type, ClipType::Audio | ClipType::Text) + || matches!(to.media_type, ClipType::Audio | ClipType::Text) + { + return Err(EditError::Invalid( + "A transition requires two visual source clips".into(), + )); + } + if from.end_frame() != to.start_frame { + return Err(EditError::Invalid( + "A transition requires an exact adjacent clip boundary".into(), + )); + } + let mut ordered: Vec<&opentake_domain::Clip> = track.clips.iter().collect(); + ordered.sort_by_key(|clip| (clip.start_frame, clip.id.as_str())); + let successor = ordered + .iter() + .position(|clip| clip.id == from_clip_id) + .and_then(|index| ordered.get(index + 1)); + if successor.map(|clip| clip.id.as_str()) != Some(to_clip_id.as_str()) { + return Err(EditError::Invalid( + "A transition requires the immediate next clip".into(), + )); + } + + let maximum = (from.duration_frames.min(to.duration_frames) / 2).max(1); + if duration_frames > maximum { + return Err(EditError::Invalid(format!( + "durationFrames exceeds the available transition handle ({duration_frames} > {maximum})" + ))); + } + let kind = kind.expect("kind checked above"); + transact( + state, + "Set Transition", + |_| format!("Set transition from {from_clip_id} to {to_clip_id}"), + |st| { + st.timeline.tracks[from_location.track_index].clips[from_location.clip_index] + .transition_out = Some(Transition { + from_clip_id: from_clip_id.clone(), + to_clip_id: to_clip_id.clone(), + kind, + duration_frames, + }); + Ok(vec![from_clip_id.clone(), to_clip_id.clone()]) + }, + ) +} + fn ripple_delete_ranges( state: &mut EditorState, track_index: usize, @@ -1879,6 +5708,9 @@ fn ripple_delete_ranges( "Track index out of range: {track_index}" ))); } + validate_timeline_frame_arithmetic(&state.timeline, "timeline")?; + ops::ripple::validate_ripple_delete_ranges(&state.timeline, track_index, &ranges) + .map_err(EditError::Invalid)?; // Run the op outside transact so a refusal aborts before any snapshot/commit. let before = state.snapshot(); let outcome = ops::ripple::ripple_delete_ranges_on_track( @@ -1896,10 +5728,14 @@ fn ripple_delete_ranges( Err(EditError::Refused(reason)) } RippleOutcome::Ok(report) => { + if let Err(error) = validate_timeline_frame_arithmetic(&state.timeline, "timeline") { + state.restore(before); + return Err(error); + } let after = state.snapshot(); let changed = before != after; if changed { - state.commit(before); + state.commit(before, "Ripple Delete"); } let summary = format!( "Removed {} frame(s) across {} track(s), shifted {} clip(s)", @@ -1934,6 +5770,7 @@ fn ripple_delete_clips( "Missing or empty 'clipIds' array".into(), )); } + validate_timeline_frame_arithmetic(&state.timeline, "timeline")?; for id in &clip_ids { if state.find_clip(id).is_none() { return Err(EditError::Invalid(format!("Clip not found: {id}"))); @@ -1948,10 +5785,14 @@ fn ripple_delete_clips( Err(EditError::Refused(reason)) } Ok(()) => { + if let Err(error) = validate_timeline_frame_arithmetic(&state.timeline, "timeline") { + state.restore(before); + return Err(error); + } let after = state.snapshot(); let changed = before != after; if changed { - state.commit(before); + state.commit(before, "Ripple Delete"); } let affected: Vec = id_set.iter().cloned().collect(); let n = affected.len(); @@ -1977,6 +5818,7 @@ fn add_texts( "Missing or empty 'entries' array".into(), )); } + let mut entry_ends = Vec::with_capacity(entries.len()); for (i, e) in entries.iter().enumerate() { if e.track_index >= state.timeline.tracks.len() { return Err(EditError::Invalid(format!( @@ -1990,18 +5832,15 @@ fn add_texts( e.track_index ))); } - if e.duration_frames < 1 { - return Err(EditError::Invalid(format!( - "entries[{i}]: durationFrames must be >= 1 (got {})", - e.duration_frames - ))); - } - if e.start_frame < 0 { - return Err(EditError::Invalid(format!( - "entries[{i}]: startFrame must be >= 0 (got {})", - e.start_frame - ))); - } + entry_ends.push(checked_clip_frame_arithmetic( + e.start_frame, + e.duration_frames, + 0, + 0, + 1.0, + ClipType::Text, + &format!("entries[{i}]"), + )?); } let action_name = if entries.len() == 1 { "Add Text" @@ -2014,17 +5853,10 @@ fn add_texts( |c| format!("Added {} text clip(s): {}", c.len(), c.join(", ")), |st| { let mut added = Vec::new(); - for e in &entries { + for (e, end_frame) in entries.iter().zip(&entry_ends) { let track_id = st.timeline.tracks[e.track_index].id.clone(); if let Some(ti) = st.track_index(&track_id) { - ops::clear_region( - &mut st.timeline, - ti, - e.start_frame, - e.start_frame + e.duration_frames, - false, - ids, - ); + ops::clear_region(&mut st.timeline, ti, e.start_frame, *end_frame, false, ids); } if let Some(ti) = st.track_index(&track_id) { let mut clip = opentake_domain::Clip::new( @@ -2068,20 +5900,21 @@ fn add_texts_auto_track( "Missing or empty 'entries' array".into(), )); } - for (i, e) in entries.iter().enumerate() { - if e.duration_frames < 1 { - return Err(EditError::Invalid(format!( - "entries[{i}]: durationFrames must be >= 1 (got {})", - e.duration_frames - ))); - } - if e.start_frame < 0 { - return Err(EditError::Invalid(format!( - "entries[{i}]: startFrame must be >= 0 (got {})", - e.start_frame - ))); - } - } + let entry_ends: Vec = entries + .iter() + .enumerate() + .map(|(index, entry)| { + checked_clip_frame_arithmetic( + entry.start_frame, + entry.duration_frames, + 0, + 0, + 1.0, + ClipType::Text, + &format!("entries[{index}]"), + ) + }) + .collect::>()?; let action_name = if entries.len() == 1 { "Add Text" } else { @@ -2099,15 +5932,8 @@ fn add_texts_auto_track( opentake_domain::Track::new(ids.next_id(), ClipType::Video), ); let mut added = Vec::with_capacity(entries.len()); - for e in &entries { - ops::clear_region( - &mut st.timeline, - 0, - e.start_frame, - e.start_frame + e.duration_frames, - false, - ids, - ); + for (e, end_frame) in entries.iter().zip(&entry_ends) { + ops::clear_region(&mut st.timeline, 0, e.start_frame, *end_frame, false, ids); let mut clip = opentake_domain::Clip::new(ids.next_id(), "", e.start_frame, e.duration_frames); clip.media_type = ClipType::Text; @@ -2149,19 +5975,16 @@ fn add_captions( "", )); } - for (i, e) in entries.iter().enumerate() { - if e.duration_frames < 1 { - return Err(EditError::Invalid(format!( - "entries[{i}]: durationFrames must be >= 1 (got {})", - e.duration_frames - ))); - } - if e.start_frame < 0 { - return Err(EditError::Invalid(format!( - "entries[{i}]: startFrame must be >= 0 (got {})", - e.start_frame - ))); - } + for (index, entry) in entries.iter().enumerate() { + checked_clip_frame_arithmetic( + entry.start_frame, + entry.duration_frames, + 0, + 0, + 1.0, + ClipType::Text, + &format!("entries[{index}]"), + )?; } transact( state, @@ -2583,6 +6406,8 @@ fn swap_media( if let Some(loc) = st.find_clip(tid) { st.timeline.tracks[loc.track_index].clips[loc.clip_index].media_ref = media_ref.clone(); + st.timeline.tracks[loc.track_index].clips[loc.clip_index] + .loudness_normalization = None; affected.push(tid.clone()); } } @@ -2629,6 +6454,7 @@ fn set_timeline_settings_cmd( "timeline settings must be positive (got fps={fps}, width={width}, height={height})" ))); } + validate_settings_frame_projection(&state.timeline, fps, "timeline")?; transact( state, "Change Project Settings", @@ -2642,7 +6468,7 @@ fn set_timeline_settings_cmd( // MARK: - Small local helpers -fn validate_entry(state: &EditorState, e: &ClipEntry, i: usize) -> Result<(), EditError> { +fn validate_entry(state: &EditorState, e: &ClipEntry, i: usize) -> Result { if e.track_index >= state.timeline.tracks.len() { return Err(EditError::Invalid(format!( "entries[{i}]: track index {} out of range", @@ -2650,78 +6476,45 @@ fn validate_entry(state: &EditorState, e: &ClipEntry, i: usize) -> Result<(), Ed ))); } let target = state.timeline.tracks[e.track_index].kind; - if !e.source_clip_type.is_compatible(target) { + // Destination compatibility is determined by the placed lane type. A + // linked audio clip can legitimately retain `source_clip_type = Video` + // because it still resolves audio from the original video asset. + if !e.media_type.is_compatible(target) { return Err(EditError::Invalid(format!( "entries[{i}]: asset type is not compatible with the destination track" ))); } - if e.duration_frames < 1 { - return Err(EditError::Invalid(format!( - "entries[{i}]: durationFrames must be >= 1 (got {})", - e.duration_frames - ))); - } - if e.start_frame < 0 { - return Err(EditError::Invalid(format!( - "entries[{i}]: startFrame must be >= 0 (got {})", - e.start_frame - ))); - } - if let Some(t) = e.trim_start_frame { - if t < 0 { - return Err(EditError::Invalid(format!( - "entries[{i}]: trimStartFrame must be >= 0 (got {t})" - ))); - } - } - if let Some(t) = e.trim_end_frame { - if t < 0 { - return Err(EditError::Invalid(format!( - "entries[{i}]: trimEndFrame must be >= 0 (got {t})" - ))); - } - } - Ok(()) + checked_clip_frame_arithmetic( + e.start_frame, + e.duration_frames, + e.trim_start_frame.unwrap_or(0), + e.trim_end_frame.unwrap_or(0), + 1.0, + e.media_type, + &format!("entries[{i}]"), + ) } -fn validate_auto_track_entry(e: &ClipEntry, i: usize) -> Result<(), EditError> { - let target = if e.source_clip_type == ClipType::Audio { +fn validate_auto_track_entry(e: &ClipEntry, i: usize) -> Result { + let target = if e.media_type == ClipType::Audio { ClipType::Audio } else { ClipType::Video }; - if !e.source_clip_type.is_compatible(target) { + if !e.media_type.is_compatible(target) { return Err(EditError::Invalid(format!( "entries[{i}]: asset type is not compatible with an auto-created track" ))); } - if e.duration_frames < 1 { - return Err(EditError::Invalid(format!( - "entries[{i}]: durationFrames must be >= 1 (got {})", - e.duration_frames - ))); - } - if e.start_frame < 0 { - return Err(EditError::Invalid(format!( - "entries[{i}]: startFrame must be >= 0 (got {})", - e.start_frame - ))); - } - if let Some(t) = e.trim_start_frame { - if t < 0 { - return Err(EditError::Invalid(format!( - "entries[{i}]: trimStartFrame must be >= 0 (got {t})" - ))); - } - } - if let Some(t) = e.trim_end_frame { - if t < 0 { - return Err(EditError::Invalid(format!( - "entries[{i}]: trimEndFrame must be >= 0 (got {t})" - ))); - } - } - Ok(()) + checked_clip_frame_arithmetic( + e.start_frame, + e.duration_frames, + e.trim_start_frame.unwrap_or(0), + e.trim_end_frame.unwrap_or(0), + 1.0, + e.media_type, + &format!("entries[{i}]"), + ) } fn empty_to_none( @@ -4248,6 +8041,363 @@ mod add_captions_tests { // State untouched by the refusal. assert_eq!(state.timeline.tracks.len(), 2); } + + #[test] + fn caption_translation_preserves_identity_timing_and_is_one_undo_step() { + let mut state = state_with_video_and_audio(); + let ids = SeqIdGen::new("cap-"); + apply( + &mut state, + EditCommand::AddCaptions { + entries: vec![caption("Hello", 3, 17, "g"), caption("World", 25, 19, "g")], + }, + &ids, + ) + .unwrap(); + let captions = state.timeline.tracks[0].clips.clone(); + let changes = captions + .iter() + .zip(["你好", "世界"]) + .map(|(clip, translated)| { + let source = clip.text_content.clone().unwrap(); + CaptionTranslationChange { + clip_id: clip.id.clone(), + expected_source_text: source.clone(), + translated_text: translated.into(), + input: CaptionTranslationInput { + source_text: source, + source_locale: "en-US".into(), + target_locale: "zh-CN".into(), + provider: "mock".into(), + model: "mock-v1".into(), + }, + } + }) + .collect(); + let result = apply( + &mut state, + EditCommand::ApplyCaptionTranslations { changes }, + &ids, + ) + .unwrap(); + assert_eq!(result.action_name, "Translate Captions"); + for (before, after) in captions.iter().zip(&state.timeline.tracks[0].clips) { + assert_eq!(after.id, before.id); + assert_eq!(after.start_frame, before.start_frame); + assert_eq!(after.duration_frames, before.duration_frames); + assert_eq!(after.caption_group_id, before.caption_group_id); + assert_eq!( + after + .caption_translation_input + .as_ref() + .unwrap() + .source_text, + before.text_content.clone().unwrap() + ); + } + apply(&mut state, EditCommand::Undo, &ids).unwrap(); + assert_eq!(state.timeline.tracks[0].clips, captions); + } + + #[test] + fn caption_translation_stale_batch_is_atomic_and_manual_edit_clears_provenance() { + let mut state = state_with_video_and_audio(); + let ids = SeqIdGen::new("cap-"); + apply( + &mut state, + EditCommand::AddCaptions { + entries: vec![caption("One", 0, 10, "g"), caption("Two", 10, 10, "g")], + }, + &ids, + ) + .unwrap(); + let before = state.timeline.clone(); + let caption_ids: Vec<_> = state.timeline.tracks[0] + .clips + .iter() + .map(|clip| clip.id.clone()) + .collect(); + let change = |clip_id: String, source: &str, translated: &str| CaptionTranslationChange { + clip_id, + expected_source_text: source.into(), + translated_text: translated.into(), + input: CaptionTranslationInput { + source_text: source.into(), + source_locale: "en".into(), + target_locale: "fr".into(), + provider: "mock".into(), + model: "mock-v1".into(), + }, + }; + assert!(apply( + &mut state, + EditCommand::ApplyCaptionTranslations { + changes: vec![ + change(caption_ids[0].clone(), "One", "Un"), + change(caption_ids[1].clone(), "stale", "Deux"), + ], + }, + &ids, + ) + .is_err()); + assert_eq!(state.timeline, before); + + apply( + &mut state, + EditCommand::ApplyCaptionTranslations { + changes: vec![change(caption_ids[0].clone(), "One", "Un")], + }, + &ids, + ) + .unwrap(); + apply( + &mut state, + EditCommand::SetClipProperties { + clip_ids: vec![caption_ids[0].clone()], + properties: Box::new(ClipProperties { + text_content: Some("Edited".into()), + ..ClipProperties::default() + }), + }, + &ids, + ) + .unwrap(); + assert!(state.timeline.tracks[0].clips[0] + .caption_translation_input + .is_none()); + } +} + +#[cfg(test)] +mod script_assembly_command_tests { + use super::*; + use crate::id::SeqIdGen; + use opentake_domain::{MediaSource, ScriptAssemblySegment}; + + fn media(id: &str, kind: ClipType, duration: f64, has_audio: bool) -> MediaManifestEntry { + MediaManifestEntry { + id: id.into(), + name: id.into(), + kind, + source: MediaSource::Project { + relative_path: format!("media/{id}"), + }, + duration, + generation_input: None, + source_width: None, + source_height: None, + source_fps: None, + has_audio: Some(has_audio), + color: None, + proxy: None, + folder_id: None, + cached_remote_url: None, + cached_remote_url_expires_at: None, + } + } + + fn plan() -> ScriptAssemblyPlan { + ScriptAssemblyPlan { + id: "script-plan-test".into(), + plan_hash: "a".repeat(64), + planner: "test-planner".into(), + planner_version: 1, + start_frame: 0, + segments: (0..3) + .map(|index| ScriptAssemblySegment { + script: format!("Scene {}", index + 1), + media_ref: format!("visual-{index}"), + narration_media_ref: Some(format!("voice-{index}")), + duration_frames: 30, + transition: (index < 2).then_some(TransitionKind::CrossDissolve), + }) + .collect(), + } + } + + fn state() -> EditorState { + let mut state = EditorState::default(); + state.timeline.settings_configured = true; + for index in 0..3 { + state.manifest.entries.push(media( + &format!("visual-{index}"), + ClipType::Image, + 0.0, + false, + )); + state.manifest.entries.push(media( + &format!("voice-{index}"), + ClipType::Audio, + 1.0, + true, + )); + } + state + } + + #[test] + fn reviewed_three_segment_plan_applies_tracks_sync_transitions_and_one_undo() { + let mut state = state(); + let ids = SeqIdGen::new("script-"); + apply( + &mut state, + EditCommand::SaveScriptAssemblyPlan { plan: plan() }, + &ids, + ) + .unwrap(); + assert!(state.timeline.tracks.is_empty()); + assert_eq!(state.timeline.script_assembly_plans, vec![plan()]); + let undo_depth_before_apply = state.undo_depth(); + let result = apply( + &mut state, + EditCommand::ApplyScriptAssemblyPlan { + plan_id: "script-plan-test".into(), + }, + &ids, + ) + .unwrap(); + assert_eq!(result.action_name, "Build Script Video"); + assert_eq!(state.undo_depth(), undo_depth_before_apply + 1); + assert_eq!(state.timeline.tracks.len(), 2); + let visual = &state.timeline.tracks[0]; + let narration = &state.timeline.tracks[1]; + assert_eq!(visual.kind, ClipType::Video); + assert_eq!(narration.kind, ClipType::Audio); + assert_eq!(visual.clips.len(), 3); + assert_eq!(narration.clips.len(), 3); + for index in 0..3 { + assert_eq!(visual.clips[index].start_frame, index as i32 * 30); + assert_eq!(narration.clips[index].start_frame, index as i32 * 30); + assert_eq!(visual.clips[index].duration_frames, 30); + assert_eq!(narration.clips[index].duration_frames, 30); + assert_eq!(visual.clips[index].volume, 0.0); + } + for index in 0..2 { + let transition = visual.clips[index].transition_out.as_ref().unwrap(); + assert_eq!(transition.from_clip_id, visual.clips[index].id); + assert_eq!(transition.to_clip_id, visual.clips[index + 1].id); + assert_eq!(transition.kind, TransitionKind::CrossDissolve); + assert_eq!(transition.duration_frames, 12); + } + assert!(visual.clips[2].transition_out.is_none()); + + apply(&mut state, EditCommand::Undo, &ids).unwrap(); + assert!(state.timeline.tracks.is_empty()); + assert_eq!(state.timeline.script_assembly_plans, vec![plan()]); + } + + #[test] + fn missing_media_refuses_atomically_and_narration_must_match_within_one_frame() { + let mut state = state(); + let ids = SeqIdGen::new("script-"); + let mut invalid = plan(); + invalid.id = "missing-plan".into(); + invalid.segments[1].media_ref = "missing".into(); + apply( + &mut state, + EditCommand::SaveScriptAssemblyPlan { + plan: invalid.clone(), + }, + &ids, + ) + .unwrap(); + let before = state.timeline.clone(); + assert!(apply( + &mut state, + EditCommand::ApplyScriptAssemblyPlan { + plan_id: invalid.id, + }, + &ids, + ) + .is_err()); + assert_eq!(state.timeline, before); + + let mut mismatch = plan(); + mismatch.id = "mismatch-plan".into(); + state + .manifest + .entries + .iter_mut() + .find(|entry| entry.id == "voice-0") + .unwrap() + .duration = 2.0; + apply( + &mut state, + EditCommand::SaveScriptAssemblyPlan { + plan: mismatch.clone(), + }, + &ids, + ) + .unwrap(); + let before = state.timeline.clone(); + assert!(apply( + &mut state, + EditCommand::ApplyScriptAssemblyPlan { + plan_id: mismatch.id, + }, + &ids, + ) + .is_err()); + assert_eq!(state.timeline, before); + } + + #[test] + fn script_span_and_media_duration_overflow_reject_before_ids_or_history() { + let mut state = state(); + let ids = SeqIdGen::new("script-overflow-"); + + let mut overflowing = plan(); + overflowing.start_frame = i32::MAX - 10; + let before_save = state.snapshot(); + let save_result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + apply( + &mut state, + EditCommand::SaveScriptAssemblyPlan { plan: overflowing }, + &ids, + ) + })); + assert!(save_result.is_ok()); + assert!(save_result.unwrap().is_err()); + assert_eq!(state.snapshot(), before_save); + assert_eq!(state.version(), 0); + assert_eq!(state.undo_depth(), 0); + assert_eq!(ids.count(), 0); + + let valid = plan(); + apply( + &mut state, + EditCommand::SaveScriptAssemblyPlan { + plan: valid.clone(), + }, + &ids, + ) + .unwrap(); + state + .manifest + .entries + .iter_mut() + .find(|entry| entry.id == "voice-0") + .unwrap() + .duration = f64::INFINITY; + let before_apply = state.snapshot(); + let before_version = state.version(); + let before_undo_depth = state.undo_depth(); + let ids_before = ids.count(); + + let apply_result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + apply( + &mut state, + EditCommand::ApplyScriptAssemblyPlan { plan_id: valid.id }, + &ids, + ) + })); + assert!(apply_result.is_ok()); + assert!(apply_result.unwrap().is_err()); + assert_eq!(state.snapshot(), before_apply); + assert_eq!(state.version(), before_version); + assert_eq!(state.undo_depth(), before_undo_depth); + assert_eq!(ids.count(), ids_before); + } } /// Tests for [`EditCommand::AddTextsAutoTrack`] (#194): the all-omitted- @@ -4432,7 +8582,7 @@ mod add_texts_auto_track_tests { mod freeze_frame_tests { use super::*; use crate::id::SeqIdGen; - use opentake_domain::{Clip, Track}; + use opentake_domain::{Clip, MediaAsset, Track}; fn state_with_video_clip() -> (EditorState, SeqIdGen) { let mut state = EditorState::default(); @@ -4452,6 +8602,69 @@ mod freeze_frame_tests { } } + fn captured_still(id: &str) -> MediaManifestEntry { + MediaAsset::new( + id, + format!("/tmp/{id}.png"), + ClipType::Image, + "Captured frame", + 0.0, + ) + .to_manifest_entry(None, 0.0) + } + + #[test] + fn registered_freeze_frame_is_one_atomic_undo_unit() { + let (mut state, ids) = state_with_video_clip(); + let before = state.clone(); + + let result = apply( + &mut state, + EditCommand::RegisterMediaAndFreezeFrame { + media: captured_still("freeze-asset"), + clip_id: "c1".to_string(), + at_frame: 130, + duration_frames: 30, + }, + &ids, + ) + .unwrap(); + + assert!(result.timeline_changed); + assert!(result.manifest_changed); + assert_eq!(result.action_name, "Freeze Frame"); + assert_eq!(state.undo_depth(), before.undo_depth() + 1); + assert_eq!(state.manifest.entries.len(), 1); + assert_eq!(state.timeline.tracks[0].clips[1].media_ref, "freeze-asset"); + + apply(&mut state, EditCommand::Undo, &ids).unwrap(); + assert_eq!(state.timeline, before.timeline); + assert_eq!(state.manifest, before.manifest); + } + + #[test] + fn rejected_registered_freeze_leaves_manifest_and_history_unchanged() { + let (mut state, ids) = state_with_video_clip(); + let before = state.clone(); + + apply( + &mut state, + EditCommand::RegisterMediaAndFreezeFrame { + media: captured_still("orphan"), + clip_id: "missing".to_string(), + at_frame: 130, + duration_frames: 30, + }, + &ids, + ) + .expect_err("invalid source clip must reject the whole transaction"); + + assert_eq!(state.timeline, before.timeline); + assert_eq!(state.manifest, before.manifest); + assert_eq!(state.undo_depth(), before.undo_depth()); + assert_eq!(state.version(), before.version()); + } + #[test] fn freeze_frame_splits_and_inserts_image_clip() { let (mut state, ids) = state_with_video_clip(); diff --git a/crates/opentake-ops/src/editor_state.rs b/crates/opentake-ops/src/editor_state.rs index f6693160..76d502b3 100644 --- a/crates/opentake-ops/src/editor_state.rs +++ b/crates/opentake-ops/src/editor_state.rs @@ -20,13 +20,20 @@ pub struct DocSnapshot { pub manifest: MediaManifest, } +#[derive(Clone, Debug)] +struct HistoryEntry { + snapshot: DocSnapshot, + action_name: String, + transaction_version: u64, +} + /// The editable document + undo/redo history + version. #[derive(Clone, Debug)] pub struct EditorState { pub timeline: Timeline, pub manifest: MediaManifest, - undo_stack: Vec, - redo_stack: Vec, + undo_stack: Vec, + redo_stack: Vec, version: u64, } @@ -64,6 +71,23 @@ impl EditorState { !self.undo_stack.is_empty() } + /// Label of the most recent undoable transaction. Agent-only undo uses this + /// together with an exact project revision so it never consumes a user's + /// intervening edit, including one that happens to have the same label. + pub fn undo_action_name(&self) -> Option<&str> { + self.undo_stack + .last() + .map(|entry| entry.action_name.as_str()) + } + + /// Document version created by the transaction currently at the top of the + /// undo stack. This stable identity disambiguates equal action labels. + pub fn undo_transaction_version(&self) -> Option { + self.undo_stack + .last() + .map(|entry| entry.transaction_version) + } + /// Whether a redo is available. pub fn can_redo(&self) -> bool { !self.redo_stack.is_empty() @@ -91,8 +115,20 @@ impl EditorState { /// Commit a structural change: push `before` onto the undo stack, clear the /// redo stack (a new edit invalidates redo), bump the version. Called only /// when `before != after`. - pub(crate) fn commit(&mut self, before: DocSnapshot) { - self.undo_stack.push(before); + pub(crate) fn commit(&mut self, before: DocSnapshot, action_name: impl Into) { + self.undo_stack.push(HistoryEntry { + snapshot: before, + action_name: action_name.into(), + transaction_version: self.version.saturating_add(1), + }); + self.redo_stack.clear(); + self.version += 1; + } + + /// Commit an irreversible audit mutation without adding an undo entry. + /// Earlier undo snapshots are retained, but restore paths keep provider + /// voice records outside ordinary document undo/redo. + pub(crate) fn commit_irreversible(&mut self) { self.redo_stack.clear(); self.version += 1; } @@ -101,28 +137,44 @@ impl EditorState { /// undone. Pushes the pre-undo document onto the redo stack and bumps the /// version. pub(crate) fn undo(&mut self) -> bool { - let Some(prev) = self.undo_stack.pop() else { - return false; - }; let current = self.snapshot(); - self.restore(prev); - self.redo_stack.push(current); - self.version += 1; - true + while let Some(mut entry) = self.undo_stack.pop() { + preserve_voice_models(&mut entry.snapshot, ¤t); + if entry.snapshot == current { + continue; + } + self.restore(entry.snapshot); + self.redo_stack.push(HistoryEntry { + snapshot: current, + action_name: entry.action_name, + transaction_version: entry.transaction_version, + }); + self.version += 1; + return true; + } + false } /// Redo the most recently undone change. Returns `true` if anything was /// redone. Pushes the pre-redo document onto the undo stack and bumps the /// version. pub(crate) fn redo(&mut self) -> bool { - let Some(next) = self.redo_stack.pop() else { - return false; - }; let current = self.snapshot(); - self.restore(next); - self.undo_stack.push(current); - self.version += 1; - true + while let Some(mut entry) = self.redo_stack.pop() { + preserve_voice_models(&mut entry.snapshot, ¤t); + if entry.snapshot == current { + continue; + } + self.restore(entry.snapshot); + self.undo_stack.push(HistoryEntry { + snapshot: current, + action_name: entry.action_name, + transaction_version: self.version.saturating_add(1), + }); + self.version += 1; + return true; + } + false } // MARK: - Lookups (1:1 port of EditorViewModel.findClip) @@ -143,10 +195,14 @@ impl EditorState { } } +fn preserve_voice_models(target: &mut DocSnapshot, current: &DocSnapshot) { + target.timeline.voice_models = current.timeline.voice_models.clone(); +} + #[cfg(test)] mod tests { use super::*; - use opentake_domain::{Clip, ClipType, Track}; + use opentake_domain::{Clip, ClipType, Track, VoiceModelRecord}; fn state_with_clip() -> EditorState { let mut tl = Timeline::new(); @@ -177,7 +233,7 @@ mod tests { let before = s.snapshot(); // mutate then commit s.timeline.tracks[0].clips[0].start_frame = 99; - s.commit(before); + s.commit(before, "Move Clip"); assert_eq!(s.version(), 1); assert!(s.can_undo()); assert!(!s.can_redo()); @@ -194,18 +250,73 @@ mod tests { assert_eq!(s.version(), 3); } + #[test] + fn permanent_voice_revocation_survives_all_undo_snapshots() { + let mut state = EditorState::default(); + let before_enroll = state.snapshot(); + state.timeline.voice_models.push(VoiceModelRecord { + id: "voice-1".into(), + provider: "elevenlabs".into(), + provider_voice_id: "provider-1".into(), + model: "model".into(), + consent_id: "consent-1".into(), + source_audio_asset_id: "audio-1".into(), + source_audio_sha256: "a".repeat(64), + request_hash: "b".repeat(64), + voice_name: "Narrator".into(), + revoked: false, + }); + state.commit(before_enroll, "Enroll Voice"); + state.timeline.voice_models[0].revoked = true; + state.commit_irreversible(); + + let version = state.version(); + assert!(!state.undo()); + assert_eq!(state.timeline.voice_models.len(), 1); + assert!(state.timeline.voice_models[0].revoked); + assert!(!state.can_undo()); + assert!(!state.redo()); + assert_eq!(state.version(), version); + } + + #[test] + fn active_provider_voice_survives_undo_of_an_earlier_edit() { + let mut state = state_with_clip(); + let before_edit = state.snapshot(); + state.timeline.tracks[0].clips[0].start_frame = 12; + state.commit(before_edit, "Move Clip"); + state.timeline.voice_models.push(VoiceModelRecord { + id: "voice-1".into(), + provider: "elevenlabs".into(), + provider_voice_id: "provider-1".into(), + model: "model".into(), + consent_id: "consent-1".into(), + source_audio_asset_id: "audio-1".into(), + source_audio_sha256: "a".repeat(64), + request_hash: "b".repeat(64), + voice_name: "Narrator".into(), + revoked: false, + }); + state.commit_irreversible(); + + assert!(state.undo()); + assert_eq!(state.timeline.tracks[0].clips[0].start_frame, 0); + assert_eq!(state.timeline.voice_models.len(), 1); + assert!(!state.timeline.voice_models[0].revoked); + } + #[test] fn new_edit_clears_redo_stack() { let mut s = state_with_clip(); let b1 = s.snapshot(); s.timeline.tracks[0].clips[0].start_frame = 10; - s.commit(b1); + s.commit(b1, "Move Clip"); assert!(s.undo()); assert!(s.can_redo()); // a fresh commit invalidates redo let b2 = s.snapshot(); s.timeline.tracks[0].clips[0].start_frame = 20; - s.commit(b2); + s.commit(b2, "Move Clip"); assert!(!s.can_redo()); } diff --git a/crates/opentake-ops/src/engines/overwrite.rs b/crates/opentake-ops/src/engines/overwrite.rs index 7eb00e38..9a33bff5 100644 --- a/crates/opentake-ops/src/engines/overwrite.rs +++ b/crates/opentake-ops/src/engines/overwrite.rs @@ -54,14 +54,25 @@ impl OverwriteEngine { region_start: i32, region_end: i32, ) -> Vec { + Self::try_compute_overwrite(clips, region_start, region_end).unwrap_or_default() + } + + pub fn try_compute_overwrite( + clips: &[Clip], + region_start: i32, + region_end: i32, + ) -> Option> { if region_end <= region_start { - return Vec::new(); + return Some(Vec::new()); } let mut actions = Vec::new(); for clip in clips { let cs = clip.start_frame; - let ce = clip.end_frame(); + let ce = cs.checked_add(clip.duration_frames)?; + if !clip.speed.is_finite() || clip.speed <= 0.0 { + return None; + } // Entirely outside the region. if ce <= region_start || cs >= region_end { @@ -75,11 +86,14 @@ impl OverwriteEngine { }); } else if cs < region_start && ce > region_end { // Spans the whole region — split. - let left_duration = region_start - cs; + let left_duration = region_start.checked_sub(cs)?; let right_start_frame = region_end; - let right_trim_start = - clip.trim_start_frame + ((region_end - cs) as f64 * clip.speed).round() as i32; - let right_duration = ce - region_end; + let source_delta = (region_end.checked_sub(cs)? as f64 * clip.speed).round(); + if !(0.0..=i32::MAX as f64).contains(&source_delta) { + return None; + } + let right_trim_start = clip.trim_start_frame.checked_add(source_delta as i32)?; + let right_duration = ce.checked_sub(region_end)?; actions.push(OverwriteAction::Split { clip_id: clip.id.clone(), left_duration, @@ -89,18 +103,21 @@ impl OverwriteEngine { }); } else if cs < region_start { // Overlaps left side — trim right edge. - let new_duration = region_start - cs; + let new_duration = region_start.checked_sub(cs)?; actions.push(OverwriteAction::TrimEnd { clip_id: clip.id.clone(), new_duration, }); } else { // Overlaps right side — trim left edge. - let trim_amount = region_end - cs; + let trim_amount = region_end.checked_sub(cs)?; let new_start_frame = region_end; - let new_trim_start = - clip.trim_start_frame + (trim_amount as f64 * clip.speed).round() as i32; - let new_duration = ce - region_end; + let source_delta = (trim_amount as f64 * clip.speed).round(); + if !(0.0..=i32::MAX as f64).contains(&source_delta) { + return None; + } + let new_trim_start = clip.trim_start_frame.checked_add(source_delta as i32)?; + let new_duration = ce.checked_sub(region_end)?; actions.push(OverwriteAction::TrimStart { clip_id: clip.id.clone(), new_start_frame, @@ -110,7 +127,7 @@ impl OverwriteEngine { } } - actions + Some(actions) } } diff --git a/crates/opentake-ops/src/engines/ripple.rs b/crates/opentake-ops/src/engines/ripple.rs index 8bda0e53..a6300c87 100644 --- a/crates/opentake-ops/src/engines/ripple.rs +++ b/crates/opentake-ops/src/engines/ripple.rs @@ -40,7 +40,15 @@ impl FrameRange { /// `end - start`. pub fn length(&self) -> i32 { - self.end - self.start + self.checked_length().unwrap_or(0) + } + + /// Checked interval length. Invalid or unrepresentable ranges return + /// `None` instead of panicking in debug builds. + pub fn checked_length(&self) -> Option { + self.end + .checked_sub(self.start) + .filter(|length| *length >= 0) } } @@ -58,17 +66,28 @@ impl RippleEngine { /// After removing clips from a track, compute new start frames for remaining /// clips that should shift backward to close the gap. pub fn compute_ripple_shifts(clips: &[Clip], removed_ids: &HashSet) -> Vec { + Self::try_compute_ripple_shifts(clips, removed_ids).unwrap_or_default() + } + + pub fn try_compute_ripple_shifts( + clips: &[Clip], + removed_ids: &HashSet, + ) -> Option> { let removed_ranges: Vec = clips .iter() .filter(|c| removed_ids.contains(&c.id)) - .map(|c| FrameRange::new(c.start_frame, c.end_frame())) - .collect(); + .map(|c| { + c.start_frame + .checked_add(c.duration_frames) + .map(|end| FrameRange::new(c.start_frame, end)) + }) + .collect::>()?; let remaining: Vec = clips .iter() .filter(|c| !removed_ids.contains(&c.id)) .cloned() .collect(); - Self::compute_ripple_shifts_for_ranges(&remaining, &removed_ranges) + Self::try_compute_ripple_shifts_for_ranges(&remaining, &removed_ranges) } /// Shift clips leftward to close the gaps defined by `removed_ranges`. Used @@ -77,9 +96,18 @@ impl RippleEngine { clips: &[Clip], removed_ranges: &[FrameRange], ) -> Vec { + Self::try_compute_ripple_shifts_for_ranges(clips, removed_ranges).unwrap_or_default() + } + + /// Checked counterpart used by mutating ripple operations. `None` means a + /// range sum or resulting start frame is not representable as `i32`. + pub fn try_compute_ripple_shifts_for_ranges( + clips: &[Clip], + removed_ranges: &[FrameRange], + ) -> Option> { let merged = Self::merge_ranges(removed_ranges); if merged.is_empty() { - return Vec::new(); + return Some(Vec::new()); } let mut sorted: Vec<&Clip> = clips.iter().collect(); @@ -87,16 +115,20 @@ impl RippleEngine { let mut shifts = Vec::new(); for clip in sorted { - let shift: i32 = merged + let shift = merged .iter() - .filter(|r| r.end <= clip.start_frame) - .map(|r| r.length()) - .sum(); + .filter(|range| range.end <= clip.start_frame) + .try_fold(0_i32, |total, range| { + total.checked_add(range.checked_length()?) + })?; if shift > 0 { - shifts.push(ClipShift::new(clip.id.clone(), clip.start_frame - shift)); + shifts.push(ClipShift::new( + clip.id.clone(), + clip.start_frame.checked_sub(shift)?, + )); } } - shifts + Some(shifts) } /// Push all clips at or after `insert_frame` forward by `push_amount` frames. @@ -109,7 +141,11 @@ impl RippleEngine { clips .iter() .filter(|c| !exclude_ids.contains(&c.id) && c.start_frame >= insert_frame) - .map(|c| ClipShift::new(c.id.clone(), c.start_frame + push_amount)) + .filter_map(|c| { + c.start_frame + .checked_add(push_amount) + .map(|start| ClipShift::new(c.id.clone(), start)) + }) .collect() } diff --git a/crates/opentake-ops/src/engines/snap.rs b/crates/opentake-ops/src/engines/snap.rs index c75460de..7d6fe705 100644 --- a/crates/opentake-ops/src/engines/snap.rs +++ b/crates/opentake-ops/src/engines/snap.rs @@ -242,6 +242,31 @@ mod tests { assert_eq!(state.currently_snapped_to, None); } + #[test] + fn find_snap_re_sticks_after_release_within_drag() { + let targets = vec![SnapTarget { + frame: 130, + kind: SnapKind::ClipEdge, + }]; + let mut state = SnapState::new(); + // 1) fresh snap via the end probe: position 105, probes [0,30] -> 135 vs 130. + let r = SnapEngine::find_snap(105, &[0, 30], &targets, &mut state, 8.0, 1.0).unwrap(); + assert_eq!((r.frame, r.probe_offset), (130, 30)); + assert_eq!(state.current_probe_offset, 30); + // 2) drift past the 1.5x hold threshold -> release resets the whole state, + // including current_probe_offset, so the next search is a fresh snap. + assert!(SnapEngine::find_snap(113, &[0, 30], &targets, &mut state, 8.0, 1.0).is_none()); + assert_eq!(state.currently_snapped_to, None); + assert_eq!(state.current_probe_offset, 0); + // 3) come back inside the base threshold -> re-sticks via the start probe. + let r2 = SnapEngine::find_snap(126, &[0, 30], &targets, &mut state, 8.0, 1.0).unwrap(); + assert_eq!((r2.frame, r2.probe_offset), (130, 0)); + assert_eq!(state.currently_snapped_to, Some(130)); + // 4) sticky hold applies again after re-sticking. + let r3 = SnapEngine::find_snap(133, &[0, 30], &targets, &mut state, 8.0, 1.0).unwrap(); + assert_eq!((r3.frame, r3.probe_offset), (130, 0)); + } + #[test] fn find_snap_playhead_has_wider_threshold() { // playhead at 100 with 1.5x threshold (12). probe 110 dist 10 -> snaps to playhead. diff --git a/crates/opentake-ops/src/intent.rs b/crates/opentake-ops/src/intent.rs index b88d426a..f8b95899 100644 --- a/crates/opentake-ops/src/intent.rs +++ b/crates/opentake-ops/src/intent.rs @@ -276,7 +276,10 @@ fn validate_intent_entry( "entries[{index}]: track index {track_index} out of range" ))); }; - if !entry.source_clip_type.is_compatible(track.kind) { + // A placed audio lane can come from a video asset (linked audio), so + // track compatibility follows the placed media type rather than the + // source container type. + if !entry.media_type.is_compatible(track.kind) { return Err(EditError::Invalid(format!( "entries[{index}]: asset type is not compatible with the destination track" ))); diff --git a/crates/opentake-ops/src/lib.rs b/crates/opentake-ops/src/lib.rs index 6c286947..14bb499c 100644 --- a/crates/opentake-ops/src/lib.rs +++ b/crates/opentake-ops/src/lib.rs @@ -31,8 +31,9 @@ pub use engines::{ // --- Command layer --- pub use command::{ - apply, CaptionEntry, ClipEntry, ClipProperties, EditCommand, EditError, EditResult, - KeyframePayload, KeyframeProperty, KeyframeValue, RenameEntry, TextAutoTrackEntry, TextEntry, + apply, CaptionEntry, CaptionTranslationChange, ClipEntry, ClipProperties, + ClipPropertyAssignment, EditCommand, EditError, EditResult, KeyframePayload, KeyframeProperty, + KeyframeValue, RenameEntry, TextAutoTrackEntry, TextEntry, }; pub use editor_state::{DocSnapshot, EditorState}; pub use id::{IdGen, SeqIdGen}; diff --git a/crates/opentake-ops/src/ops/clear_region.rs b/crates/opentake-ops/src/ops/clear_region.rs index 77590901..7334755f 100644 --- a/crates/opentake-ops/src/ops/clear_region.rs +++ b/crates/opentake-ops/src/ops/clear_region.rs @@ -8,7 +8,7 @@ //! piece sitting inside the region — splitting once more if that piece overruns //! `end`. This is the shared "make room" primitive behind add / move / paste. -use opentake_domain::Timeline; +use opentake_domain::{Clip, ClipType, Timeline}; use crate::engines::{OverwriteAction, OverwriteEngine}; use crate::id::IdGen; @@ -26,11 +26,22 @@ pub fn clear_region( prune: bool, ids: &dyn IdGen, ) { - if track_index >= timeline.tracks.len() { + if track_index >= timeline.tracks.len() + || start < 0 + || end < start + || timeline + .tracks + .iter() + .flat_map(|track| &track.clips) + .any(|clip| !clip_arithmetic_is_safe(clip)) + { return; } - let actions = - OverwriteEngine::compute_overwrite(&timeline.tracks[track_index].clips, start, end); + let Some(actions) = + OverwriteEngine::try_compute_overwrite(&timeline.tracks[track_index].clips, start, end) + else { + return; + }; for action in actions { match action { @@ -44,11 +55,20 @@ pub fn clear_region( } => { if let Some((ti, ci)) = find(timeline, &clip_id) { let clip = &timeline.tracks[ti].clips[ci]; - let source_delta = - ((clip.duration_frames - new_duration) as f64 * clip.speed).round() as i32; - let new_trim_end = clip.trim_end_frame + source_delta; + let source_delta = (clip + .duration_frames + .checked_sub(new_duration) + .expect("overwrite duration is bounded by the source clip") + as f64 + * clip.speed) + .round() as i32; + let new_trim_end = clip + .trim_end_frame + .checked_add(source_delta) + .expect("clip edge source extent was prevalidated"); let c = &mut timeline.tracks[ti].clips[ci]; c.trim_end_frame = new_trim_end; + c.loudness_normalization = None; c.set_duration(new_duration); } } @@ -63,6 +83,7 @@ pub fn clear_region( let c = &mut timeline.tracks[ti].clips[ci]; c.start_frame = new_start_frame; c.trim_start_frame = new_trim_start; + c.loudness_normalization = None; c.set_duration(new_duration); } } @@ -71,13 +92,24 @@ pub fn clear_region( if find(timeline, &clip_id).is_some() { // Split at `start`; the right half is what now covers the region. split_clip(timeline, &clip_id, start, ids); - // Locate the freshly created right half (starts at `start`, not the original id). - let right = timeline - .tracks - .iter() - .flat_map(|t| &t.clips) - .find(|c| c.start_frame == start && c.id != clip_id) - .map(|c| (c.id.clone(), c.end_frame())); + // Locate the freshly created right half on the original + // clip's track. Linked splits mint a right half on every + // partner track; a global search can select the wrong + // partner and leave a duplicate middle fragment behind. + let right = find(timeline, &clip_id).and_then(|(ti, _)| { + timeline.tracks[ti] + .clips + .iter() + .find(|c| c.start_frame == start && c.id != clip_id) + .map(|clip| { + ( + clip.id.clone(), + clip.start_frame + .checked_add(clip.duration_frames) + .expect("clip arithmetic was prevalidated"), + ) + }) + }); if let Some((right_id, right_end)) = right { if right_end > end { // Right half overruns the region — split again at `end`, @@ -98,6 +130,36 @@ pub fn clear_region( } } +fn clip_arithmetic_is_safe(clip: &Clip) -> bool { + if clip.start_frame < 0 + || clip.duration_frames < 1 + || (!matches!(clip.media_type, ClipType::Image | ClipType::Text) + && (clip.trim_start_frame < 0 || clip.trim_end_frame < 0)) + || !clip.speed.is_finite() + || clip.speed <= 0.0 + || clip.start_frame.checked_add(clip.duration_frames).is_none() + || clip + .duration_frames + .checked_add(clip.trim_start_frame) + .and_then(|value| value.checked_add(clip.trim_end_frame)) + .is_none() + { + return false; + } + let consumed = (clip.duration_frames as f64 * clip.speed).round(); + if !(0.0..=i32::MAX as f64).contains(&consumed) { + return false; + } + let consumed = consumed as i32; + clip.trim_start_frame.checked_add(consumed).is_some() + && clip.trim_end_frame.checked_add(consumed).is_some() + && clip + .trim_start_frame + .checked_add(consumed) + .and_then(|value| value.checked_add(clip.trim_end_frame)) + .is_some() +} + /// Remove a single clip by id from whatever track holds it. pub(crate) fn remove_clip(timeline: &mut Timeline, clip_id: &str) { for t in &mut timeline.tracks { diff --git a/crates/opentake-ops/src/ops/duplicate.rs b/crates/opentake-ops/src/ops/duplicate.rs index 9097a5b6..21958859 100644 --- a/crates/opentake-ops/src/ops/duplicate.rs +++ b/crates/opentake-ops/src/ops/duplicate.rs @@ -13,13 +13,24 @@ use std::collections::HashMap; -use opentake_domain::{Clip, Timeline}; +use opentake_domain::{Clip, ClipType, Timeline}; use crate::id::IdGen; use crate::ops::clear_region::clear_region; use crate::ops::place::sort_clips; use crate::ops::tracks::prune_empty_tracks; +/// Fully checked duplicate work item. Command-level preflight resolves every +/// frame sum and stable destination before a transaction can mint an id or +/// mutate a track; the mutation helper only consumes those validated values. +#[derive(Clone)] +pub(crate) struct DuplicateClipPlan { + pub clip: Clip, + pub to_track_id: String, + pub to_frame: i32, + pub to_end_frame: i32, +} + /// Deep-copy each clip in `clip_ids` to a new position: `start_frame` shifted /// by `offset_frames`, placed on `target_track_indexes[i]` (one target per /// source, by index). Returns the ids of the newly created clips (in input @@ -44,15 +55,18 @@ pub fn duplicate_clips( if clip_ids.is_empty() { return Vec::new(); } + if timeline + .tracks + .iter() + .flat_map(|track| &track.clips) + .any(|clip| !clip_arithmetic_is_safe(clip)) + { + return Vec::new(); + } // Resolve each source clip + validate its target track. Collect up front so // the mutation phase can pin tracks by id (pruning could shift indices). - struct Plan { - clone: Clip, - to_track_id: String, - to_frame: i32, - } - let mut plans: Vec = Vec::new(); + let mut plans: Vec = Vec::new(); for (i, id) in clip_ids.iter().enumerate() { let Some((ti, ci)) = find(timeline, id) else { continue; @@ -68,18 +82,40 @@ pub fn duplicate_clips( if !dest_type.is_compatible(src_type) { continue; } - let clone = timeline.tracks[ti].clips[ci].clone(); - let to_frame = (clone.start_frame + offset_frames).max(0); - plans.push(Plan { - clone, + let clip = timeline.tracks[ti].clips[ci].clone(); + debug_assert!(clip_arithmetic_is_safe(&clip)); + let Some(shifted) = clip.start_frame.checked_add(offset_frames) else { + return Vec::new(); + }; + let to_frame = shifted.max(0); + let Some(to_end_frame) = to_frame.checked_add(clip.duration_frames) else { + return Vec::new(); + }; + plans.push(DuplicateClipPlan { + clip, to_track_id: timeline.tracks[to_track].id.clone(), to_frame, + to_end_frame, }); } if plans.is_empty() { return Vec::new(); } + duplicate_clips_from_plans(timeline, plans, ids) +} + +/// Apply prevalidated duplicate plans. All fallible input/frame validation must +/// happen before this function is called so id allocation is mutation-only. +pub(crate) fn duplicate_clips_from_plans( + timeline: &mut Timeline, + plans: Vec, + ids: &dyn IdGen, +) -> Vec { + if plans.is_empty() { + return Vec::new(); + } + // Clear each destination range (pin by track id) so the duplicate overwrites // whatever was there, exactly like `move_clips` / `place_clip` do. for plan in &plans { @@ -88,14 +124,7 @@ pub fn duplicate_clips( .iter() .position(|t| t.id == plan.to_track_id) { - clear_region( - timeline, - idx, - plan.to_frame, - plan.to_frame + plan.clone.duration_frames, - false, - ids, - ); + clear_region(timeline, idx, plan.to_frame, plan.to_end_frame, false, ids); } } @@ -107,7 +136,7 @@ pub fn duplicate_clips( let mut group_counts: HashMap, usize> = HashMap::new(); for plan in &plans { *group_counts - .entry(plan.clone.link_group_id.clone()) + .entry(plan.clip.link_group_id.clone()) .or_insert(0) += 1; } let mut group_remap: HashMap, Option> = HashMap::new(); @@ -120,21 +149,38 @@ pub fn duplicate_clips( group_remap.insert(group_id.clone(), new_id); } + let new_ids: Vec = plans.iter().map(|_| ids.next_id()).collect(); + let id_map: HashMap = plans + .iter() + .zip(&new_ids) + .map(|(plan, new_id)| (plan.clip.id.clone(), new_id.clone())) + .collect(); + // Drop each deep copy at its target frame with a fresh id + remapped link. let mut created = Vec::new(); - for plan in plans { + for (plan, new_id) in plans.into_iter().zip(new_ids) { if let Some(idx) = timeline .tracks .iter() .position(|t| t.id == plan.to_track_id) { - let mut clip = plan.clone; - clip.id = ids.next_id(); + let mut clip = plan.clip; + let old_id = clip.id.clone(); + clip.id = new_id; clip.start_frame = plan.to_frame; // Remap the link group: multi-clip groups get the fresh shared id, // single-clip groups (and None) clear to None. let remapped = group_remap.get(&clip.link_group_id).cloned().flatten(); clip.link_group_id = remapped; + clip.transition_out = clip.transition_out.take().and_then(|mut transition| { + let to_id = id_map.get(&transition.to_clip_id)?.clone(); + if !transition.from_clip_id.is_empty() && transition.from_clip_id != old_id { + return None; + } + transition.from_clip_id = clip.id.clone(); + transition.to_clip_id = to_id; + Some(transition) + }); created.push(clip.id.clone()); timeline.tracks[idx].clips.push(clip); sort_clips(&mut timeline.tracks[idx]); @@ -144,6 +190,36 @@ pub fn duplicate_clips( created } +fn clip_arithmetic_is_safe(clip: &Clip) -> bool { + if clip.start_frame < 0 + || clip.duration_frames < 1 + || (!matches!(clip.media_type, ClipType::Image | ClipType::Text) + && (clip.trim_start_frame < 0 || clip.trim_end_frame < 0)) + || !clip.speed.is_finite() + || clip.speed <= 0.0 + || clip.start_frame.checked_add(clip.duration_frames).is_none() + || clip + .duration_frames + .checked_add(clip.trim_start_frame) + .and_then(|value| value.checked_add(clip.trim_end_frame)) + .is_none() + { + return false; + } + let consumed = (clip.duration_frames as f64 * clip.speed).round(); + if !(0.0..=i32::MAX as f64).contains(&consumed) { + return false; + } + let consumed = consumed as i32; + clip.trim_start_frame.checked_add(consumed).is_some() + && clip.trim_end_frame.checked_add(consumed).is_some() + && clip + .trim_start_frame + .checked_add(consumed) + .and_then(|value| value.checked_add(clip.trim_end_frame)) + .is_some() +} + fn find(timeline: &Timeline, clip_id: &str) -> Option<(usize, usize)> { for (ti, t) in timeline.tracks.iter().enumerate() { if let Some(ci) = t.clips.iter().position(|c| c.id == clip_id) { @@ -338,8 +414,9 @@ mod tests { }, feather: 0.05, invert: false, + ..Mask::default() }]; - src.effects = vec![Effect::new("gaussianBlur").with_param("radius", 4.0)]; + src.effects = vec![Effect::new("grayscale").with_param("amount", 0.4)]; let orig_color_grade = src.color_grade; let orig_chroma_key = src.chroma_key; let g = SeqIdGen::default(); diff --git a/crates/opentake-ops/src/ops/folders.rs b/crates/opentake-ops/src/ops/folders.rs index 42d6dcce..87615c7a 100644 --- a/crates/opentake-ops/src/ops/folders.rs +++ b/crates/opentake-ops/src/ops/folders.rs @@ -131,9 +131,15 @@ pub fn delete_folder( (folders_removed, assets_removed, clips_removed) } -/// Remove every clip whose `media_ref` is in `asset_ids`, then prune any tracks -/// left empty (mirroring `remove_clips`). Returns the count of clips removed. +/// Remove every clip whose `media_ref` is in `asset_ids` from the root and all +/// registered nested timelines, then prune tracks left empty (mirroring +/// `remove_clips`). Returns the total count of clips removed across the graph. fn cascade_remove_clips(timeline: &mut Timeline, asset_ids: &HashSet) -> usize { + let nested_count = timeline + .nested_sequences + .iter_mut() + .map(|sequence| cascade_remove_clips(&mut sequence.timeline, asset_ids)) + .sum::(); let doomed: Vec = timeline .tracks .iter() @@ -148,7 +154,7 @@ fn cascade_remove_clips(timeline: &mut Timeline, asset_ids: &HashSet) -> if count > 0 { crate::ops::prune_empty_tracks(timeline); } - count + nested_count + count } /// Expand a set of root folder ids to include all transitive descendant folders @@ -192,6 +198,8 @@ mod tests { source_height: None, source_fps: None, has_audio: None, + color: None, + proxy: None, folder_id: None, cached_remote_url: None, cached_remote_url_expires_at: None, @@ -311,6 +319,40 @@ mod tests { assert_eq!(tl.tracks[0].clips[0].id, "clip-2"); } + #[test] + fn delete_media_cascades_through_nested_timelines() { + use opentake_domain::{Clip, NestedSequence, Track}; + + let mut m = MediaManifest::new(); + m.entries.push(entry("a")); + m.entries.push(entry("b")); + + let mut child = timeline_with_clip("nested-a", "a"); + child.tracks[0] + .clips + .push(Clip::new("nested-b", "b", 40, 30)); + let mut root = Timeline::new(); + let mut root_track = Track::new("root-track", ClipType::Video); + root_track + .clips + .push(Clip::new_nested("compound", "sequence", 0, 70)); + root.tracks.push(root_track); + root.nested_sequences + .push(NestedSequence::new("sequence", "Nested", child)); + + let (assets, clips) = + delete_media(&mut root, &mut m, &["a".to_string()].into_iter().collect()); + + assert_eq!(assets, 1); + assert_eq!(clips, 1); + assert_eq!(root.nested_sequences[0].timeline.tracks.len(), 1); + assert_eq!( + root.nested_sequences[0].timeline.tracks[0].clips[0].id, + "nested-b" + ); + assert_eq!(root.tracks[0].clips[0].id, "compound"); + } + #[test] fn delete_folder_recurses_and_cascades() { let mut m = MediaManifest::new(); diff --git a/crates/opentake-ops/src/ops/linking.rs b/crates/opentake-ops/src/ops/linking.rs index 3508a784..d4fdb0db 100644 --- a/crates/opentake-ops/src/ops/linking.rs +++ b/crates/opentake-ops/src/ops/linking.rs @@ -82,7 +82,9 @@ pub fn partner_moves(timeline: &Timeline, clip_id: &str, to_frame: i32) -> Vec<( let Some(lead) = find_clip_start(timeline, clip_id) else { return Vec::new(); }; - let delta = to_frame - lead; + let Some(delta) = to_frame.checked_sub(lead) else { + return Vec::new(); + }; if delta == 0 { return Vec::new(); } @@ -90,7 +92,7 @@ pub fn partner_moves(timeline: &Timeline, clip_id: &str, to_frame: i32) -> Vec<( .into_iter() .filter_map(|pid| { let start = find_clip_start(timeline, &pid)?; - Some((pid, (start + delta).max(0))) + Some((pid, start.checked_add(delta)?.max(0))) }) .collect() } diff --git a/crates/opentake-ops/src/ops/move_clips.rs b/crates/opentake-ops/src/ops/move_clips.rs index 80ec0dc6..94caf0e6 100644 --- a/crates/opentake-ops/src/ops/move_clips.rs +++ b/crates/opentake-ops/src/ops/move_clips.rs @@ -6,7 +6,7 @@ //! frame (overwrite-style). Tracks are pinned by id across the clears because //! pruning could otherwise shift indices. -use opentake_domain::{Clip, Timeline}; +use opentake_domain::{Clip, ClipType, Timeline}; use crate::id::IdGen; use crate::ops::clear_region::clear_region; @@ -22,6 +22,16 @@ pub struct ClipMove { pub to_frame: i32, } +/// Fully checked move work item. It pins the source snapshot and destination +/// range so mutation never repeats overflow-prone frame arithmetic. +#[derive(Clone)] +pub(crate) struct MoveClipPlan { + pub clip: Clip, + pub to_track_id: String, + pub to_frame: i32, + pub to_end_frame: i32, +} + /// Move clips to their targets. Incompatible-destination or missing-clip moves /// are silently dropped (mirrors upstream's `guard ... continue`). Returns the /// number of clips actually moved. @@ -29,14 +39,17 @@ pub fn move_clips(timeline: &mut Timeline, moves: &[ClipMove], ids: &dyn IdGen) if moves.is_empty() { return 0; } + if timeline + .tracks + .iter() + .flat_map(|track| &track.clips) + .any(|clip| !clip_arithmetic_is_safe(clip)) + { + return 0; + } // Collect current state + validate track-type compatibility. - struct Info { - clip: Clip, - to_track_id: String, - to_frame: i32, - } - let mut infos: Vec = Vec::new(); + let mut infos: Vec = Vec::new(); for m in moves { let Some((ti, ci)) = find(timeline, &m.clip_id) else { continue; @@ -49,44 +62,58 @@ pub fn move_clips(timeline: &mut Timeline, moves: &[ClipMove], ids: &dyn IdGen) if !dest_type.is_compatible(src_type) { continue; } - infos.push(Info { - clip: timeline.tracks[ti].clips[ci].clone(), + let clip = timeline.tracks[ti].clips[ci].clone(); + debug_assert!(clip_arithmetic_is_safe(&clip)); + let to_frame = m.to_frame.max(0); + let Some(to_end_frame) = to_frame.checked_add(clip.duration_frames) else { + return 0; + }; + infos.push(MoveClipPlan { + clip, to_track_id: timeline.tracks[m.to_track].id.clone(), - to_frame: m.to_frame.max(0), + to_frame, + to_end_frame, }); } if infos.is_empty() { return 0; } + move_clips_from_plans(timeline, &infos, ids) +} + +/// Apply prevalidated move plans. All input/frame errors must be rejected by +/// the caller before invoking this mutation-only phase. +pub(crate) fn move_clips_from_plans( + timeline: &mut Timeline, + infos: &[MoveClipPlan], + ids: &dyn IdGen, +) -> usize { + if infos.is_empty() { + return 0; + } + // Pull moved clips off their source tracks first. - for info in &infos { + for info in infos { if let Some((ti, ci)) = find(timeline, &info.clip.id) { timeline.tracks[ti].clips.remove(ci); } } // Trim / remove non-moved clips blocking each destination range (pin by id). - for info in &infos { + for info in infos { if let Some(idx) = timeline .tracks .iter() .position(|t| t.id == info.to_track_id) { - clear_region( - timeline, - idx, - info.to_frame, - info.to_frame + info.clip.duration_frames, - false, - ids, - ); + clear_region(timeline, idx, info.to_frame, info.to_end_frame, false, ids); } } // Drop each clip at its exact target frame. let mut moved = 0; - for info in &infos { + for info in infos { if let Some(idx) = timeline .tracks .iter() @@ -105,6 +132,36 @@ pub fn move_clips(timeline: &mut Timeline, moves: &[ClipMove], ids: &dyn IdGen) moved } +fn clip_arithmetic_is_safe(clip: &Clip) -> bool { + if clip.start_frame < 0 + || clip.duration_frames < 1 + || (!matches!(clip.media_type, ClipType::Image | ClipType::Text) + && (clip.trim_start_frame < 0 || clip.trim_end_frame < 0)) + || !clip.speed.is_finite() + || clip.speed <= 0.0 + || clip.start_frame.checked_add(clip.duration_frames).is_none() + || clip + .duration_frames + .checked_add(clip.trim_start_frame) + .and_then(|value| value.checked_add(clip.trim_end_frame)) + .is_none() + { + return false; + } + let consumed = (clip.duration_frames as f64 * clip.speed).round(); + if !(0.0..=i32::MAX as f64).contains(&consumed) { + return false; + } + let consumed = consumed as i32; + clip.trim_start_frame.checked_add(consumed).is_some() + && clip.trim_end_frame.checked_add(consumed).is_some() + && clip + .trim_start_frame + .checked_add(consumed) + .and_then(|value| value.checked_add(clip.trim_end_frame)) + .is_some() +} + fn find(timeline: &Timeline, clip_id: &str) -> Option<(usize, usize)> { for (ti, t) in timeline.tracks.iter().enumerate() { if let Some(ci) = t.clips.iter().position(|c| c.id == clip_id) { diff --git a/crates/opentake-ops/src/ops/place.rs b/crates/opentake-ops/src/ops/place.rs index 34274ca6..380a049e 100644 --- a/crates/opentake-ops/src/ops/place.rs +++ b/crates/opentake-ops/src/ops/place.rs @@ -74,7 +74,17 @@ pub fn place_clip( linked_audio_track_index: Option, ids: &dyn IdGen, ) -> Vec { - if track_index >= timeline.tracks.len() { + if track_index >= timeline.tracks.len() + || !spec_arithmetic_is_safe(spec) + || !timeline.tracks[track_index] + .kind + .is_compatible(spec.media_type) + || timeline + .tracks + .iter() + .flat_map(|track| &track.clips) + .any(|clip| !clip_arithmetic_is_safe(clip)) + { return Vec::new(); } let target_is_video = timeline.tracks[track_index].kind == ClipType::Video; @@ -142,6 +152,56 @@ pub fn place_clip( out } +fn spec_arithmetic_is_safe(spec: &PlaceSpec) -> bool { + let trim_start = spec.trim_start_frame.unwrap_or(0); + let trim_end = spec.trim_end_frame.unwrap_or(0); + if spec.start_frame < 0 + || spec.duration_frames < 1 + || (!matches!(spec.media_type, ClipType::Image | ClipType::Text) + && (trim_start < 0 || trim_end < 0)) + || spec.start_frame.checked_add(spec.duration_frames).is_none() + || spec + .duration_frames + .checked_add(trim_start) + .and_then(|value| value.checked_add(trim_end)) + .is_none() + || trim_start.checked_add(spec.duration_frames).is_none() + || trim_end.checked_add(spec.duration_frames).is_none() + { + return false; + } + true +} + +fn clip_arithmetic_is_safe(clip: &Clip) -> bool { + if clip.start_frame < 0 + || clip.duration_frames < 1 + || (!matches!(clip.media_type, ClipType::Image | ClipType::Text) + && (clip.trim_start_frame < 0 || clip.trim_end_frame < 0)) + || !clip.speed.is_finite() + || clip.speed <= 0.0 + || clip.start_frame.checked_add(clip.duration_frames).is_none() + { + return false; + } + let consumed = (clip.duration_frames as f64 * clip.speed).round(); + if !(0.0..=i32::MAX as f64).contains(&consumed) { + return false; + } + let consumed = consumed as i32; + clip.duration_frames + .checked_add(clip.trim_start_frame) + .and_then(|value| value.checked_add(clip.trim_end_frame)) + .is_some() + && clip.trim_start_frame.checked_add(consumed).is_some() + && clip.trim_end_frame.checked_add(consumed).is_some() + && clip + .trim_start_frame + .checked_add(consumed) + .and_then(|value| value.checked_add(clip.trim_end_frame)) + .is_some() +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/opentake-ops/src/ops/ripple.rs b/crates/opentake-ops/src/ops/ripple.rs index 8e6b7629..8c31d43f 100644 --- a/crates/opentake-ops/src/ops/ripple.rs +++ b/crates/opentake-ops/src/ops/ripple.rs @@ -10,10 +10,10 @@ use std::collections::{HashMap, HashSet}; -use opentake_domain::Timeline; +use opentake_domain::{Clip, ClipType, Timeline}; use crate::engines::{ClipShift, FrameRange, RippleEngine}; -use crate::id::IdGen; +use crate::id::{IdGen, SeqIdGen}; use crate::ops::clear_region::{clear_region, remove_clip}; use crate::ops::linking::linked_partner_ids; use crate::ops::place::{place_clip, sort_clips, PlaceSpec}; @@ -23,6 +23,21 @@ use crate::ops::tracks::prune_empty_tracks; /// Apply each shift's new `start_frame` to its clip. Returns the count applied. /// 1:1 port of `applyShifts`. pub fn apply_shifts(timeline: &mut Timeline, shifts: &[ClipShift]) -> usize { + if validate_timeline_arithmetic(timeline).is_err() + || shifts.iter().any(|shift| { + let Some((track_index, clip_index)) = find(timeline, &shift.clip_id) else { + return false; + }; + let clip = &timeline.tracks[track_index].clips[clip_index]; + shift.new_start_frame < 0 + || shift + .new_start_frame + .checked_add(clip.duration_frames) + .is_none() + }) + { + return 0; + } let mut applied = 0; for shift in shifts { if let Some((ti, ci)) = find(timeline, &shift.clip_id) { @@ -62,7 +77,12 @@ pub fn validate_shifts( "Sync-locked track \"{label}\" would move past the timeline start." )); } - intervals.push(FrameRange::new(start, start + clip.duration_frames)); + let Some(end) = start.checked_add(clip.duration_frames) else { + return Some(format!( + "Sync-locked track \"{label}\" would overflow the timeline." + )); + }; + intervals.push(FrameRange::new(start, end)); } intervals.sort_by_key(|r| r.start); for i in 1..intervals.len() { @@ -108,6 +128,7 @@ pub fn ripple_delete( if ids.is_empty() { return Ok(()); } + validate_timeline_arithmetic(timeline)?; // Merged ranges used to shift sync-locked tracks with no deletions of their own. let global_removed: Vec = timeline @@ -115,8 +136,13 @@ pub fn ripple_delete( .iter() .flat_map(|t| &t.clips) .filter(|c| ids.contains(&c.id)) - .map(|c| FrameRange::new(c.start_frame, c.end_frame())) - .collect(); + .map(|clip| { + clip.start_frame + .checked_add(clip.duration_frames) + .map(|end| FrameRange::new(clip.start_frame, end)) + .ok_or_else(|| format!("clip {} endFrame overflows", clip.id)) + }) + .collect::>()?; // Compute every track's shifts up front; refuse before mutating anything. let mut shifts_by_track: HashMap> = HashMap::new(); @@ -124,9 +150,13 @@ pub fn ripple_delete( let track = &timeline.tracks[ti]; let has_own = track.clips.iter().any(|c| ids.contains(&c.id)); if has_own { - shifts_by_track.insert(ti, RippleEngine::compute_ripple_shifts(&track.clips, ids)); + let shifts = RippleEngine::try_compute_ripple_shifts(&track.clips, ids) + .ok_or_else(|| "ripple-delete shift arithmetic overflows".to_string())?; + shifts_by_track.insert(ti, shifts); } else if track.sync_locked { - let s = RippleEngine::compute_ripple_shifts_for_ranges(&track.clips, &global_removed); + let s = + RippleEngine::try_compute_ripple_shifts_for_ranges(&track.clips, &global_removed) + .ok_or_else(|| "sync-locked ripple shift arithmetic overflows".to_string())?; if let Some(reason) = validate_shifts(timeline, ti, &s, &track_label(timeline, ti)) { return Err(reason); } @@ -156,15 +186,64 @@ pub fn ripple_delete_ranges_on_track( track_label: &dyn Fn(&Timeline, usize) -> String, id_gen: &dyn IdGen, ) -> RippleOutcome { - if track_index >= timeline.tracks.len() { - return RippleOutcome::Refused(format!("Track index out of range: {track_index}")); + if let Err(reason) = validate_ripple_delete_ranges(timeline, track_index, ranges) { + return RippleOutcome::Refused(reason); + } + // Exercise the complete clear/split/shift path on a disposable timeline + // with a private id stream. This establishes that the real mutation cannot + // encounter a late arithmetic refusal after consuming caller ids. + let mut preflight = timeline.clone(); + let mut preflight_prefix = "__ripple-preflight-".to_string(); + while timeline + .tracks + .iter() + .flat_map(|track| &track.clips) + .any(|clip| clip.id.starts_with(&preflight_prefix)) + { + preflight_prefix.push('_'); + } + let preflight_ids = SeqIdGen::new(preflight_prefix); + if let RippleOutcome::Refused(reason) = ripple_delete_ranges_validated( + &mut preflight, + track_index, + ranges, + track_label, + &preflight_ids, + ) { + return RippleOutcome::Refused(reason); + } + + let mut candidate = timeline.clone(); + let outcome = + ripple_delete_ranges_validated(&mut candidate, track_index, ranges, track_label, id_gen); + if matches!(outcome, RippleOutcome::Ok(_)) { + *timeline = candidate; } - let nonempty: Vec = ranges.iter().copied().filter(|r| r.length() > 0).collect(); + outcome +} + +fn ripple_delete_ranges_validated( + timeline: &mut Timeline, + track_index: usize, + ranges: &[FrameRange], + track_label: &dyn Fn(&Timeline, usize) -> String, + id_gen: &dyn IdGen, +) -> RippleOutcome { + let nonempty: Vec = ranges + .iter() + .copied() + .filter(|range| range.checked_length().is_some_and(|length| length > 0)) + .collect(); let merged = RippleEngine::merge_ranges(&nonempty); if merged.is_empty() { return RippleOutcome::Refused("No non-empty ranges to delete".into()); } - let total_removed: i32 = merged.iter().map(|r| r.length()).sum(); + let total_removed = merged + .iter() + .try_fold(0_i32, |total, range| { + total.checked_add(range.checked_length()?) + }) + .expect("merged range arithmetic was prevalidated"); let anchor_track_id = timeline.tracks[track_index].id.clone(); let mut clear_track_ids: HashSet = [anchor_track_id.clone()].into_iter().collect(); @@ -196,7 +275,8 @@ pub fn ripple_delete_ranges_on_track( if clear_track_ids.contains(&track.id) || !track.sync_locked { continue; } - let s = RippleEngine::compute_ripple_shifts_for_ranges(&track.clips, &merged); + let s = RippleEngine::try_compute_ripple_shifts_for_ranges(&track.clips, &merged) + .expect("ripple shift arithmetic was prevalidated"); if let Some(reason) = validate_shifts(timeline, ti, &s, &track_label(timeline, ti)) { return RippleOutcome::Refused(reason); } @@ -225,7 +305,10 @@ pub fn ripple_delete_ranges_on_track( if !(clear_track_ids.contains(&track.id) || track.sync_locked) { continue; } - let s = RippleEngine::compute_ripple_shifts_for_ranges(&track.clips, &merged); + let Some(s) = RippleEngine::try_compute_ripple_shifts_for_ranges(&track.clips, &merged) + else { + return RippleOutcome::Refused("post-clear ripple shift arithmetic overflows".into()); + }; shifted_clips += apply_shifts(timeline, &s); sort_clips(&mut timeline.tracks[ti]); } @@ -245,6 +328,12 @@ pub fn ripple_delete_ranges_on_track( fragments.sort_by_key(|f| f.1); let removed_clip_ids: Vec = anchor_before_ids.difference(&after_ids).cloned().collect(); + if let Err(reason) = validate_timeline_arithmetic(timeline) { + return RippleOutcome::Refused(format!( + "ripple-delete produced invalid frame arithmetic: {reason}" + )); + } + RippleOutcome::Ok(RippleRangesReport { removed_frames: total_removed, cleared_tracks: clear_track_ids.len(), @@ -255,6 +344,51 @@ pub fn ripple_delete_ranges_on_track( }) } +/// Validate ripple-delete range inputs and every pre-mutation shift. This is +/// shared with the command layer so malformed IPC values are rejected before a +/// snapshot, id allocation, or track mutation. +pub fn validate_ripple_delete_ranges( + timeline: &Timeline, + track_index: usize, + ranges: &[FrameRange], +) -> Result<(), String> { + if track_index >= timeline.tracks.len() { + return Err(format!("Track index out of range: {track_index}")); + } + if ranges.is_empty() { + return Err("Missing or empty ranges".into()); + } + validate_timeline_arithmetic(timeline)?; + for (index, range) in ranges.iter().enumerate() { + if range.start < 0 || range.end <= range.start || range.checked_length().is_none() { + return Err(format!( + "ranges[{index}] must satisfy 0 <= start < end without overflow" + )); + } + } + let merged = RippleEngine::merge_ranges(ranges); + merged + .iter() + .try_fold(0_i32, |total, range| { + total.checked_add(range.checked_length()?) + }) + .ok_or_else(|| "ripple-delete range total overflows".to_string())?; + for track in &timeline.tracks { + RippleEngine::try_compute_ripple_shifts_for_ranges(&track.clips, &merged) + .ok_or_else(|| "ripple-delete shift arithmetic overflows".to_string())?; + } + Ok(()) +} + +fn validate_timeline_arithmetic(timeline: &Timeline) -> Result<(), String> { + for track in &timeline.tracks { + for clip in &track.clips { + validate_clip_arithmetic(clip)?; + } + } + Ok(()) +} + /// Ripple-insert clips at `at_frame`, pushing everything past it right by the /// total inserted duration on the target track, every sync-locked track, and the /// audio track any linked audio lands on. Straddling clips on pushed tracks are @@ -267,10 +401,9 @@ pub fn ripple_insert( at_frame: i32, ids: &dyn IdGen, ) -> Vec { - if track_index >= timeline.tracks.len() || specs.is_empty() { + let Ok(total_push) = validate_ripple_insert(timeline, specs, track_index, at_frame) else { return Vec::new(); - } - let total_push: i32 = specs.iter().map(|s| s.duration_frames).sum(); + }; // Pin the linked-audio destination before pushing so it ripples too. let target_is_video = timeline.tracks[track_index].kind == opentake_domain::ClipType::Video; @@ -342,11 +475,190 @@ pub fn ripple_insert( linked_audio_track_index, ids, )); - cursor += spec.duration_frames; + cursor = cursor + .checked_add(spec.duration_frames) + .expect("ripple insertion cursor was prevalidated"); } created } +/// Validate every frame value that [`ripple_insert`] derives before it can +/// split a clip, create a track, or consume an id. The returned value is the +/// checked aggregate duration used to shift follower clips. +pub fn validate_ripple_insert( + timeline: &Timeline, + specs: &[PlaceSpec], + track_index: usize, + at_frame: i32, +) -> Result { + if track_index >= timeline.tracks.len() { + return Err(format!("Track index out of range: {track_index}")); + } + if specs.is_empty() { + return Err("Missing or empty insertion specs".into()); + } + if at_frame < 0 { + return Err(format!("atFrame must be >= 0 (got {at_frame})")); + } + for track in &timeline.tracks { + for clip in &track.clips { + validate_clip_arithmetic(clip)?; + } + } + + let mut cursor = at_frame; + for (index, spec) in specs.iter().enumerate() { + validate_spec_arithmetic(spec, cursor) + .map_err(|reason| format!("entries[{index}]: {reason}"))?; + cursor = cursor + .checked_add(spec.duration_frames) + .ok_or_else(|| "inserted clip durations overflow the timeline".to_string())?; + } + let total_push = cursor + .checked_sub(at_frame) + .ok_or_else(|| "inserted clip duration total overflows".to_string())?; + + let target_is_video = timeline.tracks[track_index].kind == ClipType::Video; + let needs_linked_audio = target_is_video + && specs + .iter() + .any(|spec| spec.source_clip_type == ClipType::Video && spec.has_audio); + let linked_audio_track_index = needs_linked_audio.then(|| { + timeline + .tracks + .iter() + .position(|track| track.kind == ClipType::Audio) + }); + let pushed_tracks: Vec = (0..timeline.tracks.len()) + .filter(|&index| { + index == track_index + || linked_audio_track_index.flatten() == Some(index) + || timeline.tracks[index].sync_locked + }) + .collect(); + + let split_groups: HashSet = pushed_tracks + .iter() + .flat_map(|&index| &timeline.tracks[index].clips) + .filter(|clip| clip.start_frame < at_frame && at_frame < checked_clip_end(clip)) + .filter_map(|clip| clip.link_group_id.clone()) + .collect(); + for clip in timeline.tracks.iter().flat_map(|track| &track.clips) { + let is_direct_straddler = pushed_tracks.iter().any(|&index| { + timeline.tracks[index] + .clips + .iter() + .any(|candidate| candidate.id == clip.id) + }) && clip.start_frame < at_frame + && at_frame < checked_clip_end(clip); + let is_linked_straddler = clip + .link_group_id + .as_ref() + .is_some_and(|group| split_groups.contains(group)) + && clip.start_frame < at_frame + && at_frame < checked_clip_end(clip); + if is_direct_straddler || is_linked_straddler { + let (left, mut right) = opentake_domain::split_clip(clip, at_frame, "preflight") + .ok_or_else(|| format!("clip {} cannot be split at frame {at_frame}", clip.id))?; + validate_clip_arithmetic(&left)?; + validate_clip_arithmetic(&right)?; + if is_direct_straddler { + right.start_frame = right.start_frame.checked_add(total_push).ok_or_else(|| { + format!("ripple shift overflows split clip {} startFrame", clip.id) + })?; + validate_clip_arithmetic(&right)?; + } + } + } + + for &index in &pushed_tracks { + for clip in &timeline.tracks[index].clips { + if clip.start_frame >= at_frame { + let shifted_start = clip + .start_frame + .checked_add(total_push) + .ok_or_else(|| format!("ripple shift overflows clip {} startFrame", clip.id))?; + shifted_start + .checked_add(clip.duration_frames) + .ok_or_else(|| format!("ripple shift overflows clip {} endFrame", clip.id))?; + } + } + } + Ok(total_push) +} + +fn checked_clip_end(clip: &Clip) -> i32 { + clip.start_frame + .checked_add(clip.duration_frames) + .expect("clip arithmetic was validated") +} + +fn validate_spec_arithmetic(spec: &PlaceSpec, start_frame: i32) -> Result<(), String> { + let trim_start = spec.trim_start_frame.unwrap_or(0); + let trim_end = spec.trim_end_frame.unwrap_or(0); + if start_frame < 0 || spec.duration_frames < 1 { + return Err("startFrame must be >= 0 and durationFrames >= 1".into()); + } + if !matches!(spec.media_type, ClipType::Image | ClipType::Text) + && (trim_start < 0 || trim_end < 0) + { + return Err("trim frames must be >= 0 for audio/video clips".into()); + } + start_frame + .checked_add(spec.duration_frames) + .ok_or_else(|| "startFrame + durationFrames overflows".to_string())?; + spec.duration_frames + .checked_add(trim_start) + .and_then(|value| value.checked_add(trim_end)) + .ok_or_else(|| "durationFrames + trim frames overflows".to_string())?; + trim_start + .checked_add(spec.duration_frames) + .and_then(|value| value.checked_add(trim_end)) + .ok_or_else(|| "source-frame extent overflows".to_string())?; + trim_end + .checked_add(spec.duration_frames) + .ok_or_else(|| "trimEnd source-frame extent overflows".to_string())?; + Ok(()) +} + +fn validate_clip_arithmetic(clip: &Clip) -> Result<(), String> { + if !clip.speed.is_finite() || clip.speed <= 0.0 { + return Err(format!("clip {} speed must be finite and > 0", clip.id)); + } + let spec = PlaceSpec { + media_ref: clip.media_ref.clone(), + media_type: clip.media_type, + source_clip_type: clip.source_clip_type, + start_frame: clip.start_frame, + duration_frames: clip.duration_frames, + trim_start_frame: Some(clip.trim_start_frame), + trim_end_frame: Some(clip.trim_end_frame), + has_audio: false, + add_linked_audio: false, + transform: None, + }; + validate_spec_arithmetic(&spec, clip.start_frame)?; + let consumed = (clip.duration_frames as f64 * clip.speed).round(); + if !(0.0..=i32::MAX as f64).contains(&consumed) { + return Err(format!( + "clip {} source-frame extent is out of range", + clip.id + )); + } + let consumed = consumed as i32; + clip.trim_start_frame + .checked_add(consumed) + .ok_or_else(|| format!("clip {} trimStart source-frame extent overflows", clip.id))?; + clip.trim_end_frame + .checked_add(consumed) + .ok_or_else(|| format!("clip {} trimEnd source-frame extent overflows", clip.id))?; + clip.trim_start_frame + .checked_add(consumed) + .and_then(|value| value.checked_add(clip.trim_end_frame)) + .ok_or_else(|| format!("clip {} source-frame extent overflows", clip.id))?; + Ok(()) +} + fn find(timeline: &Timeline, clip_id: &str) -> Option<(usize, usize)> { for (ti, t) in timeline.tracks.iter().enumerate() { if let Some(ci) = t.clips.iter().position(|c| c.id == clip_id) { @@ -441,25 +753,23 @@ mod tests { ); } + // A sync-locked follower can never refuse through `ripple_delete` / + // `ripple_delete_ranges_on_track` for moving past frame 0. Shifts are + // computed by `RippleEngine::try_compute_ripple_shifts_for_ranges`, which + // only counts removed ranges with `end <= clip.start`. On a well-formed + // timeline (all starts validated >= 0) those ranges are disjoint, ordered, + // and lie within `[0, start]`, so the accumulated shift never exceeds + // `clip.start` and the new start frame stays >= 0. The negative-start + // refusal in `validate_shifts` is therefore reachable only via a direct + // call with a hand-crafted negative `ClipShift`, tested below. #[test] - fn ripple_delete_refuses_when_follower_passes_zero() { + fn validate_shifts_refuses_negative_start() { let mut tl = Timeline::new(); - // deleted range [100,130) on track0; follower clip at 10 would shift to -20. - tl.tracks.push(one_track(vec![clip("a", 100, 30)], true)); - let mut a = Track::new("audio", ClipType::Audio); - a.sync_locked = true; - a.clips.push(clip("early", 10, 30)); // before the range? end 40 <= 100 -> shift counts -> 10-30 = -20 - tl.tracks.push(a); - // Wait: shift = sum of ranges with end <= clip.start. range end 130 > 10 -> NOT counted. - // So 'early' wouldn't shift. Use a clip AFTER the range that lands negative is impossible. - // Instead: follower clip strictly after range, with another making negative impossible. - // Simpler: put follower clip at 120 (inside) won't shift cleanly; use start 200 -> shift 30 -> 170 (fine). - // To force <0 we need a removed range before a clip with start < range length — not possible since - // only ranges fully before the clip count. So negative-start refusal needs range length > clip.start - // with range end <= clip.start: contradiction. This branch is covered by validate_shifts unit test instead. - // Here just assert the safe case succeeds. - let res = ripple_delete(&mut tl, &["a".to_string()].into_iter().collect(), &label); - assert!(res.is_ok()); + tl.tracks.push(one_track(vec![clip("x", 0, 30)], true)); + let reason = validate_shifts(&tl, 0, &[ClipShift::new("x", -5)], &label(&tl, 0)).unwrap(); + assert!(reason.contains("past the timeline start")); + // Non-negative shifts on the same track pass. + assert!(validate_shifts(&tl, 0, &[ClipShift::new("x", 10)], &label(&tl, 0)).is_none()); } #[test] @@ -488,6 +798,71 @@ mod tests { } } + #[test] + fn ripple_delete_ranges_keeps_linked_av_frame_exact() { + let mut tl = Timeline::new(); + let mut video = Track::new("video", ClipType::Video); + let mut video_clip = clip("video-clip", 0, 900); + video_clip.link_group_id = Some("av".into()); + video.clips.push(video_clip); + let mut audio = Track::new("audio", ClipType::Audio); + let mut audio_clip = clip("audio-clip", 0, 900); + audio_clip.media_type = ClipType::Audio; + audio_clip.link_group_id = Some("av".into()); + audio.clips.push(audio_clip); + tl.tracks.extend([video, audio]); + + let g = SeqIdGen::new("r-"); + let out = ripple_delete_ranges_on_track(&mut tl, 1, &[FrameRange::new(6, 12)], &label, &g); + assert!(matches!(out, RippleOutcome::Ok(_))); + let spans = |track: &Track| { + track + .clips + .iter() + .map(|clip| (clip.start_frame, clip.end_frame())) + .collect::>() + }; + assert_eq!(spans(&tl.tracks[0]), vec![(0, 6), (6, 894)]); + assert_eq!(spans(&tl.tracks[1]), vec![(0, 6), (6, 894)]); + } + + #[test] + fn ripple_delete_multiple_ranges_with_sync_locked_captions_is_atomic() { + let mut tl = Timeline::new(); + let mut captions = Track::new("captions", ClipType::Video); + captions.sync_locked = true; + captions.clips.extend([ + clip("caption-1", 0, 86), + clip("caption-2", 146, 116), + clip("caption-3", 350, 21), + clip("caption-4", 371, 57), + clip("caption-5", 428, 82), + ]); + let mut video = Track::new("video", ClipType::Video); + let mut video_clip = clip("video-clip", 0, 900); + video_clip.link_group_id = Some("av".into()); + video.clips.push(video_clip); + let mut audio = Track::new("audio", ClipType::Audio); + let mut audio_clip = clip("audio-clip", 0, 900); + audio_clip.media_type = ClipType::Audio; + audio_clip.link_group_id = Some("av".into()); + audio.clips.push(audio_clip); + tl.tracks.extend([captions, video, audio]); + let before = tl.clone(); + + let g = SeqIdGen::new("r-"); + let out = ripple_delete_ranges_on_track( + &mut tl, + 2, + &[FrameRange::new(151, 154), FrameRange::new(358, 365)], + &label, + &g, + ); + + assert!(matches!(out, RippleOutcome::Refused(_))); + assert_eq!(tl, before, "a follower collision must be side-effect free"); + } + #[test] fn ripple_delete_ranges_refuses_on_locked_follower_collision() { let mut tl = Timeline::new(); @@ -533,4 +908,38 @@ mod tests { 50 ); } + + #[test] + fn extreme_range_and_insert_refuse_without_mutation_or_ids() { + let mut tl = Timeline::new(); + tl.tracks.push(one_track(vec![clip("a", 0, 30)], true)); + let before = tl.clone(); + let ids = SeqIdGen::new("extreme-ripple-"); + + let delete_result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + ripple_delete_ranges_on_track( + &mut tl, + 0, + &[FrameRange::new(i32::MIN, i32::MAX)], + &label, + &ids, + ) + })); + assert!(matches!(delete_result.unwrap(), RippleOutcome::Refused(_))); + assert_eq!(tl, before); + assert_eq!(ids.count(), 0); + + let insert_result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + ripple_insert( + &mut tl, + &[PlaceSpec::new("m", ClipType::Video, 0, 1)], + 0, + i32::MAX, + &ids, + ) + })); + assert!(insert_result.unwrap().is_empty()); + assert_eq!(tl, before); + assert_eq!(ids.count(), 0); + } } diff --git a/crates/opentake-ops/src/ops/settings.rs b/crates/opentake-ops/src/ops/settings.rs index 9e35fa7b..af377705 100644 --- a/crates/opentake-ops/src/ops/settings.rs +++ b/crates/opentake-ops/src/ops/settings.rs @@ -22,7 +22,7 @@ //! it is safe: clips simply keep their explicit transform across a //! resolution change (they are not silently re-fitted). -use opentake_domain::Timeline; +use opentake_domain::{Clip, ClipType, Timeline}; /// Apply new project settings to `timeline`. Returns `true` when anything /// changed (the command layer's snapshot/commit also re-checks, so a no-op call @@ -32,6 +32,17 @@ pub fn set_timeline_settings(timeline: &mut Timeline, fps: i32, width: i32, heig if fps <= 0 || width <= 0 || height <= 0 { return false; } + if !settings_frame_projection_is_safe(timeline, fps) { + return false; + } + + // Nested timelines share one project timebase and output canvas. Keep every + // stored child synchronized (including frame/keyframe rescaling) so entering + // a compound never exposes stale settings after the root changes. + let mut nested_changed = false; + for sequence in &mut timeline.nested_sequences { + nested_changed |= set_timeline_settings(&mut sequence.timeline, fps, width, height); + } let prev_fps = timeline.fps; let prev_width = timeline.width; @@ -39,7 +50,7 @@ pub fn set_timeline_settings(timeline: &mut Timeline, fps: i32, width: i32, heig let prev_configured = timeline.settings_configured; if fps == prev_fps && width == prev_width && height == prev_height && prev_configured { - return false; + return nested_changed; } // Rescale all frame-based values when FPS changes (upstream :26-52). @@ -62,6 +73,10 @@ pub fn set_timeline_settings(timeline: &mut Timeline, fps: i32, width: i32, heig clip.rescale_keyframes(scale); clip.fade_in_frames = round_scale(clip.fade_in_frames, scale); clip.fade_out_frames = round_scale(clip.fade_out_frames, scale); + if let Some(transition) = &mut clip.transition_out { + transition.duration_frames = + round_scale(transition.duration_frames, scale).max(1); + } clip.clamp_keyframes_to_duration(); clip.clamp_fades_to_duration(); previous_end = Some(clip.end_frame()); @@ -82,10 +97,91 @@ fn round_scale(value: i32, scale: f64) -> i32 { (value as f64 * scale).round() as i32 } +fn settings_frame_projection_is_safe(timeline: &Timeline, fps: i32) -> bool { + if timeline.fps <= 0 + || timeline + .nested_sequences + .iter() + .any(|sequence| !settings_frame_projection_is_safe(&sequence.timeline, fps)) + || timeline + .tracks + .iter() + .flat_map(|track| &track.clips) + .any(|clip| !clip_arithmetic_is_safe(clip)) + { + return false; + } + if timeline.fps == fps { + return true; + } + let scale = fps as f64 / timeline.fps as f64; + for track in &timeline.tracks { + let mut order: Vec = (0..track.clips.len()).collect(); + order.sort_by_key(|&index| track.clips[index].start_frame); + let mut previous_end = None; + for index in order { + let clip = &track.clips[index]; + let source_end = clip.start_frame.checked_add(clip.duration_frames); + let Some(source_end) = source_end else { + return false; + }; + let scaled_start = round_scale(clip.start_frame, scale); + let scaled_end = round_scale(source_end, scale); + let start_frame = scaled_start.max(previous_end.unwrap_or(scaled_start)); + let Some(duration_frames) = scaled_end.checked_sub(start_frame) else { + return false; + }; + let duration_frames = duration_frames.max(1); + let mut projected = clip.clone(); + projected.start_frame = start_frame; + projected.duration_frames = duration_frames; + projected.trim_start_frame = round_scale(clip.trim_start_frame, scale); + projected.trim_end_frame = round_scale(clip.trim_end_frame, scale); + if !clip_arithmetic_is_safe(&projected) { + return false; + } + previous_end = projected.start_frame.checked_add(projected.duration_frames); + } + } + true +} + +fn clip_arithmetic_is_safe(clip: &Clip) -> bool { + if clip.start_frame < 0 + || clip.duration_frames < 1 + || (!matches!(clip.media_type, ClipType::Image | ClipType::Text) + && (clip.trim_start_frame < 0 || clip.trim_end_frame < 0)) + || !clip.speed.is_finite() + || clip.speed <= 0.0 + || clip.start_frame.checked_add(clip.duration_frames).is_none() + || clip + .duration_frames + .checked_add(clip.trim_start_frame) + .and_then(|value| value.checked_add(clip.trim_end_frame)) + .is_none() + { + return false; + } + let consumed = (clip.duration_frames as f64 * clip.speed).round(); + if !(0.0..=i32::MAX as f64).contains(&consumed) { + return false; + } + let consumed = consumed as i32; + clip.trim_start_frame.checked_add(consumed).is_some() + && clip.trim_end_frame.checked_add(consumed).is_some() + && clip + .trim_start_frame + .checked_add(consumed) + .and_then(|value| value.checked_add(clip.trim_end_frame)) + .is_some() +} + #[cfg(test)] mod tests { use super::*; - use opentake_domain::{Clip, ClipType, Keyframe, KeyframeTrack, Track}; + use opentake_domain::{ + Clip, ClipType, Keyframe, KeyframeTrack, Track, Transition, TransitionKind, + }; fn track(id: &str, kind: ClipType, clips: Vec) -> Track { let mut track = Track::new(id, kind); @@ -117,6 +213,29 @@ mod tests { assert_eq!((c.start_frame, c.duration_frames), (10, 40)); } + #[test] + fn settings_change_rescales_registered_nested_timelines() { + use opentake_domain::NestedSequence; + + let mut child = Timeline::new(); + child.tracks.push(track( + "child", + ClipType::Video, + vec![clip("nested", 15, 30)], + )); + let mut root = Timeline::new(); + root.nested_sequences + .push(NestedSequence::new("sequence", "Scene", child)); + + assert!(set_timeline_settings(&mut root, 60, 1280, 720)); + + let child = &root.nested_sequences[0].timeline; + assert_eq!((child.fps, child.width, child.height), (60, 1280, 720)); + assert!(child.settings_configured); + assert_eq!(child.tracks[0].clips[0].start_frame, 30); + assert_eq!(child.tracks[0].clips[0].duration_frames, 60); + } + #[test] fn fps_doubling_scales_clip_start_and_duration() { let mut tl = Timeline::new(); @@ -147,6 +266,29 @@ mod tests { assert_eq!(c.fade_out_frames, 24); } + #[test] + fn fps_change_scales_transition_duration() { + let mut tl = Timeline::new(); + let mut a = clip("a", 0, 60); + a.transition_out = Some(Transition { + from_clip_id: "a".into(), + to_clip_id: "b".into(), + kind: TransitionKind::CrossDissolve, + duration_frames: 15, + }); + tl.tracks + .push(track("v", ClipType::Video, vec![a, clip("b", 60, 60)])); + assert!(set_timeline_settings(&mut tl, 60, 1920, 1080)); + assert_eq!( + tl.tracks[0].clips[0] + .transition_out + .as_ref() + .unwrap() + .duration_frames, + 30 + ); + } + #[test] fn fps_change_rescales_keyframe_offsets() { let mut tl = Timeline::new(); diff --git a/crates/opentake-ops/src/ops/split.rs b/crates/opentake-ops/src/ops/split.rs index f69589e3..f15bf804 100644 --- a/crates/opentake-ops/src/ops/split.rs +++ b/crates/opentake-ops/src/ops/split.rs @@ -73,8 +73,12 @@ pub fn split_single_clip( ids: &dyn IdGen, ) -> Option { let (ti, ci) = find(timeline, clip_id)?; + // Validate all arithmetic before consuming the right-half id. The domain + // primitive is fallible for malformed persisted clips and extreme retimes. + opentake_domain::split_clip(&timeline.tracks[ti].clips[ci], at_frame, "preflight")?; let (left, right) = - opentake_domain::split_clip(&timeline.tracks[ti].clips[ci], at_frame, ids.next_id())?; + opentake_domain::split_clip(&timeline.tracks[ti].clips[ci], at_frame, ids.next_id()) + .expect("split arithmetic was prevalidated"); let right_id = right.id.clone(); timeline.tracks[ti].clips[ci] = left; timeline.tracks[ti].clips.push(right); diff --git a/crates/opentake-ops/src/ops/swap.rs b/crates/opentake-ops/src/ops/swap.rs index 45eb2214..2785a4b1 100644 --- a/crates/opentake-ops/src/ops/swap.rs +++ b/crates/opentake-ops/src/ops/swap.rs @@ -55,17 +55,15 @@ pub fn swap_clip_positions(timeline: &mut Timeline, id_a: &str, id_b: &str) -> b // Both clips vacate their slots, so they never block each other; only OTHER // clips on each destination track can refuse the swap. let exclude = [id_a, id_b]; - if !range_free( - &timeline.tracks[tb], - b_start, - b_start + clip_a.duration_frames, - &exclude, - ) || !range_free( - &timeline.tracks[ta], - a_start, - a_start + clip_b.duration_frames, - &exclude, - ) { + let Some(a_destination_end) = b_start.checked_add(clip_a.duration_frames) else { + return false; + }; + let Some(b_destination_end) = a_start.checked_add(clip_b.duration_frames) else { + return false; + }; + if !range_free(&timeline.tracks[tb], b_start, a_destination_end, &exclude) + || !range_free(&timeline.tracks[ta], a_start, b_destination_end, &exclude) + { return false; } remove_clip(timeline, id_a); @@ -95,10 +93,12 @@ fn find(timeline: &Timeline, clip_id: &str) -> Option<(usize, usize)> { /// True when `[start, end)` is free of any clip on `track` whose id isn't in /// `exclude` (half-open overlap test, matching the timeline's no-overlap rule). fn range_free(track: &opentake_domain::Track, start: i32, end: i32, exclude: &[&str]) -> bool { - !track - .clips - .iter() - .any(|c| !exclude.contains(&c.id.as_str()) && c.start_frame < end && c.end_frame() > start) + !track.clips.iter().any(|clip| { + let clip_end = clip.start_frame.checked_add(clip.duration_frames); + !exclude.contains(&clip.id.as_str()) + && (clip_end.is_none() + || (clip.start_frame < end && clip_end.is_some_and(|value| value > start))) + }) } #[cfg(test)] diff --git a/crates/opentake-ops/src/ops/tracks.rs b/crates/opentake-ops/src/ops/tracks.rs index d2cd4af4..79cddc51 100644 --- a/crates/opentake-ops/src/ops/tracks.rs +++ b/crates/opentake-ops/src/ops/tracks.rs @@ -88,13 +88,15 @@ pub fn available_audio_track_index( start_frame: i32, duration: i32, ) -> Option { + let end_frame = start_frame.checked_add(duration)?; let z = zones(timeline); for i in z.first_audio_index..z.track_count { let track = &timeline.tracks[i]; - let conflicts = track - .clips - .iter() - .any(|c| !(c.end_frame() <= start_frame || c.start_frame >= start_frame + duration)); + let conflicts = track.clips.iter().any(|clip| { + clip.start_frame + .checked_add(clip.duration_frames) + .is_none_or(|clip_end| !(clip_end <= start_frame || clip.start_frame >= end_frame)) + }); if !conflicts { return Some(i); } diff --git a/crates/opentake-ops/src/ops/trim.rs b/crates/opentake-ops/src/ops/trim.rs index e5d90f3d..5ed187c4 100644 --- a/crates/opentake-ops/src/ops/trim.rs +++ b/crates/opentake-ops/src/ops/trim.rs @@ -6,7 +6,7 @@ //! frames; their deltas are translated to timeline frames via `round(delta / //! speed)` before touching `start_frame` / `duration_frames`. -use opentake_domain::{ClipType, Timeline}; +use opentake_domain::{Clip, ClipType, Timeline}; /// Which edge a trim drag grabs. #[derive(Clone, Copy, PartialEq, Eq, Debug)] @@ -23,35 +23,79 @@ pub fn trim_clip_internal( trim_start_frame: i32, trim_end_frame: i32, ) { + let _ = trim_clip_internal_checked(timeline, clip_id, trim_start_frame, trim_end_frame); +} + +fn trim_clip_internal_checked( + timeline: &mut Timeline, + clip_id: &str, + trim_start_frame: i32, + trim_end_frame: i32, +) -> bool { let Some((ti, ci)) = find(timeline, clip_id) else { - return; + return true; }; let clip = &timeline.tracks[ti].clips[ci]; + if !clip_arithmetic_is_safe(clip) + || (!matches!(clip.media_type, ClipType::Image | ClipType::Text) + && (trim_start_frame < 0 || trim_end_frame < 0)) + { + return false; + } let prev_start = clip.trim_start_frame; let prev_end = clip.trim_end_frame; let prev_duration = clip.duration_frames; let speed = clip.speed; let reversed = clip.reversed; - let delta_start_source = trim_start_frame - prev_start; - let delta_end_source = trim_end_frame - prev_end; - let delta_start_timeline = (delta_start_source as f64 / speed).round() as i32; - let delta_end_timeline = (delta_end_source as f64 / speed).round() as i32; - let new_duration = prev_duration - delta_start_timeline - delta_end_timeline; - let new_start_frame = clip.start_frame - + if reversed { - delta_end_timeline - } else { - delta_start_timeline - }; + let Some(delta_start_source) = trim_start_frame.checked_sub(prev_start) else { + return false; + }; + let Some(delta_end_source) = trim_end_frame.checked_sub(prev_end) else { + return false; + }; + let delta_start_timeline = (delta_start_source as f64 / speed).round(); + let delta_end_timeline = (delta_end_source as f64 / speed).round(); + if !(i32::MIN as f64..=i32::MAX as f64).contains(&delta_start_timeline) + || !(i32::MIN as f64..=i32::MAX as f64).contains(&delta_end_timeline) + { + return false; + } + let delta_start_timeline = delta_start_timeline as i32; + let delta_end_timeline = delta_end_timeline as i32; + let Some(new_duration) = prev_duration + .checked_sub(delta_start_timeline) + .and_then(|duration| duration.checked_sub(delta_end_timeline)) + else { + return false; + }; + let timeline_delta = if reversed { + delta_end_timeline + } else { + delta_start_timeline + }; + let Some(new_start_frame) = clip.start_frame.checked_add(timeline_delta) else { + return false; + }; + + let mut updated = clip.clone(); + updated.trim_start_frame = trim_start_frame; + updated.trim_end_frame = trim_end_frame; + updated.start_frame = new_start_frame; + updated.duration_frames = new_duration; + if !clip_arithmetic_is_safe(&updated) { + return false; + } let c = &mut timeline.tracks[ti].clips[ci]; c.trim_start_frame = trim_start_frame; c.trim_end_frame = trim_end_frame; + c.loudness_normalization = None; c.start_frame = new_start_frame; c.set_duration(new_duration); sort_track(timeline, ti); + true } /// A `(clip_id, trim_start, trim_end)` edit, in source frames. @@ -59,10 +103,45 @@ pub type TrimEdit = (String, i32, i32); /// Apply a batch of trim edits (one undo group upstream; here just sequential). /// 1:1 port of `trimClips(_:)`. -pub fn trim_clips(timeline: &mut Timeline, edits: &[TrimEdit]) { +pub fn trim_clips(timeline: &mut Timeline, edits: &[TrimEdit]) -> bool { + let mut candidate = timeline.clone(); for (id, ts, te) in edits { - trim_clip_internal(timeline, id, *ts, *te); + if !trim_clip_internal_checked(&mut candidate, id, *ts, *te) { + return false; + } + } + *timeline = candidate; + true +} + +fn clip_arithmetic_is_safe(clip: &Clip) -> bool { + if clip.start_frame < 0 + || clip.duration_frames < 1 + || (!matches!(clip.media_type, ClipType::Image | ClipType::Text) + && (clip.trim_start_frame < 0 || clip.trim_end_frame < 0)) + || !clip.speed.is_finite() + || clip.speed <= 0.0 + || clip.start_frame.checked_add(clip.duration_frames).is_none() + || clip + .duration_frames + .checked_add(clip.trim_start_frame) + .and_then(|value| value.checked_add(clip.trim_end_frame)) + .is_none() + { + return false; + } + let consumed = (clip.duration_frames as f64 * clip.speed).round(); + if !(0.0..=i32::MAX as f64).contains(&consumed) { + return false; } + let consumed = consumed as i32; + clip.trim_start_frame.checked_add(consumed).is_some() + && clip.trim_end_frame.checked_add(consumed).is_some() + && clip + .trim_start_frame + .checked_add(consumed) + .and_then(|value| value.checked_add(clip.trim_end_frame)) + .is_some() } /// Compute the new source-frame `(trim_start, trim_end)` for an edge drag of @@ -80,7 +159,7 @@ pub fn trim_values( let unbounded = media_type == ClipType::Image || media_type == ClipType::Text; match edge { TrimEdge::Left => { - let new_start = cur_trim_start + source_delta; + let new_start = cur_trim_start.saturating_add(source_delta); ( if unbounded { new_start @@ -91,7 +170,7 @@ pub fn trim_values( ) } TrimEdge::Right => { - let new_end = cur_trim_end - source_delta; + let new_end = cur_trim_end.saturating_sub(source_delta); ( cur_trim_start, if unbounded { new_end } else { new_end.max(0) }, @@ -179,6 +258,33 @@ mod tests { assert_eq!((ts, te), (0, 40)); } + #[test] + fn trim_values_extremes_never_overflow() { + let left = std::panic::catch_unwind(|| { + trim_values( + ClipType::Image, + f64::MAX, + i32::MAX, + i32::MIN, + TrimEdge::Left, + i32::MAX, + ) + }); + assert_eq!(left.unwrap(), (i32::MAX, i32::MIN)); + + let right = std::panic::catch_unwind(|| { + trim_values( + ClipType::Text, + f64::MAX, + i32::MAX, + i32::MIN, + TrimEdge::Right, + i32::MIN, + ) + }); + assert_eq!(right.unwrap(), (i32::MAX, 0)); + } + #[test] fn trim_reversed_clip_keeps_visible_window() { let mut c = Clip::new("c", "a", 100, 30); diff --git a/crates/opentake-ops/tests/command_apply.rs b/crates/opentake-ops/tests/command_apply.rs index f84f8099..37ffcd04 100644 --- a/crates/opentake-ops/tests/command_apply.rs +++ b/crates/opentake-ops/tests/command_apply.rs @@ -3,14 +3,23 @@ //! resulting `Timeline` / `MediaManifest`, undo/redo behavior, versioning, and //! the refusal path — the behaviors the port must match upstream. -use opentake_domain::{AnimPair, Interpolation, Keyframe, KeyframeTrack}; -use opentake_domain::{ChromaKey, ColorGrade, Effect, Mask, MaskShape, Point2}; +use opentake_domain::{ + AnimPair, Crop, Interpolation, Keyframe, KeyframeTrack, NestedSequence, Transition, + TransitionKind, +}; +use opentake_domain::{ + ChromaKey, ColorGrade, Effect, HslSecondary, LiftGammaGain, LutReference, Mask, MaskShape, + Point2, Rgb, +}; use opentake_domain::{ Clip, ClipType, MediaManifest, MediaManifestEntry, MediaSource, Timeline, Track, Transform, }; +use opentake_ops::command::{ + NewTrackClipMode, PasteClipEntry, PlaceMediaTarget, ProjectTimelineSettings, UnplacedClipEntry, +}; use opentake_ops::{ - apply, ClipEntry, ClipMove, ClipProperties, EditCommand, EditError, EditorState, FrameRange, - KeyframePayload, KeyframeProperty, SeqIdGen, TextEntry, + apply, ClipEntry, ClipMove, ClipProperties, ClipPropertyAssignment, EditCommand, EditError, + EditorState, FrameRange, KeyframePayload, KeyframeProperty, SeqIdGen, TextEntry, }; // ---- builders ------------------------------------------------------------- @@ -55,6 +64,519 @@ fn entry(track_index: usize, media_type: ClipType, start: i32, dur: i32) -> Clip } } +#[test] +fn per_clip_properties_commit_once_and_one_undo_restores_every_transform() { + let first = clip("first", 0, 30); + let second = clip("second", 40, 30); + let original_first = first.transform; + let original_second = second.transform; + let mut state = state(vec![video_track("video", true, vec![first, second])]); + let ids = SeqIdGen::new("property-"); + + let changed = apply( + &mut state, + EditCommand::SetClipPropertiesPerClip { + assignments: vec![ + ClipPropertyAssignment { + clip_id: "first".into(), + properties: ClipProperties { + transform: Some(Transform { + width: 0.4, + height: 0.2, + ..Transform::default() + }), + ..Default::default() + }, + }, + ClipPropertyAssignment { + clip_id: "second".into(), + properties: ClipProperties { + transform: Some(Transform { + center_x: 0.7, + center_y: 0.3, + width: 0.2, + height: 0.4, + ..Transform::default() + }), + ..Default::default() + }, + }, + ], + }, + &ids, + ) + .unwrap(); + + assert!(changed.changed); + assert_eq!(state.undo_depth(), 1); + assert_eq!(state.version(), 1); + assert_eq!(state.timeline.tracks[0].clips[0].transform.width, 0.4); + assert_eq!(state.timeline.tracks[0].clips[1].transform.center_x, 0.7); + + let undone = apply(&mut state, EditCommand::Undo, &ids).unwrap(); + assert!(undone.changed); + assert_eq!(state.timeline.tracks[0].clips[0].transform, original_first); + assert_eq!(state.timeline.tracks[0].clips[1].transform, original_second); + assert_eq!(state.version(), 2); +} + +#[test] +fn compound_create_edit_move_trim_duplicate_dissolve_and_undo_share_one_command_path() { + let mut child = Timeline::new(); + child.tracks = vec![video_track( + "child-track", + true, + vec![Clip::new("child", "asset-child", 5, 20)], + )]; + let mut st = state(vec![video_track("root", true, vec![])]); + let ids = SeqIdGen::new("nested-"); + + let created = apply( + &mut st, + EditCommand::CreateNestedSequence { + name: "Scene A".into(), + timeline: child, + track_index: 0, + start_frame: 100, + duration_frames: 30, + }, + &ids, + ) + .unwrap(); + let compound_id = created.affected_clip_ids[0].clone(); + assert_eq!(st.timeline.nested_sequences.len(), 1); + assert_eq!(st.undo_depth(), 1); + + let sequence_id = st.timeline.nested_sequences[0].id.clone(); + let mut edited = st.timeline.nested_sequences[0].timeline.clone(); + edited.tracks[0].clips[0].media_ref = "asset-edited".into(); + apply( + &mut st, + EditCommand::SetNestedSequenceTimeline { + sequence_id: sequence_id.clone(), + timeline: edited, + }, + &ids, + ) + .unwrap(); + apply( + &mut st, + EditCommand::RenameNestedSequence { + sequence_id, + name: "Edited scene".into(), + }, + &ids, + ) + .unwrap(); + apply( + &mut st, + EditCommand::MoveClips { + moves: vec![ClipMove { + clip_id: compound_id.clone(), + to_track: 0, + to_frame: 110, + }], + }, + &ids, + ) + .unwrap(); + apply( + &mut st, + EditCommand::TrimClips { + edits: vec![(compound_id.clone(), 5, 0)], + }, + &ids, + ) + .unwrap(); + let duplicate = apply( + &mut st, + EditCommand::DuplicateClips { + clip_ids: vec![compound_id.clone()], + offset_frames: 40, + target_track_indexes: vec![0], + }, + &ids, + ) + .unwrap(); + assert_eq!(duplicate.affected_clip_ids.len(), 1); + + let before_dissolve = st.timeline.clone(); + let dissolved = apply( + &mut st, + EditCommand::DissolveNestedSequence { + clip_id: compound_id.clone(), + }, + &ids, + ) + .unwrap(); + assert_eq!(dissolved.affected_clip_ids.len(), 1); + let leaf = st + .timeline + .tracks + .iter() + .flat_map(|track| &track.clips) + .find(|clip| dissolved.affected_clip_ids.contains(&clip.id)) + .unwrap(); + assert_eq!(leaf.media_ref, "asset-edited"); + assert_eq!(leaf.start_frame, 115); + assert_eq!(leaf.duration_frames, 20); + assert_eq!(leaf.trim_start_frame, 0); + + apply(&mut st, EditCommand::Undo, &ids).unwrap(); + assert_eq!(st.timeline, before_dissolve); +} + +#[test] +fn dissolve_refuses_parent_edits_without_changing_history() { + let mut child = Timeline::new(); + child.tracks = vec![video_track( + "child-track", + true, + vec![Clip::new("child", "asset-child", 0, 20)], + )]; + let mut st = state(vec![video_track("root", true, vec![])]); + let ids = SeqIdGen::new("nested-"); + let created = apply( + &mut st, + EditCommand::CreateNestedSequence { + name: "Scene".into(), + timeline: child, + track_index: 0, + start_frame: 0, + duration_frames: 20, + }, + &ids, + ) + .unwrap(); + let compound_id = created.affected_clip_ids[0].clone(); + apply( + &mut st, + EditCommand::SetClipProperties { + clip_ids: vec![compound_id.clone()], + properties: Box::new(ClipProperties { + opacity: Some(0.5), + ..ClipProperties::default() + }), + }, + &ids, + ) + .unwrap(); + let before = st.timeline.clone(); + let undo_depth = st.undo_depth(); + let version = st.version(); + + let error = apply( + &mut st, + EditCommand::DissolveNestedSequence { + clip_id: compound_id, + }, + &ids, + ) + .unwrap_err(); + + assert!(error + .to_string() + .contains("parent-level edits must be normalized")); + assert_eq!(st.timeline, before); + assert_eq!(st.undo_depth(), undo_depth); + assert_eq!(st.version(), version); +} + +#[test] +fn compound_edit_refuses_properties_that_cannot_render() { + let mut child = Timeline::new(); + child.tracks = vec![video_track( + "child-track", + true, + vec![Clip::new("child", "asset-child", 0, 20)], + )]; + let mut st = state(vec![video_track("root", true, vec![])]); + let ids = SeqIdGen::new("nested-"); + let created = apply( + &mut st, + EditCommand::CreateNestedSequence { + name: "Scene".into(), + timeline: child, + track_index: 0, + start_frame: 0, + duration_frames: 20, + }, + &ids, + ) + .unwrap(); + let compound_id = created.affected_clip_ids[0].clone(); + let before = st.timeline.clone(); + let undo_depth = st.undo_depth(); + + let error = apply( + &mut st, + EditCommand::SetClipProperties { + clip_ids: vec![compound_id.clone()], + properties: Box::new(ClipProperties { + speed: Some(2.0), + ..ClipProperties::default() + }), + }, + &ids, + ) + .unwrap_err(); + assert!(error.to_string().contains("does not support retime")); + assert_eq!(st.timeline, before); + assert_eq!(st.undo_depth(), undo_depth); + + let error = apply( + &mut st, + EditCommand::SetColorGrade { + clip_ids: vec![compound_id], + grade: Some(ColorGrade::default()), + }, + &ids, + ) + .unwrap_err(); + assert!(error.to_string().contains("direct pixel effects")); + assert_eq!(st.timeline, before); + assert_eq!(st.undo_depth(), undo_depth); +} + +#[test] +fn compound_creation_expands_linked_partners_and_refuses_unselected_overlap() { + let mut video = Clip::new("video", "video-asset", 0, 10); + video.link_group_id = Some("av".into()); + let blocker = Clip::new("blocker", "blocker-asset", 15, 5); + let later = Clip::new("later", "later-asset", 20, 10); + let mut audio = Clip::new("audio", "audio-asset", 0, 10); + audio.media_type = ClipType::Audio; + audio.source_clip_type = ClipType::Audio; + audio.link_group_id = Some("av".into()); + let mut st = state(vec![ + video_track("v1", true, vec![video, blocker]), + video_track("v2", true, vec![later]), + audio_track("a1", true, vec![audio]), + ]); + let ids = SeqIdGen::new("nested-"); + let before = st.timeline.clone(); + + let error = apply( + &mut st, + EditCommand::CreateNestedSequenceFromClips { + name: "Blocked".into(), + clip_ids: vec!["video".into(), "later".into()], + }, + &ids, + ) + .unwrap_err(); + assert!(error.to_string().contains("overlaps an unselected clip")); + assert_eq!(st.timeline, before); + + apply( + &mut st, + EditCommand::CreateNestedSequenceFromClips { + name: "Linked".into(), + clip_ids: vec!["video".into()], + }, + &ids, + ) + .unwrap(); + let child_clips = st.timeline.nested_sequences[0] + .timeline + .tracks + .iter() + .flat_map(|track| &track.clips) + .collect::>(); + assert_eq!(child_clips.len(), 2); + assert!(child_clips.iter().any(|clip| clip.id == "video")); + assert!(child_clips.iter().any(|clip| clip.id == "audio")); +} + +#[test] +fn dissolve_remaps_link_groups_and_transition_targets() { + let mut first = Clip::new("first", "first-asset", 0, 10); + first.link_group_id = Some("av".into()); + first.transition_out = Some(opentake_domain::Transition { + from_clip_id: "first".into(), + to_clip_id: "second".into(), + kind: TransitionKind::CrossDissolve, + duration_frames: 3, + }); + let second = Clip::new("second", "second-asset", 10, 10); + let mut audio = Clip::new("audio", "audio-asset", 0, 10); + audio.media_type = ClipType::Audio; + audio.source_clip_type = ClipType::Audio; + audio.link_group_id = Some("av".into()); + let mut child = Timeline::new(); + child.tracks = vec![ + video_track("child-video", true, vec![first, second]), + audio_track("child-audio", true, vec![audio]), + ]; + let mut st = state(vec![video_track("root", true, vec![])]); + let ids = SeqIdGen::new("nested-"); + let created = apply( + &mut st, + EditCommand::CreateNestedSequence { + name: "Scene".into(), + timeline: child, + track_index: 0, + start_frame: 0, + duration_frames: 20, + }, + &ids, + ) + .unwrap(); + apply( + &mut st, + EditCommand::DissolveNestedSequence { + clip_id: created.affected_clip_ids[0].clone(), + }, + &ids, + ) + .unwrap(); + + let clips = st + .timeline + .tracks + .iter() + .flat_map(|track| &track.clips) + .collect::>(); + let first = clips + .iter() + .find(|clip| clip.media_ref == "first-asset") + .unwrap(); + let second = clips + .iter() + .find(|clip| clip.media_ref == "second-asset") + .unwrap(); + let audio = clips + .iter() + .find(|clip| clip.media_ref == "audio-asset") + .unwrap(); + assert_eq!(first.transition_out.as_ref().unwrap().to_clip_id, second.id); + assert!(first.link_group_id.is_some()); + assert_eq!(first.link_group_id, audio.link_group_id); + assert_ne!(first.link_group_id.as_deref(), Some("av")); +} + +#[test] +fn invalid_nested_edit_restores_document_and_history() { + let mut st = state(vec![video_track("root", true, vec![])]); + let ids = SeqIdGen::new("nested-"); + let mut child = Timeline::new(); + child.tracks.push(video_track( + "child-track", + true, + vec![Clip::new_nested("bad-ref", "missing", 0, 10)], + )); + let before = st.timeline.clone(); + let error = apply( + &mut st, + EditCommand::CreateNestedSequence { + name: "Invalid".into(), + timeline: child, + track_index: 0, + start_frame: 0, + duration_frames: 10, + }, + &ids, + ) + .unwrap_err(); + + assert!(error + .to_string() + .contains("missing nested sequence reference")); + assert_eq!(st.timeline, before); + assert_eq!(st.undo_depth(), 0); +} + +#[test] +fn nested_child_command_edits_in_place_and_root_undo_restores_it() { + let mut child = Timeline::new(); + child.tracks = vec![video_track( + "child-track", + true, + vec![Clip::new("child-a", "asset", 0, 20)], + )]; + let mut st = state(vec![video_track("root", true, vec![])]); + let ids = SeqIdGen::new("nested-"); + apply( + &mut st, + EditCommand::CreateNestedSequence { + name: "Scene".into(), + timeline: child, + track_index: 0, + start_frame: 0, + duration_frames: 20, + }, + &ids, + ) + .unwrap(); + let sequence_id = st.timeline.nested_sequences[0].id.clone(); + let before = st.timeline.clone(); + + let result = apply( + &mut st, + EditCommand::EditNestedSequence { + sequence_id, + command: Box::new(EditCommand::MoveClips { + moves: vec![ClipMove { + clip_id: "child-a".into(), + to_track: 0, + to_frame: 7, + }], + }), + }, + &ids, + ) + .unwrap(); + assert!(result.changed); + assert_eq!( + st.timeline.nested_sequences[0].timeline.tracks[0].clips[0].start_frame, + 7 + ); + + apply(&mut st, EditCommand::Undo, &ids).unwrap(); + assert_eq!(st.timeline, before); +} + +#[test] +fn nested_child_refuses_root_scoped_commands() { + let mut child = Timeline::new(); + child.tracks = vec![video_track("child-track", true, vec![])]; + let mut st = state(vec![video_track("root", true, vec![])]); + let ids = SeqIdGen::new("nested-"); + apply( + &mut st, + EditCommand::CreateNestedSequence { + name: "Scene".into(), + timeline: child, + track_index: 0, + start_frame: 0, + duration_frames: 20, + }, + &ids, + ) + .unwrap(); + let sequence_id = st.timeline.nested_sequences[0].id.clone(); + let before_timeline = st.timeline.clone(); + let before_manifest = st.manifest.clone(); + let undo_depth = st.undo_depth(); + + let error = apply( + &mut st, + EditCommand::EditNestedSequence { + sequence_id, + command: Box::new(EditCommand::DeleteMedia { + asset_ids: vec!["asset".into()], + }), + }, + &ids, + ) + .unwrap_err(); + + assert!(error.to_string().contains("must target the root timeline")); + assert_eq!(st.timeline, before_timeline); + assert_eq!(st.manifest, before_manifest); + assert_eq!(st.undo_depth(), undo_depth); +} + // ---- add_clips + overwrite ------------------------------------------------ #[test] @@ -109,6 +631,23 @@ fn add_clips_applies_supplied_transform() { assert_eq!(placed.transform.height, 1.0); } +#[test] +fn add_clips_accepts_audio_lane_derived_from_video_asset() { + let mut st = state(vec![audio_track("a", true, vec![])]); + let g = SeqIdGen::new("n-"); + let mut e = entry(0, ClipType::Audio, 15, 110); + e.source_clip_type = ClipType::Video; + e.trim_start_frame = Some(10); + + apply(&mut st, EditCommand::AddClips { entries: vec![e] }, &g).unwrap(); + + let placed = &st.timeline.tracks[0].clips[0]; + assert_eq!(placed.media_type, ClipType::Audio); + assert_eq!(placed.source_clip_type, ClipType::Video); + assert_eq!(placed.start_frame, 15); + assert_eq!(placed.trim_start_frame, 10); +} + #[test] fn add_clips_rejects_out_of_range_track() { let mut st = state(vec![video_track("v", true, vec![])]); @@ -279,53 +818,124 @@ fn split_linked_pair_splits_partner_and_regroups() { } #[test] -fn duplicate_linked_pair_keeps_copies_linked() { - // Option/Alt-drag duplicating an A/V linked pair must keep the copies linked - // to each other under a fresh group id (groupCounts/groupRemap semantics). +fn split_clips_deduplicates_linked_targets_and_undoes_the_whole_batch_once() { let mut st = linked_av_state(); - let g = SeqIdGen::default(); + st.timeline + .tracks + .push(video_track("overlay", true, vec![clip("solo", 90, 80)])); + let before = st.timeline.clone(); + let g = SeqIdGen::new("batch-split-"); + let res = apply( &mut st, - EditCommand::DuplicateClips { - clip_ids: vec!["v1".into(), "a1".into()], - offset_frames: 200, - target_track_indexes: vec![0, 1], + EditCommand::SplitClips { + clip_ids: vec!["v1".into(), "a1".into(), "v1".into(), "solo".into()], + at_frame: 130, }, &g, ) .unwrap(); - assert_eq!(res.affected_clip_ids.len(), 2); - // The copies share a NEW link_group_id (same as each other, different from - // the source "g1") — the A/V link survives the duplicate. - let vc = find_clip(&st, &res.affected_clip_ids[0]); - let ac = find_clip(&st, &res.affected_clip_ids[1]); - assert_eq!(vc.link_group_id, ac.link_group_id); - assert_ne!(vc.link_group_id.as_deref(), Some("g1")); - assert!( - vc.link_group_id.is_some(), - "linked pair copies must stay linked" - ); + assert!(res.changed); + assert_eq!(res.action_name, "Split Clips"); + assert_eq!(res.affected_clip_ids.len(), 3); + assert_eq!(st.undo_depth(), 1); + assert_eq!(st.version(), 1); + assert_eq!(st.timeline.tracks[0].clips.len(), 2); + assert_eq!(st.timeline.tracks[1].clips.len(), 2); + assert_eq!(st.timeline.tracks[2].clips.len(), 2); - // Originals keep "g1". - assert_eq!(find_clip(&st, "v1").link_group_id.as_deref(), Some("g1")); - assert_eq!(find_clip(&st, "a1").link_group_id.as_deref(), Some("g1")); + let undo = apply(&mut st, EditCommand::Undo, &g).unwrap(); + assert!(undo.changed); + assert_eq!(st.timeline, before); + assert_eq!(st.undo_depth(), 0); } #[test] -fn remove_clips_expands_to_linked_partner() { +fn split_clips_preflights_every_target_before_ids_history_or_timeline_change() { let mut st = linked_av_state(); - let g = SeqIdGen::default(); - // removing just v1 should also remove its linked a1. - let res = apply( + let before = st.timeline.clone(); + let g = SeqIdGen::new("rejected-split-"); + + let missing = apply( &mut st, - EditCommand::RemoveClips { - clip_ids: vec!["v1".into()], + EditCommand::SplitClips { + clip_ids: vec!["v1".into(), "missing".into()], + at_frame: 130, }, &g, ) - .unwrap(); - assert!(res.changed); + .unwrap_err(); + assert!(matches!(missing, EditError::Invalid(_))); + assert_eq!(st.timeline, before); + assert_eq!(st.undo_depth(), 0); + assert_eq!(st.version(), 0); + assert_eq!(g.count(), 0); + + let boundary = apply( + &mut st, + EditCommand::SplitClips { + clip_ids: vec!["v1".into(), "a1".into()], + at_frame: 100, + }, + &g, + ) + .unwrap_err(); + assert!(matches!(boundary, EditError::Invalid(_))); + assert_eq!(st.timeline, before); + assert_eq!(st.undo_depth(), 0); + assert_eq!(st.version(), 0); + assert_eq!(g.count(), 0); +} + +#[test] +fn duplicate_linked_pair_keeps_copies_linked() { + // Option/Alt-drag duplicating an A/V linked pair must keep the copies linked + // to each other under a fresh group id (groupCounts/groupRemap semantics). + let mut st = linked_av_state(); + let g = SeqIdGen::default(); + let res = apply( + &mut st, + EditCommand::DuplicateClips { + clip_ids: vec!["v1".into(), "a1".into()], + offset_frames: 200, + target_track_indexes: vec![0, 1], + }, + &g, + ) + .unwrap(); + assert_eq!(res.affected_clip_ids.len(), 2); + + // The copies share a NEW link_group_id (same as each other, different from + // the source "g1") — the A/V link survives the duplicate. + let vc = find_clip(&st, &res.affected_clip_ids[0]); + let ac = find_clip(&st, &res.affected_clip_ids[1]); + assert_eq!(vc.link_group_id, ac.link_group_id); + assert_ne!(vc.link_group_id.as_deref(), Some("g1")); + assert!( + vc.link_group_id.is_some(), + "linked pair copies must stay linked" + ); + + // Originals keep "g1". + assert_eq!(find_clip(&st, "v1").link_group_id.as_deref(), Some("g1")); + assert_eq!(find_clip(&st, "a1").link_group_id.as_deref(), Some("g1")); +} + +#[test] +fn remove_clips_expands_to_linked_partner() { + let mut st = linked_av_state(); + let g = SeqIdGen::default(); + // removing just v1 should also remove its linked a1. + let res = apply( + &mut st, + EditCommand::RemoveClips { + clip_ids: vec!["v1".into()], + }, + &g, + ) + .unwrap(); + assert!(res.changed); assert_eq!(res.action_name, "Remove Clips"); // 2 clips after expansion // both tracks emptied and pruned. assert!(st.timeline.tracks.is_empty()); @@ -718,6 +1328,174 @@ fn set_clip_properties_flip_writes_to_transform() { assert!(c.transform.flip_vertical); } +#[test] +fn set_transform_at_frame_updates_active_tracks_and_static_fields_atomically() { + let mut animated = clip("animated", 100, 60); + animated.transform = Transform { + center_x: 0.25, + center_y: 0.35, + width: 0.8, + height: 0.6, + rotation: 5.0, + flip_horizontal: false, + flip_vertical: true, + }; + animated.position_track = Some(KeyframeTrack::from_keyframes(vec![Keyframe::new( + 0, + AnimPair::new(0.0, 0.05), + )])); + animated.rotation_track = Some(KeyframeTrack::from_keyframes(vec![Keyframe::new(0, 10.0)])); + let before_clip = animated.clone(); + let mut st = state(vec![video_track("v", true, vec![animated])]); + let g = SeqIdGen::new("transform-"); + let target = Transform { + center_x: 0.7, + center_y: 0.6, + width: 0.4, + height: 0.25, + rotation: 33.0, + flip_horizontal: true, + flip_vertical: false, + }; + + let res = apply( + &mut st, + EditCommand::SetTransformAtFrame { + clip_id: "animated".into(), + frame: 130, + transform: target, + }, + &g, + ) + .unwrap(); + + assert!(res.changed); + assert_eq!(res.action_name, "Change Transform"); + assert_eq!(st.undo_depth(), 1); + assert_eq!(st.version(), 1); + let changed = &st.timeline.tracks[0].clips[0]; + let position = changed.position_track.as_ref().unwrap(); + assert_eq!(position.keyframes.len(), 2); + assert_eq!(position.keyframes[1].frame, 30); + assert!((position.keyframes[1].value.a - 0.5).abs() < 1e-9); + assert!((position.keyframes[1].value.b - 0.475).abs() < 1e-9); + let rotation = changed.rotation_track.as_ref().unwrap(); + assert_eq!(rotation.keyframes.len(), 2); + assert_eq!(rotation.keyframes[1].frame, 30); + assert!((rotation.keyframes[1].value - 33.0).abs() < 1e-9); + assert_eq!( + (changed.transform.width, changed.transform.height), + (0.4, 0.25) + ); + assert_eq!( + (changed.transform.center_x, changed.transform.center_y), + (0.25, 0.35) + ); + assert_eq!(changed.transform.rotation, 5.0); + assert!(changed.transform.flip_horizontal); + assert!(!changed.transform.flip_vertical); + + apply(&mut st, EditCommand::Undo, &g).unwrap(); + assert_eq!(st.timeline.tracks[0].clips[0], before_clip); + assert_eq!(st.undo_depth(), 0); +} + +#[test] +fn set_transform_at_frame_writes_a_fully_static_transform() { + let original = clip("static", 20, 40); + let mut st = state(vec![video_track("v", true, vec![original])]); + let g = SeqIdGen::default(); + let target = Transform { + center_x: 0.2, + center_y: 0.8, + width: 0.3, + height: 0.45, + rotation: -20.0, + flip_horizontal: true, + flip_vertical: true, + }; + + apply( + &mut st, + EditCommand::SetTransformAtFrame { + clip_id: "static".into(), + frame: 25, + transform: target, + }, + &g, + ) + .unwrap(); + + assert_eq!(st.timeline.tracks[0].clips[0].transform, target); + assert_eq!(st.undo_depth(), 1); +} + +#[test] +fn set_transform_at_frame_rejects_outside_animation_frame_and_nan_atomically() { + let mut animated = clip("animated", 100, 60); + animated.position_track = Some(KeyframeTrack::from_keyframes(vec![Keyframe::new( + 0, + AnimPair::new(0.0, 0.0), + )])); + let mut st = state(vec![video_track("v", true, vec![animated])]); + let before = st.timeline.clone(); + let g = SeqIdGen::default(); + + let outside = apply( + &mut st, + EditCommand::SetTransformAtFrame { + clip_id: "animated".into(), + frame: 160, + transform: Transform::default(), + }, + &g, + ) + .unwrap_err(); + assert!(matches!(outside, EditError::Invalid(_))); + assert_eq!(st.timeline, before); + assert_eq!(st.undo_depth(), 0); + assert_eq!(st.version(), 0); + + let invalid = Transform { + center_x: f64::NAN, + ..Transform::default() + }; + let nan = apply( + &mut st, + EditCommand::SetTransformAtFrame { + clip_id: "animated".into(), + frame: 130, + transform: invalid, + }, + &g, + ) + .unwrap_err(); + assert!(matches!(nan, EditError::Invalid(_))); + assert_eq!(st.timeline, before); + assert_eq!(st.undo_depth(), 0); + assert_eq!(st.version(), 0); + + let derived_overflow = Transform { + center_x: -f64::MAX, + width: f64::MAX, + ..Transform::default() + }; + let overflow = apply( + &mut st, + EditCommand::SetTransformAtFrame { + clip_id: "animated".into(), + frame: 130, + transform: derived_overflow, + }, + &g, + ) + .unwrap_err(); + assert!(matches!(overflow, EditError::Invalid(_))); + assert_eq!(st.timeline, before); + assert_eq!(st.undo_depth(), 0); + assert_eq!(st.version(), 0); +} + #[test] fn set_clip_properties_multiple_fields_at_once() { let mut st = state(vec![video_track("v", true, vec![clip("c", 0, 60)])]); @@ -753,6 +1531,116 @@ fn set_clip_properties_multiple_fields_at_once() { assert!(c.opacity_track.is_none()); // opacity scalar cleared its track } +#[test] +fn set_transition_validates_pair_rejects_oversize_and_undoes() { + let mut st = state(vec![video_track( + "v", + true, + vec![clip("a", 0, 100), clip("b", 100, 40)], + )]); + let g = SeqIdGen::default(); + + let result = apply( + &mut st, + EditCommand::SetTransition { + from_clip_id: "a".into(), + to_clip_id: "b".into(), + kind: Some(TransitionKind::CrossDissolve), + duration_frames: 20, + }, + &g, + ) + .unwrap(); + + assert!(result.changed); + assert_eq!(result.action_name, "Set Transition"); + let transition = st.timeline.tracks[0].clips[0] + .transition_out + .as_ref() + .expect("transition stored on outgoing clip"); + assert_eq!(transition.to_clip_id, "b"); + assert_eq!(transition.from_clip_id, "a"); + assert_eq!(transition.kind, TransitionKind::CrossDissolve); + assert_eq!(transition.duration_frames, 20); + + apply(&mut st, EditCommand::Undo, &g).unwrap(); + assert!(st.timeline.tracks[0].clips[0].transition_out.is_none()); + + let error = apply( + &mut st, + EditCommand::SetTransition { + from_clip_id: "b".into(), + to_clip_id: "a".into(), + kind: Some(TransitionKind::CrossDissolve), + duration_frames: 10, + }, + &g, + ) + .unwrap_err(); + assert!(matches!(error, EditError::Invalid(_))); + + let oversized = apply( + &mut st, + EditCommand::SetTransition { + from_clip_id: "a".into(), + to_clip_id: "b".into(), + kind: Some(TransitionKind::CrossDissolve), + duration_frames: 21, + }, + &g, + ) + .unwrap_err(); + assert!(matches!(oversized, EditError::Invalid(_))); +} + +#[test] +fn moving_either_side_of_a_transition_prunes_it_and_undo_restores_it() { + let mut st = state(vec![video_track( + "v", + true, + vec![clip("a", 0, 100), clip("b", 100, 40)], + )]); + let g = SeqIdGen::default(); + apply( + &mut st, + EditCommand::SetTransition { + from_clip_id: "a".into(), + to_clip_id: "b".into(), + kind: Some(TransitionKind::CrossDissolve), + duration_frames: 12, + }, + &g, + ) + .unwrap(); + + apply( + &mut st, + EditCommand::MoveClips { + moves: vec![ClipMove { + clip_id: "b".into(), + to_track: 0, + to_frame: 110, + }], + }, + &g, + ) + .unwrap(); + let outgoing = st.timeline.tracks[0] + .clips + .iter() + .find(|clip| clip.id == "a") + .unwrap(); + assert!(outgoing.transition_out.is_none()); + + apply(&mut st, EditCommand::Undo, &g).unwrap(); + let outgoing = st.timeline.tracks[0] + .clips + .iter() + .find(|clip| clip.id == "a") + .unwrap(); + assert_eq!(outgoing.transition_out.as_ref().unwrap().to_clip_id, "b"); +} + // ---- set_keyframes -------------------------------------------------------- #[test] @@ -847,6 +1735,8 @@ fn create_folder_and_move_asset_into_it() { source_height: None, source_fps: None, has_audio: None, + color: None, + proxy: None, folder_id: None, cached_remote_url: None, cached_remote_url_expires_at: None, @@ -1089,6 +1979,11 @@ fn set_color_grade_applies_and_undoes() { let grade = ColorGrade { exposure: 0.5, saturation: 1.2, + hsl_secondary: Some(HslSecondary { + hue_center: 0.65, + hue_shift: 0.15, + ..Default::default() + }), ..Default::default() }; let res = apply( @@ -1108,6 +2003,84 @@ fn set_color_grade_applies_and_undoes() { // Undo restores the cleared grade. apply(&mut st, EditCommand::Undo, &g).unwrap(); assert_eq!(find_clip(&st, "c").color_grade, None); + apply(&mut st, EditCommand::Redo, &g).unwrap(); + assert_eq!(find_clip(&st, "c").color_grade, Some(grade)); +} + +#[test] +fn set_lut_applies_adjusts_removes_and_round_trips_history() { + let mut state = one_clip_state(); + let ids = SeqIdGen::default(); + let reference = + LutReference::new("0123456789abcdef".repeat(4), "Known Transform", 1.0).unwrap(); + apply( + &mut state, + EditCommand::SetLut { + clip_ids: vec!["c".into()], + lut: Some(reference.clone()), + }, + &ids, + ) + .unwrap(); + assert_eq!(find_clip(&state, "c").lut.as_ref(), Some(&reference)); + + let adjusted = LutReference { + intensity: 0.35, + ..reference.clone() + }; + apply( + &mut state, + EditCommand::SetLut { + clip_ids: vec!["c".into()], + lut: Some(adjusted.clone()), + }, + &ids, + ) + .unwrap(); + assert_eq!(find_clip(&state, "c").lut.as_ref(), Some(&adjusted)); + apply(&mut state, EditCommand::Undo, &ids).unwrap(); + assert_eq!(find_clip(&state, "c").lut.as_ref(), Some(&reference)); + apply(&mut state, EditCommand::Redo, &ids).unwrap(); + assert_eq!(find_clip(&state, "c").lut.as_ref(), Some(&adjusted)); + + apply( + &mut state, + EditCommand::SetLut { + clip_ids: vec!["c".into()], + lut: None, + }, + &ids, + ) + .unwrap(); + assert!(find_clip(&state, "c").lut.is_none()); +} + +#[test] +fn set_color_grade_rejects_invalid_without_mutation() { + let mut st = one_clip_state(); + let g = SeqIdGen::default(); + let error = apply( + &mut st, + EditCommand::SetColorGrade { + clip_ids: vec!["c".into()], + grade: Some(ColorGrade { + lift_gamma_gain: LiftGammaGain { + gamma: Rgb::new(0.0, 1.0, 1.0), + ..Default::default() + }, + ..Default::default() + }), + }, + &g, + ) + .expect_err("zero gamma must be rejected before mutation"); + assert_eq!( + error.to_string(), + "invalid color grade: liftGammaGain.gamma.r must be finite and within (0, 4]" + ); + assert_eq!(find_clip(&st, "c").color_grade, None); + assert_eq!(st.version(), 0); + assert!(!st.can_undo()); } #[test] @@ -1227,6 +2200,7 @@ fn set_masks_replaces_list() { }, feather: 0.05, invert: false, + ..Mask::default() }]; let res = apply( &mut st, @@ -1253,6 +2227,29 @@ fn set_masks_replaces_list() { .unwrap(); assert!(res2.changed); assert!(find_clip(&st, "c").masks.is_empty()); + + apply(&mut st, EditCommand::Undo, &g).unwrap(); + assert_eq!(find_clip(&st, "c").masks, masks); + apply(&mut st, EditCommand::Redo, &g).unwrap(); + assert!(find_clip(&st, "c").masks.is_empty()); + + let oversized_polygon = Mask { + shape: MaskShape::Poly { + points: vec![Point2::new(0.5, 0.5); 17], + }, + ..Mask::default() + }; + let err = apply( + &mut st, + EditCommand::SetMasks { + clip_ids: vec!["c".into()], + masks: vec![oversized_polygon], + }, + &g, + ) + .unwrap_err(); + assert!(err.to_string().contains("3..=16 points")); + assert!(find_clip(&st, "c").masks.is_empty()); } #[test] @@ -1260,8 +2257,8 @@ fn set_effects_replaces_chain() { let mut st = one_clip_state(); let g = SeqIdGen::default(); let effects = vec![ - Effect::new("gaussianBlur").with_param("radius", 4.0), - Effect::new("glow").with_param("intensity", 0.6), + Effect::new("grayscale").with_param("amount", 0.4), + Effect::new("sepia").with_param("amount", 0.6), ]; let res = apply( &mut st, @@ -1278,11 +2275,35 @@ fn set_effects_replaces_chain() { } #[test] -fn advanced_effect_commands_reject_empty_and_missing() { - let mut st = one_clip_state(); +fn set_effects_rejects_unknown_names_and_invalid_parameters_without_history() { let g = SeqIdGen::default(); - // Empty clip_ids -> Invalid. - assert!(matches!( + for effect in [ + Effect::new("blur"), + Effect::new("sepia").with_param("radius", 2.0), + Effect::new("invert").with_param("amount", 1.1), + ] { + let mut st = one_clip_state(); + let error = apply( + &mut st, + EditCommand::SetEffects { + clip_ids: vec!["c".into()], + effects: vec![effect], + }, + &g, + ) + .unwrap_err(); + assert!(matches!(error, EditError::Invalid(_))); + assert!(find_clip(&st, "c").effects.is_empty()); + assert_eq!(st.version(), 0); + } +} + +#[test] +fn advanced_effect_commands_reject_empty_and_missing() { + let mut st = one_clip_state(); + let g = SeqIdGen::default(); + // Empty clip_ids -> Invalid. + assert!(matches!( apply( &mut st, EditCommand::SetColorGrade { @@ -1299,7 +2320,7 @@ fn advanced_effect_commands_reject_empty_and_missing() { &mut st, EditCommand::SetEffects { clip_ids: vec!["nope".into()], - effects: vec![Effect::new("blur")] + effects: vec![Effect::new("grayscale")] }, &g ), @@ -1368,6 +2389,8 @@ fn media_entry(id: &str, kind: ClipType, duration_secs: f64) -> MediaManifestEnt source_height: None, source_fps: None, has_audio: None, + color: None, + proxy: None, folder_id: None, cached_remote_url: None, cached_remote_url_expires_at: None, @@ -1702,3 +2725,994 @@ fn swap_media_does_not_cascade_to_link_group_with_different_ref() { assert_eq!(v_clip.media_ref, "new_v"); assert_eq!(a_clip.media_ref, "other"); // untouched } + +// ---- atomic timeline gestures -------------------------------------------- + +fn unplaced_media( + media_ref: &str, + media_type: ClipType, + start_frame: i32, + duration_frames: i32, +) -> UnplacedClipEntry { + UnplacedClipEntry { + media_ref: media_ref.into(), + media_type, + source_clip_type: media_type, + start_frame, + duration_frames, + trim_start_frame: None, + trim_end_frame: None, + has_audio: false, + add_linked_audio: false, + transform: None, + } +} + +fn document_snapshot(state: &EditorState) -> (Timeline, MediaManifest) { + (state.timeline.clone(), state.manifest.clone()) +} + +fn assert_arithmetic_rejection_is_atomic(mut state: EditorState, command: EditCommand) { + let before = document_snapshot(&state); + let before_version = state.version(); + let before_undo_depth = state.undo_depth(); + let before_can_redo = state.can_redo(); + let ids = SeqIdGen::new("rejected-frame-"); + let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + apply(&mut state, command, &ids) + })); + + assert!(outcome.is_ok(), "invalid frame arithmetic must never panic"); + assert!( + outcome.unwrap().is_err(), + "invalid frame arithmetic must return Err" + ); + assert_eq!(document_snapshot(&state), before); + assert_eq!(state.version(), before_version); + assert_eq!(state.undo_depth(), before_undo_depth); + assert_eq!(state.can_redo(), before_can_redo); + assert_eq!(ids.count(), 0, "preflight rejection must not consume ids"); +} + +#[test] +fn atomic_commands_reject_overflow_and_extreme_loaded_frames_without_side_effects() { + let place_state = || { + state_with_media( + vec![video_track("target", true, vec![])], + vec![media_entry("known", ClipType::Video, 1.0)], + ) + }; + assert_arithmetic_rejection_is_atomic( + place_state(), + EditCommand::PlaceMedia { + sequence_id: None, + settings: None, + target: PlaceMediaTarget::NewTrack { + kind: ClipType::Video, + at: Some(0), + }, + entry: unplaced_media("known", ClipType::Video, i32::MAX, 1), + }, + ); + let mut extreme_place = unplaced_media("known", ClipType::Video, 0, 1); + extreme_place.trim_start_frame = Some(i32::MAX); + assert_arithmetic_rejection_is_atomic( + place_state(), + EditCommand::PlaceMedia { + sequence_id: None, + settings: None, + target: PlaceMediaTarget::ExistingTrack { + track_id: "target".into(), + }, + entry: extreme_place, + }, + ); + + let paste_state = || { + state_with_media( + vec![video_track("target", true, vec![])], + vec![media_entry("known", ClipType::Video, 1.0)], + ) + }; + assert_arithmetic_rejection_is_atomic( + paste_state(), + EditCommand::PasteClips { + entries: vec![PasteClipEntry { + clip: Clip::new("clipboard", "known", 0, 1), + target_track_id: "target".into(), + start_frame: i32::MAX, + }], + }, + ); + let mut extreme_paste = Clip::new("clipboard", "known", 0, 1); + extreme_paste.trim_end_frame = i32::MAX; + assert_arithmetic_rejection_is_atomic( + paste_state(), + EditCommand::PasteClips { + entries: vec![PasteClipEntry { + clip: extreme_paste, + target_track_id: "target".into(), + start_frame: 0, + }], + }, + ); + + assert_arithmetic_rejection_is_atomic( + state(vec![video_track( + "target", + true, + vec![Clip::new("source", "asset", 0, 1)], + )]), + EditCommand::MoveClips { + moves: vec![ClipMove { + clip_id: "source".into(), + to_track: 0, + to_frame: i32::MAX, + }], + }, + ); + + for mode in [NewTrackClipMode::Move, NewTrackClipMode::Duplicate] { + assert_arithmetic_rejection_is_atomic( + state(vec![video_track( + "source-track", + true, + vec![Clip::new("source", "asset", i32::MAX - 1, 1)], + )]), + EditCommand::MoveOrDuplicateClipsToNewTrack { + clip_ids: vec!["source".into()], + lead_clip_id: "source".into(), + requested_frame_delta: 1, + insert_at: 0, + mode, + }, + ); + } + assert_arithmetic_rejection_is_atomic( + state(vec![video_track( + "source-track", + true, + vec![Clip::new("source", "asset", i32::MAX - 1, 1)], + )]), + EditCommand::DuplicateClips { + clip_ids: vec!["source".into()], + offset_frames: 1, + target_track_indexes: vec![0], + }, + ); + assert_arithmetic_rejection_is_atomic( + state(vec![video_track( + "source-track", + true, + vec![Clip::new("source", "asset", i32::MIN, 1)], + )]), + EditCommand::MoveOrDuplicateClipsToNewTrack { + clip_ids: vec!["source".into()], + lead_clip_id: "source".into(), + requested_frame_delta: 0, + insert_at: 0, + mode: NewTrackClipMode::Move, + }, + ); +} + +#[test] +fn atomic_preflight_rejects_unrelated_root_and_nested_malformed_clips() { + let valid_command = || EditCommand::DuplicateClips { + clip_ids: vec!["source".into()], + offset_frames: 10, + target_track_indexes: vec![0], + }; + + let mut invalid_root = state(vec![ + video_track( + "source-track", + true, + vec![Clip::new("source", "asset", 0, 10)], + ), + video_track( + "unrelated", + true, + vec![Clip::new("malformed", "asset", i32::MAX, 1)], + ), + ]); + invalid_root.timeline.nested_sequences = vec![]; + assert_arithmetic_rejection_is_atomic(invalid_root, valid_command()); + + let mut nested = Timeline::new(); + nested.tracks = vec![video_track( + "nested-track", + true, + vec![Clip::new("nested-malformed", "asset", i32::MIN, 1)], + )]; + let mut invalid_nested = state(vec![video_track( + "source-track", + true, + vec![Clip::new("source", "asset", 0, 10)], + )]); + invalid_nested + .timeline + .nested_sequences + .push(NestedSequence::new( + "malformed-sequence", + "Malformed", + nested, + )); + assert_arithmetic_rejection_is_atomic(invalid_nested, valid_command()); +} + +#[test] +fn negative_image_and_text_trims_remain_editable_but_per_edge_overflow_is_rejected() { + for media_type in [ClipType::Image, ClipType::Text] { + let mut extended = Clip::new("extended", "asset", 0, 30); + extended.media_type = media_type; + extended.source_clip_type = media_type; + extended.trim_start_frame = -10; + extended.trim_end_frame = -5; + let mut st = state(vec![video_track("visual", true, vec![extended])]); + let ids = SeqIdGen::new("negative-trim-"); + + let result = apply( + &mut st, + EditCommand::DuplicateClips { + clip_ids: vec!["extended".into()], + offset_frames: 40, + target_track_indexes: vec![0], + }, + &ids, + ) + .unwrap(); + + assert_eq!(result.affected_clip_ids.len(), 1); + let copy = find_clip(&st, &result.affected_clip_ids[0]); + assert_eq!((copy.trim_start_frame, copy.trim_end_frame), (-10, -5)); + } + + let mut unsafe_edge = Clip::new("unsafe-edge", "asset", 0, 10); + unsafe_edge.media_type = ClipType::Image; + unsafe_edge.source_clip_type = ClipType::Image; + unsafe_edge.trim_start_frame = -100; + unsafe_edge.trim_end_frame = i32::MAX - 5; + assert_arithmetic_rejection_is_atomic( + state(vec![video_track("visual", true, vec![unsafe_edge])]), + EditCommand::InsertTrack { + kind: ClipType::Video, + at: Some(0), + }, + ); +} + +#[test] +fn ripple_trim_insert_properties_and_settings_extremes_reject_atomically() { + let base = || { + state(vec![video_track( + "visual", + true, + vec![Clip::new("source", "asset", 0, 10)], + )]) + }; + assert_arithmetic_rejection_is_atomic( + base(), + EditCommand::RippleDeleteRanges { + track_index: 0, + ranges: vec![FrameRange::new(i32::MIN, i32::MAX)], + }, + ); + assert_arithmetic_rejection_is_atomic( + base(), + EditCommand::TrimClips { + edits: vec![("source".into(), i32::MAX, 0)], + }, + ); + assert_arithmetic_rejection_is_atomic( + state(vec![video_track("visual", true, vec![])]), + EditCommand::InsertClips { + track_index: 0, + at_frame: i32::MAX - 5, + entries: vec![ + entry(0, ClipType::Video, 0, 3), + entry(0, ClipType::Video, 0, 3), + ], + }, + ); + + let mut video = Clip::new("video", "asset", 0, 10); + video.link_group_id = Some("linked".into()); + let mut audio = Clip::new("audio", "asset", i32::MAX - 10, 10); + audio.media_type = ClipType::Audio; + audio.source_clip_type = ClipType::Video; + audio.link_group_id = Some("linked".into()); + assert_arithmetic_rejection_is_atomic( + state(vec![ + video_track("video-track", true, vec![video]), + audio_track("audio-track", true, vec![audio]), + ]), + EditCommand::SetClipProperties { + clip_ids: vec!["video".into()], + properties: Box::new(ClipProperties { + duration_frames: Some(20), + ..Default::default() + }), + }, + ); + + let projected = Clip::new("projected", "asset", 1_073_741_824, 1); + assert_arithmetic_rejection_is_atomic( + state(vec![video_track("visual", true, vec![projected])]), + EditCommand::SetTimelineSettings { + fps: 60, + width: 1920, + height: 1080, + }, + ); +} + +#[test] +fn compound_and_dissolve_near_i32_boundary_are_checked_and_undoable() { + assert_arithmetic_rejection_is_atomic( + state(vec![video_track("visual", true, vec![])]), + EditCommand::CreateNestedSequence { + name: "Overflow".into(), + timeline: Timeline::new(), + track_index: 0, + start_frame: i32::MAX, + duration_frames: 1, + }, + ); + + let mut child = Timeline::new(); + child.tracks = vec![video_track( + "child-track", + true, + vec![Clip::new("child", "asset", 0, 10)], + )]; + let compound = Clip::new_nested("compound", "sequence", i32::MAX - 10, 10); + let mut root = Timeline::new(); + root.tracks = vec![video_track("root-track", true, vec![compound])]; + root.nested_sequences = vec![NestedSequence::new("sequence", "Boundary", child)]; + let mut st = EditorState::from_timeline(root); + let before = st.timeline.clone(); + let ids = SeqIdGen::new("boundary-dissolve-"); + + let result = apply( + &mut st, + EditCommand::DissolveNestedSequence { + clip_id: "compound".into(), + }, + &ids, + ) + .unwrap(); + let dissolved = find_clip(&st, &result.affected_clip_ids[0]); + assert_eq!(dissolved.start_frame, i32::MAX - 10); + assert_eq!(dissolved.duration_frames, 10); + + apply(&mut st, EditCommand::Undo, &ids).unwrap(); + assert_eq!(st.timeline, before); +} + +#[test] +fn place_media_settings_new_track_and_linked_audio_are_one_undo_step() { + let mut media = media_entry("av", ClipType::Video, 2.0); + media.has_audio = Some(true); + let mut st = state_with_media(vec![], vec![media]); + let before = document_snapshot(&st); + let ids = SeqIdGen::new("place-"); + let mut entry = unplaced_media("av", ClipType::Video, 12, 48); + entry.has_audio = true; + entry.add_linked_audio = true; + + let result = apply( + &mut st, + EditCommand::PlaceMedia { + sequence_id: None, + settings: Some(ProjectTimelineSettings { + fps: 60, + width: 3840, + height: 2160, + }), + target: PlaceMediaTarget::NewTrack { + kind: ClipType::Video, + at: Some(0), + }, + entry, + }, + &ids, + ) + .unwrap(); + + assert_eq!(result.affected_clip_ids.len(), 2); + assert_eq!(st.version(), 1); + assert_eq!(st.undo_depth(), 1); + assert_eq!( + (st.timeline.fps, st.timeline.width, st.timeline.height), + (60, 3840, 2160) + ); + assert!(st.timeline.settings_configured); + assert_eq!( + st.timeline + .tracks + .iter() + .map(|track| track.kind) + .collect::>(), + vec![ClipType::Video, ClipType::Audio] + ); + let video = find_clip(&st, &result.affected_clip_ids[0]); + let audio = find_clip(&st, &result.affected_clip_ids[1]); + assert_eq!(video.media_type, ClipType::Video); + assert_eq!(audio.media_type, ClipType::Audio); + assert_eq!(video.link_group_id, audio.link_group_id); + assert!(video.link_group_id.is_some()); + + let undone = apply(&mut st, EditCommand::Undo, &ids).unwrap(); + assert!(undone.changed); + assert_eq!(document_snapshot(&st), before); +} + +#[test] +fn place_media_targets_a_nested_track_by_stable_id_with_root_settings() { + let mut child = Timeline::new(); + child.tracks = vec![video_track( + "child-track", + true, + vec![Clip::new("existing", "m", 10, 20)], + )]; + let mut root = Timeline::new(); + root.nested_sequences + .push(NestedSequence::new("sequence-a", "Scene", child)); + let mut st = EditorState::new(root, { + let mut manifest = MediaManifest::new(); + manifest + .entries + .push(media_entry("m", ClipType::Video, 4.0)); + manifest + }); + let before = st.timeline.clone(); + let ids = SeqIdGen::new("nested-place-"); + + let result = apply( + &mut st, + EditCommand::PlaceMedia { + sequence_id: Some("sequence-a".into()), + settings: Some(ProjectTimelineSettings { + fps: 60, + width: 1280, + height: 720, + }), + target: PlaceMediaTarget::ExistingTrack { + track_id: "child-track".into(), + }, + entry: unplaced_media("m", ClipType::Video, 100, 30), + }, + &ids, + ) + .unwrap(); + + assert_eq!(result.affected_clip_ids.len(), 1); + assert_eq!(st.version(), 1); + assert_eq!(st.undo_depth(), 1); + assert_eq!( + (st.timeline.fps, st.timeline.width, st.timeline.height), + (60, 1280, 720) + ); + let child = &st.timeline.nested_sequences[0].timeline; + assert_eq!(child.tracks[0].id, "child-track"); + assert!(child.tracks[0] + .clips + .iter() + .any(|clip| clip.id == "existing" && clip.start_frame == 20 && clip.duration_frames == 40)); + assert!(child.tracks[0] + .clips + .iter() + .any(|clip| clip.id == result.affected_clip_ids[0] && clip.start_frame == 100)); + + apply(&mut st, EditCommand::Undo, &ids).unwrap(); + assert_eq!(st.timeline, before); +} + +#[test] +fn place_media_rejects_manifest_audio_mismatch_without_mutation() { + let mut media = media_entry("av", ClipType::Video, 2.0); + media.has_audio = Some(true); + let mut st = state_with_media(vec![], vec![media]); + let before = document_snapshot(&st); + let ids = SeqIdGen::new("rejected-place-"); + + let error = apply( + &mut st, + EditCommand::PlaceMedia { + sequence_id: None, + settings: Some(ProjectTimelineSettings { + fps: 60, + width: 3840, + height: 2160, + }), + target: PlaceMediaTarget::NewTrack { + kind: ClipType::Video, + at: None, + }, + // The manifest says the video has audio. The gesture snapshot says + // it does not, so even its settings must not be committed. + entry: unplaced_media("av", ClipType::Video, 0, 30), + }, + &ids, + ) + .unwrap_err(); + + assert!(error.to_string().contains("hasAudio")); + assert_eq!(document_snapshot(&st), before); + assert_eq!(st.version(), 0); + assert_eq!(st.undo_depth(), 0); +} + +#[test] +fn move_clips_to_new_track_uses_stable_ids_and_pins_linked_audio() { + let mut lead = Clip::new("lead", "av", 10, 20); + lead.link_group_id = Some("old-link".into()); + let second = Clip::new("second", "b", 50, 20); + let mut audio = Clip::new("audio", "av", 10, 20); + audio.media_type = ClipType::Audio; + audio.source_clip_type = ClipType::Video; + audio.link_group_id = Some("old-link".into()); + let mut st = state(vec![ + video_track("lead-track", true, vec![lead, second]), + video_track("other-track", true, vec![Clip::new("other", "x", 0, 5)]), + audio_track("audio-track", true, vec![audio]), + ]); + let before = st.timeline.clone(); + let ids = SeqIdGen::new("new-track-"); + + let result = apply( + &mut st, + EditCommand::MoveOrDuplicateClipsToNewTrack { + clip_ids: vec!["lead".into(), "audio".into(), "second".into()], + lead_clip_id: "lead".into(), + requested_frame_delta: -99, + insert_at: 1, + mode: NewTrackClipMode::Move, + }, + &ids, + ) + .unwrap(); + + assert_eq!(result.affected_clip_ids, vec!["lead", "audio", "second"]); + assert_eq!(st.version(), 1); + assert_eq!(st.undo_depth(), 1); + let lead_location = st.find_clip("lead").unwrap(); + let second_location = st.find_clip("second").unwrap(); + let audio_location = st.find_clip("audio").unwrap(); + assert_eq!( + st.timeline.tracks[lead_location.track_index].id, + st.timeline.tracks[second_location.track_index].id + ); + assert_ne!( + st.timeline.tracks[lead_location.track_index].id, + "lead-track" + ); + assert_eq!( + st.timeline.tracks[audio_location.track_index].id, + "audio-track" + ); + assert_eq!(find_clip(&st, "lead").start_frame, 0); + assert_eq!(find_clip(&st, "audio").start_frame, 0); + assert_eq!(find_clip(&st, "second").start_frame, 40); + assert!(st + .timeline + .tracks + .iter() + .any(|track| track.id == "other-track")); + + apply(&mut st, EditCommand::Undo, &ids).unwrap(); + assert_eq!(st.timeline, before); +} + +#[test] +fn duplicate_clips_to_new_track_keeps_sources_and_remaps_linked_copies() { + let mut lead = Clip::new("lead", "av", 10, 20); + lead.link_group_id = Some("old-link".into()); + let mut audio = Clip::new("audio", "av", 10, 20); + audio.media_type = ClipType::Audio; + audio.source_clip_type = ClipType::Video; + audio.link_group_id = Some("old-link".into()); + let mut st = state(vec![ + video_track("lead-track", true, vec![lead]), + audio_track("audio-track", true, vec![audio]), + ]); + let before = st.timeline.clone(); + let ids = SeqIdGen::new("duplicate-new-track-"); + + let result = apply( + &mut st, + EditCommand::MoveOrDuplicateClipsToNewTrack { + clip_ids: vec!["lead".into(), "audio".into()], + lead_clip_id: "lead".into(), + requested_frame_delta: 40, + insert_at: 0, + mode: NewTrackClipMode::Duplicate, + }, + &ids, + ) + .unwrap(); + + assert_eq!(result.affected_clip_ids.len(), 2); + assert!(st.find_clip("lead").is_some()); + assert!(st.find_clip("audio").is_some()); + let video_copy = find_clip(&st, &result.affected_clip_ids[0]); + let audio_copy = find_clip(&st, &result.affected_clip_ids[1]); + assert_eq!(video_copy.media_type, ClipType::Video); + assert_eq!(audio_copy.media_type, ClipType::Audio); + assert_eq!(video_copy.start_frame, 50); + assert_eq!(audio_copy.start_frame, 50); + assert_eq!(video_copy.link_group_id, audio_copy.link_group_id); + assert!(video_copy.link_group_id.is_some()); + assert_ne!(video_copy.link_group_id.as_deref(), Some("old-link")); + assert_eq!(st.version(), 1); + assert_eq!(st.undo_depth(), 1); + + apply(&mut st, EditCommand::Undo, &ids).unwrap(); + assert_eq!(st.timeline, before); +} + +#[test] +fn duplicate_adjacent_clips_to_new_track_remaps_transition_and_undoes_exactly() { + let mut first = Clip::new("first", "a", 10, 20); + let second = Clip::new("second", "b", 30, 20); + first.transition_out = Some(Transition { + from_clip_id: first.id.clone(), + to_clip_id: second.id.clone(), + kind: TransitionKind::CrossDissolve, + duration_frames: 8, + }); + let mut st = state(vec![video_track( + "source-track", + true, + vec![first.clone(), second.clone()], + )]); + let before = st.timeline.clone(); + let ids = SeqIdGen::new("transition-copy-"); + + let result = apply( + &mut st, + EditCommand::MoveOrDuplicateClipsToNewTrack { + clip_ids: vec!["first".into(), "second".into()], + lead_clip_id: "first".into(), + requested_frame_delta: 40, + insert_at: 0, + mode: NewTrackClipMode::Duplicate, + }, + &ids, + ) + .unwrap(); + + assert_eq!(result.affected_clip_ids.len(), 2); + let first_copy = find_clip(&st, &result.affected_clip_ids[0]); + let second_copy = find_clip(&st, &result.affected_clip_ids[1]); + assert_eq!((first_copy.start_frame, second_copy.start_frame), (50, 70)); + assert_eq!( + first_copy.transition_out, + Some(Transition { + from_clip_id: first_copy.id.clone(), + to_clip_id: second_copy.id.clone(), + kind: TransitionKind::CrossDissolve, + duration_frames: 8, + }) + ); + assert!(second_copy.transition_out.is_none()); + assert_eq!(find_clip(&st, "first"), &first); + assert_eq!(find_clip(&st, "second"), &second); + assert_eq!(st.version(), 1); + assert_eq!(st.undo_depth(), 1); + + apply(&mut st, EditCommand::Undo, &ids).unwrap(); + assert_eq!(st.timeline, before); +} + +#[test] +fn duplicate_linked_av_to_new_track_preserves_sources_at_zero_and_overlapping_delta() { + for frame_delta in [0, 5] { + let mut lead = Clip::new("lead", "av", 10, 20); + lead.link_group_id = Some("old-link".into()); + let original_lead = lead.clone(); + let mut audio = Clip::new("audio", "av", 10, 20); + audio.media_type = ClipType::Audio; + audio.source_clip_type = ClipType::Video; + audio.link_group_id = Some("old-link".into()); + let original_audio = audio.clone(); + let mut st = state(vec![ + video_track("lead-track", true, vec![lead]), + audio_track("audio-track", true, vec![audio]), + ]); + let before = st.timeline.clone(); + let ids = SeqIdGen::new(format!("safe-duplicate-{frame_delta}-")); + + let result = apply( + &mut st, + EditCommand::MoveOrDuplicateClipsToNewTrack { + clip_ids: vec!["lead".into(), "audio".into()], + lead_clip_id: "lead".into(), + requested_frame_delta: frame_delta, + insert_at: 0, + mode: NewTrackClipMode::Duplicate, + }, + &ids, + ) + .unwrap(); + + assert_eq!(find_clip(&st, "lead"), &original_lead); + assert_eq!(find_clip(&st, "audio"), &original_audio); + assert_eq!(result.affected_clip_ids.len(), 2); + let video_copy = find_clip(&st, &result.affected_clip_ids[0]); + let audio_copy = find_clip(&st, &result.affected_clip_ids[1]); + assert_eq!(video_copy.start_frame, 10 + frame_delta); + assert_eq!(audio_copy.start_frame, 10 + frame_delta); + assert_eq!(video_copy.link_group_id, audio_copy.link_group_id); + assert_ne!(video_copy.link_group_id.as_deref(), Some("old-link")); + let audio_copy_location = st.find_clip(&audio_copy.id).unwrap(); + assert_ne!( + st.timeline.tracks[audio_copy_location.track_index].id, + "audio-track" + ); + assert_eq!(st.version(), 1); + assert_eq!(st.undo_depth(), 1); + + apply(&mut st, EditCommand::Undo, &ids).unwrap(); + assert_eq!(st.timeline, before); + } +} + +#[test] +fn new_track_gesture_rejects_an_invalid_lead_before_inserting() { + let mut st = state(vec![video_track( + "lead-track", + true, + vec![Clip::new("lead", "av", 10, 20)], + )]); + let before = document_snapshot(&st); + let ids = SeqIdGen::new("invalid-new-track-"); + + let error = apply( + &mut st, + EditCommand::MoveOrDuplicateClipsToNewTrack { + clip_ids: vec!["lead".into()], + lead_clip_id: "missing".into(), + requested_frame_delta: 0, + insert_at: 0, + mode: NewTrackClipMode::Move, + }, + &ids, + ) + .unwrap_err(); + + assert!(error.to_string().contains("leadClipId")); + assert_eq!(document_snapshot(&st), before); + assert_eq!(st.undo_depth(), 0); +} + +#[test] +fn paste_clips_deep_copies_all_fields_and_remaps_only_internal_references() { + let mut video_media = media_entry("video-a", ClipType::Video, 10.0); + video_media.has_audio = Some(true); + let second_media = media_entry("video-b", ClipType::Video, 10.0); + + let mut nested_timeline = Timeline::new(); + nested_timeline.tracks = vec![video_track( + "nested-track", + true, + vec![Clip::new("nested-leaf", "video-b", 0, 10)], + )]; + let mut timeline = Timeline::new(); + timeline.nested_sequences.push(NestedSequence::new( + "nested-sequence", + "Nested", + nested_timeline, + )); + timeline.tracks = vec![ + video_track("video-target", true, vec![]), + audio_track("audio-target", true, vec![]), + ]; + let mut manifest = MediaManifest::new(); + manifest.entries = vec![video_media, second_media]; + let mut st = EditorState::new(timeline, manifest); + let before = st.timeline.clone(); + let ids = SeqIdGen::new("paste-"); + + let mut first = Clip::new("old-first", "video-a", 0, 30); + first.trim_start_frame = 3; + first.trim_end_frame = 7; + first.speed = 1.25; + first.volume = 0.75; + first.fade_in_frames = 4; + first.fade_out_frames = 5; + first.fade_in_interpolation = Interpolation::Smooth; + first.opacity = 0.8; + first.transform = Transform { + center_x: 0.3, + center_y: 0.4, + width: 0.5, + height: 0.6, + rotation: 12.0, + flip_horizontal: true, + flip_vertical: false, + }; + first.crop = Crop { + left: 0.1, + top: 0.2, + right: 0.05, + bottom: 0.15, + }; + first.opacity_track = Some(KeyframeTrack::from_keyframes(vec![Keyframe::new(0, 0.4)])); + first.position_track = Some(KeyframeTrack::from_keyframes(vec![Keyframe::new( + 0, + AnimPair::new(0.2, 0.8), + )])); + first.color_grade = Some(ColorGrade::default()); + first.lut = Some(LutReference::new("0123456789abcdef".repeat(4), "Paste LUT", 0.6).unwrap()); + first.chroma_key = Some(ChromaKey::default()); + first.masks = vec![Mask { + shape: MaskShape::Circle { + center: Point2::new(0.5, 0.5), + radius: Point2::new(0.25, 0.25), + }, + feather: 0.1, + invert: true, + ..Mask::default() + }]; + first.effects = vec![Effect::new("grayscale").with_param("amount", 0.4)]; + first.reversed = true; + first.link_group_id = Some("old-link".into()); + first.caption_group_id = Some("old-caption".into()); + + let mut second = Clip::new("old-second", "video-b", 30, 30); + second.caption_group_id = Some("old-caption".into()); + first.transition_out = Some(Transition { + from_clip_id: first.id.clone(), + to_clip_id: second.id.clone(), + kind: TransitionKind::CrossDissolve, + duration_frames: 8, + }); + second.transition_out = Some(Transition { + from_clip_id: second.id.clone(), + to_clip_id: "outside-selection".into(), + kind: TransitionKind::CrossDissolve, + duration_frames: 8, + }); + + let mut linked_audio = Clip::new("old-audio", "video-a", 0, 30); + linked_audio.media_type = ClipType::Audio; + linked_audio.source_clip_type = ClipType::Video; + linked_audio.link_group_id = Some("old-link".into()); + + let mut text = Clip::new("old-text", "", 60, 20); + text.media_type = ClipType::Text; + text.source_clip_type = ClipType::Text; + text.text_content = Some("Copied title".into()); + text.text_style = Some(opentake_domain::TextStyle::default()); + text.caption_group_id = Some("old-caption".into()); + + let compound = Clip::new_nested("old-compound", "nested-sequence", 90, 20); + let entries = vec![ + PasteClipEntry { + clip: second.clone(), + target_track_id: "video-target".into(), + start_frame: 230, + }, + PasteClipEntry { + clip: linked_audio.clone(), + target_track_id: "audio-target".into(), + start_frame: 200, + }, + PasteClipEntry { + clip: first.clone(), + target_track_id: "video-target".into(), + start_frame: 200, + }, + PasteClipEntry { + clip: text.clone(), + target_track_id: "video-target".into(), + start_frame: 300, + }, + PasteClipEntry { + clip: compound.clone(), + target_track_id: "video-target".into(), + start_frame: 330, + }, + ]; + + let result = apply(&mut st, EditCommand::PasteClips { entries }, &ids).unwrap(); + assert_eq!(result.affected_clip_ids.len(), 5); + assert_eq!(st.version(), 1); + assert_eq!(st.undo_depth(), 1); + + let new_second = find_clip(&st, &result.affected_clip_ids[0]).clone(); + let new_audio = find_clip(&st, &result.affected_clip_ids[1]).clone(); + let new_first = find_clip(&st, &result.affected_clip_ids[2]).clone(); + let new_text = find_clip(&st, &result.affected_clip_ids[3]).clone(); + let new_compound = find_clip(&st, &result.affected_clip_ids[4]).clone(); + + let mut expected_first = first; + expected_first.id = result.affected_clip_ids[2].clone(); + expected_first.start_frame = 200; + expected_first.link_group_id = new_first.link_group_id.clone(); + expected_first.caption_group_id = new_first.caption_group_id.clone(); + expected_first.transition_out = Some(Transition { + from_clip_id: result.affected_clip_ids[2].clone(), + to_clip_id: result.affected_clip_ids[0].clone(), + kind: TransitionKind::CrossDissolve, + duration_frames: 8, + }); + assert_eq!(new_first, expected_first); + + let mut expected_second = second; + expected_second.id = result.affected_clip_ids[0].clone(); + expected_second.start_frame = 230; + expected_second.caption_group_id = new_second.caption_group_id.clone(); + expected_second.transition_out = None; + assert_eq!(new_second, expected_second); + + let mut expected_audio = linked_audio; + expected_audio.id = result.affected_clip_ids[1].clone(); + expected_audio.start_frame = 200; + expected_audio.link_group_id = new_audio.link_group_id.clone(); + assert_eq!(new_audio, expected_audio); + + let mut expected_text = text; + expected_text.id = result.affected_clip_ids[3].clone(); + expected_text.start_frame = 300; + expected_text.caption_group_id = new_text.caption_group_id.clone(); + assert_eq!(new_text, expected_text); + + let mut expected_compound = compound; + expected_compound.id = result.affected_clip_ids[4].clone(); + expected_compound.start_frame = 330; + assert_eq!(new_compound, expected_compound); + + assert_eq!(new_first.link_group_id, new_audio.link_group_id); + assert_ne!(new_first.link_group_id.as_deref(), Some("old-link")); + assert_eq!(new_first.caption_group_id, new_second.caption_group_id); + assert_eq!(new_first.caption_group_id, new_text.caption_group_id); + assert_ne!(new_first.caption_group_id.as_deref(), Some("old-caption")); + + apply(&mut st, EditCommand::Undo, &ids).unwrap(); + assert_eq!(st.timeline, before); +} + +#[test] +fn paste_clips_rejects_invalid_media_without_clearing_destinations() { + let blocker = Clip::new("blocker", "known", 0, 30); + let mut st = state_with_media( + vec![video_track("video-target", true, vec![blocker])], + vec![media_entry("known", ClipType::Video, 1.0)], + ); + let before = document_snapshot(&st); + let ids = SeqIdGen::new("invalid-paste-"); + let missing = Clip::new("clipboard", "missing", 0, 30); + + let error = apply( + &mut st, + EditCommand::PasteClips { + entries: vec![PasteClipEntry { + clip: missing, + target_track_id: "video-target".into(), + start_frame: 0, + }], + }, + &ids, + ) + .unwrap_err(); + + assert!(error.to_string().contains("Media not found")); + assert_eq!(document_snapshot(&st), before); + assert_eq!(st.version(), 0); + assert_eq!(st.undo_depth(), 0); +} + +/// Composite acceptance entry tracked by the data-safety implementation plan. +/// It rolls up command validation, linked edits, collision refusal, no-op +/// semantics, and undo/redo through the public `apply` boundary. +#[test] +fn cross_cutting_command_acceptance() { + add_clips_rejects_incompatible_type(); + split_linked_pair_splits_partner_and_regroups(); + ripple_delete_ranges_refuses_when_sync_follower_collides(); + undo_redo_restores_and_versions(); + unchanged_command_does_not_push_undo_or_bump_version(); +} diff --git a/crates/opentake-process-tree/Cargo.toml b/crates/opentake-process-tree/Cargo.toml new file mode 100644 index 00000000..ae13867c --- /dev/null +++ b/crates/opentake-process-tree/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "opentake-process-tree" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +description = "Cross-platform fail-closed child process tree containment for OpenTake." + +[target.'cfg(unix)'.dependencies] +libc = "0.2" + +[target.'cfg(windows)'.dependencies] +windows-sys = { version = "0.61", features = [ + "Win32_Foundation", + "Win32_Security", + "Win32_System_Diagnostics_ToolHelp", + "Win32_System_JobObjects", + "Win32_System_Threading", +] } diff --git a/crates/opentake-process-tree/src/lib.rs b/crates/opentake-process-tree/src/lib.rs new file mode 100644 index 00000000..34f13721 --- /dev/null +++ b/crates/opentake-process-tree/src/lib.rs @@ -0,0 +1,573 @@ +//! Cross-platform containment for untrusted helper processes. + +use std::io; +use std::process::Command; +use std::time::Duration; +#[cfg(any(test, windows))] +use std::time::Instant; + +#[cfg(any(test, windows))] +fn wait_for_processes_to_exit( + timeout: Duration, + mut active_processes: impl FnMut() -> io::Result, + mut pause: impl FnMut(Duration), +) -> io::Result<()> { + const POLL_INTERVAL: Duration = Duration::from_millis(1); + let deadline = Instant::now() + .checked_add(timeout) + .unwrap_or_else(Instant::now); + loop { + if active_processes()? == 0 { + return Ok(()); + } + let now = Instant::now(); + if now >= deadline { + return Err(io::Error::new( + io::ErrorKind::TimedOut, + "process tree remained active after termination", + )); + } + pause(POLL_INTERVAL.min(deadline.saturating_duration_since(now))); + } +} + +/// Configure a command before spawn so descendants can be terminated as a +/// unit. Call [`ProcessTree::attach`] immediately after a successful spawn. +/// +/// Windows children start with their primary thread suspended. `attach` first +/// places that inert process in a kill-on-close Job Object and only then resumes +/// it, closing the spawn-to-assignment escape window. +pub fn configure_command(command: &mut Command) { + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + command.process_group(0); + } + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + use windows_sys::Win32::System::Threading::{CREATE_NEW_PROCESS_GROUP, CREATE_SUSPENDED}; + command.creation_flags(CREATE_NEW_PROCESS_GROUP | CREATE_SUSPENDED); + } +} + +#[cfg(windows)] +mod windows_containment { + use super::*; + use std::mem::size_of; + use std::ptr; + use windows_sys::Win32::Foundation::{CloseHandle, HANDLE, INVALID_HANDLE_VALUE}; + use windows_sys::Win32::System::Diagnostics::ToolHelp::{ + CreateToolhelp32Snapshot, Thread32First, Thread32Next, TH32CS_SNAPTHREAD, THREADENTRY32, + }; + use windows_sys::Win32::System::JobObjects::{ + AssignProcessToJobObject, CreateJobObjectW, JobObjectBasicAccountingInformation, + JobObjectExtendedLimitInformation, QueryInformationJobObject, SetInformationJobObject, + TerminateJobObject, JOBOBJECT_BASIC_ACCOUNTING_INFORMATION, + JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, + }; + use windows_sys::Win32::System::Threading::{ + OpenProcess, OpenThread, ResumeThread, PROCESS_SET_QUOTA, PROCESS_TERMINATE, + THREAD_SUSPEND_RESUME, + }; + + struct OwnedHandle(HANDLE); + + impl OwnedHandle { + fn from_nullable(handle: HANDLE) -> io::Result { + if handle.is_null() { + Err(io::Error::last_os_error()) + } else { + Ok(Self(handle)) + } + } + + fn from_snapshot(handle: HANDLE) -> io::Result { + if handle == INVALID_HANDLE_VALUE { + Err(io::Error::last_os_error()) + } else { + Ok(Self(handle)) + } + } + + fn raw(&self) -> HANDLE { + self.0 + } + + fn into_raw(mut self) -> HANDLE { + let handle = self.0; + self.0 = ptr::null_mut(); + handle + } + } + + impl Drop for OwnedHandle { + fn drop(&mut self) { + if !self.0.is_null() && self.0 != INVALID_HANDLE_VALUE { + // SAFETY: this value owns the handle and closes it exactly once. + unsafe { + let _ = CloseHandle(self.0); + } + } + } + } + + fn open_primary_thread(process_id: u32) -> io::Result { + // The target was created suspended, so it can only have its original + // primary thread while this snapshot is inspected. + // SAFETY: this call takes no borrowed pointers; the documented thread + // snapshot flag ignores the process-id argument. `OwnedHandle` checks + // the sentinel and assumes sole ownership of a successful result. + let snapshot = + OwnedHandle::from_snapshot(unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0) })?; + let mut entry = THREADENTRY32 { + dwSize: size_of::() as u32, + ..THREADENTRY32::default() + }; + // SAFETY: `entry` has the required size and remains valid throughout + // the enumeration; `snapshot` is a live ToolHelp snapshot. + let mut has_entry = unsafe { Thread32First(snapshot.raw(), &mut entry) } != 0; + while has_entry { + if entry.th32OwnerProcessID == process_id { + // SAFETY: the thread id comes from the live snapshot and only + // suspend/resume access is requested. + return OwnedHandle::from_nullable(unsafe { + OpenThread(THREAD_SUSPEND_RESUME, 0, entry.th32ThreadID) + }); + } + // SAFETY: same snapshot and initialized output structure as above. + has_entry = unsafe { Thread32Next(snapshot.raw(), &mut entry) } != 0; + } + Err(io::Error::new( + io::ErrorKind::NotFound, + "suspended child primary thread was not found", + )) + } + + pub(super) fn attach(process_id: u32) -> io::Result { + // SAFETY: null pointers request an unnamed job with default security. + let job = + OwnedHandle::from_nullable(unsafe { CreateJobObjectW(ptr::null(), ptr::null()) })?; + let mut limits = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default(); + limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + // SAFETY: structure and information class are paired correctly. + if unsafe { + SetInformationJobObject( + job.raw(), + JobObjectExtendedLimitInformation, + (&limits as *const JOBOBJECT_EXTENDED_LIMIT_INFORMATION).cast(), + size_of::() as u32, + ) + } == 0 + { + return Err(io::Error::last_os_error()); + } + + // The process has not executed any user code, so even an OpenProcess or + // assignment failure leaves no descendant to escape. The caller kills + // and reaps the still-suspended immediate child on every attach error. + // SAFETY: `process_id` identifies that live suspended child; the access + // mask is sufficient for job assignment/termination, and a successful + // handle is transferred into the single-owner `OwnedHandle` wrapper. + let process = OwnedHandle::from_nullable(unsafe { + OpenProcess(PROCESS_SET_QUOTA | PROCESS_TERMINATE, 0, process_id) + })?; + // SAFETY: both handles are valid and have the documented rights. + if unsafe { AssignProcessToJobObject(job.raw(), process.raw()) } == 0 { + return Err(io::Error::last_os_error()); + } + + let primary_thread = open_primary_thread(process_id)?; + // SAFETY: this is the primary thread created by CREATE_SUSPENDED. It is + // resumed only after successful Job Object assignment. + let previous_suspend_count = unsafe { ResumeThread(primary_thread.raw()) }; + if previous_suspend_count != 1 { + return Err(if previous_suspend_count == u32::MAX { + io::Error::last_os_error() + } else { + io::Error::other(format!( + "unexpected suspended child thread count: {previous_suspend_count}" + )) + }); + } + + Ok(job.into_raw()) + } + + pub(super) fn terminate(job: HANDLE) -> io::Result<()> { + // SAFETY: the caller owns a live Job Object handle. + if unsafe { TerminateJobObject(job, 1) } == 0 { + Err(io::Error::last_os_error()) + } else { + Ok(()) + } + } + + pub(super) fn active_processes(job: HANDLE) -> io::Result { + let mut accounting = JOBOBJECT_BASIC_ACCOUNTING_INFORMATION::default(); + // SAFETY: `job` is a live Job Object handle owned by the caller, and + // the output buffer exactly matches the requested information class. + if unsafe { + QueryInformationJobObject( + job, + JobObjectBasicAccountingInformation, + (&mut accounting as *mut JOBOBJECT_BASIC_ACCOUNTING_INFORMATION).cast(), + size_of::() as u32, + ptr::null_mut(), + ) + } == 0 + { + Err(io::Error::last_os_error()) + } else { + Ok(accounting.ActiveProcesses) + } + } +} + +/// Owns the operating-system containment object for one spawned process tree. +/// Dropping an armed value is fail-closed and terminates the tree. +pub struct ProcessTree { + #[cfg(unix)] + process_group: i32, + #[cfg(windows)] + job: windows_sys::Win32::Foundation::HANDLE, + armed: bool, +} + +// SAFETY: on Windows the owned Job Object HANDLE is an opaque kernel value with +// exclusive ownership here — it is moved, never shared, and closed exactly once +// (Drop/disarm). Moving the handle between threads is therefore sound; it is +// only ever used through this value's own methods. +unsafe impl Send for ProcessTree {} + +impl ProcessTree { + /// Attach to a child spawned from a command prepared by + /// [`configure_command`]. + /// + /// Call this immediately after spawn and before doing any other work with + /// the child. On Windows the configured child remains suspended until this + /// method assigns it to the Job Object; on Unix the configuration creates + /// the isolated process group stored here. + pub fn attach(process_id: u32) -> io::Result { + // On Unix, negating these reserved identifiers would change the + // target from one isolated child process group to the caller's group + // (`kill(0, ...)`) or every permitted process (`kill(-1, ...)`). Keep + // the public cross-platform API fail-closed even if it is called with + // an identifier that did not come from `Child::id()`. + if process_id <= 1 { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "process id must identify an isolated child process", + )); + } + #[cfg(unix)] + { + let process_group = i32::try_from(process_id) + .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "invalid child pid"))?; + Ok(Self { + process_group, + armed: true, + }) + } + #[cfg(windows)] + { + let job = windows_containment::attach(process_id)?; + Ok(Self { job, armed: true }) + } + #[cfg(not(any(unix, windows)))] + { + let _ = process_id; + Ok(Self { armed: true }) + } + } + + pub fn terminate(&self) -> io::Result<()> { + if !self.armed { + return Ok(()); + } + #[cfg(unix)] + { + // A negative pid targets the entire child process group. + // SAFETY: `attach` rejects process groups 0 and 1, and the public + // launch contract requires this value to come from a child whose + // command was prepared by `configure_command`, which makes its PID + // the isolated process-group id. No pointers cross the FFI call. + let result = unsafe { libc::kill(-self.process_group, libc::SIGKILL) }; + if result != 0 { + let error = io::Error::last_os_error(); + if error.raw_os_error() != Some(libc::ESRCH) { + return Err(error); + } + } + } + #[cfg(windows)] + { + windows_containment::terminate(self.job)?; + } + Ok(()) + } + + /// Terminate the contained process tree and wait until the operating + /// system reports that no process remains active. + /// + /// On Windows, callers must first release any process handles they own: + /// Windows keeps `ActiveProcesses` nonzero until terminated process + /// references are released. The wait is bounded and returns a timeout + /// error instead of silently releasing an incompletely drained Job Object. + pub fn terminate_and_wait(&self, timeout: Duration) -> io::Result<()> { + self.terminate()?; + self.wait_for_exit(timeout) + } + + /// Wait for a previously requested termination to finish. + pub fn wait_for_exit(&self, timeout: Duration) -> io::Result<()> { + if !self.armed { + return Ok(()); + } + #[cfg(windows)] + { + wait_for_processes_to_exit( + timeout, + || windows_containment::active_processes(self.job), + std::thread::sleep, + )?; + } + #[cfg(not(windows))] + { + let _ = timeout; + } + Ok(()) + } + + /// Mark normal completion and release containment without termination. + pub fn disarm(&mut self) { + self.armed = false; + #[cfg(windows)] + if !self.job.is_null() { + // SAFETY: this value owns and closes the job handle exactly once. + unsafe { + let _ = windows_sys::Win32::Foundation::CloseHandle(self.job); + } + self.job = std::ptr::null_mut(); + } + } +} + +impl Drop for ProcessTree { + fn drop(&mut self) { + if self.armed { + let _ = self.terminate(); + } + #[cfg(windows)] + if !self.job.is_null() { + // SAFETY: this value exclusively owns the non-null Job Object + // handle and closes it exactly once here; disarm nulls it after an + // earlier close. KILL_ON_JOB_CLOSE is a second fail-closed boundary. + unsafe { + let _ = windows_sys::Win32::Foundation::CloseHandle(self.job); + } + self.job = std::ptr::null_mut(); + } + } +} + +#[cfg(test)] +mod contract_tests { + use super::*; + use std::collections::VecDeque; + use std::time::Duration; + + #[test] + fn attach_rejects_process_ids_with_group_or_broadcast_semantics() { + for process_id in [0, 1] { + match ProcessTree::attach(process_id) { + Err(error) => assert_eq!(error.kind(), io::ErrorKind::InvalidInput), + Ok(tree) => { + // Leak an invalid regression value so its Drop cannot + // exercise kill(0) or kill(-1) in the test runner. + std::mem::forget(tree); + panic!("reserved process id must be rejected before containment is armed"); + } + } + } + } + + #[test] + fn active_process_fence_waits_for_zero_without_returning_early() { + let mut counts = VecDeque::from([3, 1, 0]); + let mut pauses = 0; + wait_for_processes_to_exit( + Duration::from_secs(1), + || Ok(counts.pop_front().expect("bounded accounting query")), + |_| pauses += 1, + ) + .expect("active process count reaches zero"); + assert_eq!(pauses, 2); + assert!(counts.is_empty()); + } + + #[test] + fn active_process_fence_is_fail_closed_on_query_error_and_timeout() { + let query_error = wait_for_processes_to_exit( + Duration::from_secs(1), + || Err(io::Error::other("accounting unavailable")), + |_| {}, + ) + .expect_err("accounting errors must not be treated as process exit"); + assert_eq!(query_error.kind(), io::ErrorKind::Other); + + let timeout = wait_for_processes_to_exit(Duration::ZERO, || Ok(1), |_| {}) + .expect_err("an active process at the deadline must fail closed"); + assert_eq!(timeout.kind(), io::ErrorKind::TimedOut); + } +} + +#[cfg(all(test, windows))] +mod tests { + use super::*; + use std::fs; + use std::process::Stdio; + use std::thread; + use std::time::{Duration, Instant}; + use windows_sys::Win32::Foundation::{CloseHandle, HANDLE, STILL_ACTIVE}; + use windows_sys::Win32::System::Threading::{ + GetExitCodeProcess, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION, + }; + + const TEST_NAME: &str = "tests::windows_suspended_job_contains_fast_exit_descendant"; + const MODE_ENV: &str = "OPENTAKE_PROCESS_TREE_TEST_MODE"; + const DIR_ENV: &str = "OPENTAKE_PROCESS_TREE_TEST_DIR"; + + struct TestProcessHandle(HANDLE); + + impl Drop for TestProcessHandle { + fn drop(&mut self) { + if !self.0.is_null() { + // SAFETY: the test owns this process handle. + unsafe { + let _ = CloseHandle(self.0); + } + } + } + } + + fn wait_for_file(path: &std::path::Path, deadline: Instant) { + while !path.is_file() { + assert!(Instant::now() < deadline, "timed out waiting for {path:?}"); + thread::sleep(Duration::from_millis(10)); + } + } + + fn helper_mode() { + let Some(mode) = std::env::var_os(MODE_ENV) else { + return; + }; + let directory = std::path::PathBuf::from( + std::env::var_os(DIR_ENV).expect("helper test directory must be supplied"), + ); + if mode == "fast-parent" { + let child = Command::new(std::env::current_exe().expect("current test executable")) + .args(["--exact", TEST_NAME, "--nocapture", "--test-threads=1"]) + .env(MODE_ENV, "grandchild") + .env(DIR_ENV, &directory) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .expect("spawn grandchild helper"); + fs::write(directory.join("grandchild.pid"), child.id().to_string()) + .expect("publish grandchild pid"); + // Intentionally exit without waiting. The grandchild must remain in + // the parent's already-assigned Job Object despite this fast exit. + std::process::exit(0); + } + if mode == "grandchild" { + fs::write(directory.join("grandchild.ready"), b"ready") + .expect("publish grandchild readiness"); + thread::sleep(Duration::from_secs(60)); + std::process::exit(0); + } + panic!("unknown process-tree helper mode: {mode:?}"); + } + + #[test] + fn windows_suspended_job_contains_fast_exit_descendant() { + helper_mode(); + + let unique = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system time after epoch") + .as_nanos(); + let directory = std::env::temp_dir().join(format!( + "opentake-process-tree-{}-{unique}", + std::process::id() + )); + fs::create_dir(&directory).expect("create process-tree test directory"); + let mut command = Command::new(std::env::current_exe().expect("current test executable")); + command + .args(["--exact", TEST_NAME, "--nocapture", "--test-threads=1"]) + .env(MODE_ENV, "fast-parent") + .env(DIR_ENV, &directory) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + configure_command(&mut command); + let mut parent = command.spawn().expect("spawn suspended parent helper"); + let mut tree = ProcessTree::attach(parent.id()).expect("attach and resume parent helper"); + + let deadline = Instant::now() + Duration::from_secs(10); + let pid_path = directory.join("grandchild.pid"); + let ready_path = directory.join("grandchild.ready"); + wait_for_file(&pid_path, deadline); + wait_for_file(&ready_path, deadline); + let grandchild_pid = fs::read_to_string(pid_path) + .expect("read grandchild pid") + .parse::() + .expect("parse grandchild pid"); + + let parent_status = loop { + match parent.try_wait().expect("poll fast parent") { + Some(status) => break status, + None => { + assert!(Instant::now() < deadline, "fast parent did not exit"); + thread::sleep(Duration::from_millis(10)); + } + } + }; + assert!(parent_status.success()); + drop(parent); + + // SAFETY: `grandchild_pid` was read from the just-spawned helper. The + // requested access is query-only, and `TestProcessHandle` exclusively + // owns and closes a successful handle exactly once. + let grandchild = TestProcessHandle(unsafe { + OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, grandchild_pid) + }); + assert!( + !grandchild.0.is_null(), + "grandchild exited before containment check" + ); + + let mut exit_code = 0_u32; + // SAFETY: the held process handle is live and query-only. + assert_ne!( + unsafe { GetExitCodeProcess(grandchild.0, &mut exit_code) }, + 0 + ); + assert_eq!(exit_code, STILL_ACTIVE as u32); + // ActiveProcesses is not permitted to reach zero while this external + // process handle remains open. Release it before exercising the + // completion fence used by real browser shutdown. + drop(grandchild); + + tree.terminate_and_wait(Duration::from_secs(5)) + .expect("terminate and fully drain contained job"); + assert_eq!( + windows_containment::active_processes(tree.job).expect("query drained Job Object"), + 0, + "termination completion must be observable when the API returns" + ); + tree.disarm(); + fs::remove_dir_all(&directory).expect("remove process-tree test directory"); + } +} diff --git a/crates/opentake-project/src/archive.rs b/crates/opentake-project/src/archive.rs index 841e6a94..1086d70f 100644 --- a/crates/opentake-project/src/archive.rs +++ b/crates/opentake-project/src/archive.rs @@ -404,6 +404,8 @@ mod tests { source_height: None, source_fps: None, has_audio: None, + color: None, + proxy: None, folder_id: None, cached_remote_url: None, cached_remote_url_expires_at: None, diff --git a/crates/opentake-project/src/bundle.rs b/crates/opentake-project/src/bundle.rs index 6278abf7..ecfb4362 100644 --- a/crates/opentake-project/src/bundle.rs +++ b/crates/opentake-project/src/bundle.rs @@ -33,7 +33,7 @@ use crate::compatibility; use crate::error::{ProjectError, Result}; use crate::gen_log::{GenerationLog, GenerationLogEntry}; use crate::layout; -use crate::ProjectRoot; +use crate::{is_safe_project_asset_relative_path, ProjectRoot}; /// Persisted schema details this build cannot safely write back. #[derive(Clone, Debug, Default, PartialEq, Eq)] @@ -41,6 +41,34 @@ pub struct ProjectCompatibility { blockers: Vec, } +fn validate_manifest_paths(manifest: &MediaManifest) -> Result<()> { + for entry in &manifest.entries { + if let opentake_domain::MediaSource::Project { relative_path } = &entry.source { + if !is_safe_project_asset_relative_path(relative_path) { + return Err(ProjectError::InvalidMediaManifest { + file: layout::MANIFEST_FILE, + reason: format!( + "project source for asset '{}' is not a safe bundle-relative path", + entry.id + ), + }); + } + } + if let Some(proxy) = &entry.proxy { + if !is_safe_project_asset_relative_path(&proxy.relative_path) { + return Err(ProjectError::InvalidMediaManifest { + file: layout::MANIFEST_FILE, + reason: format!( + "proxy for asset '{}' is not a safe bundle-relative path", + entry.id + ), + }); + } + } + } + Ok(()) +} + impl ProjectCompatibility { /// Whether saving would discard data this build does not understand. pub fn is_read_only(&self) -> bool { @@ -174,6 +202,11 @@ impl Project { } /// Decode every project component from one retained root capability. + /// + /// Persisted compatibility is applied by each component's deserializer: + /// missing optional fields receive their legacy defaults, while explicit + /// schema versions are preserved. Saving the returned project therefore + /// writes the decoded state without silently promoting legacy versions. pub fn open_from_root(root: &ProjectRoot) -> Result { Self::open_from_root_with_hook(root, |_| {}) } @@ -192,6 +225,12 @@ impl Project { let (mut timeline, timeline_blockers, timeline_document) = decode_component::(&timeline_bytes, layout::TIMELINE_FILE)?; compatibility::repair_timeline_ids(&mut timeline, &timeline_document); + timeline + .validate_nested_sequences() + .map_err(|reason| ProjectError::InvalidTimeline { + file: layout::TIMELINE_FILE, + reason, + })?; after_component(layout::TIMELINE_FILE); let mut compatibility = ProjectCompatibility::default(); compatibility.extend(timeline_blockers); @@ -201,6 +240,7 @@ impl Project { let (manifest, blockers, _) = decode_component::(&bytes, layout::MANIFEST_FILE)?; compatibility.extend(blockers); + validate_manifest_paths(&manifest)?; manifest } else { MediaManifest::new() @@ -312,7 +352,65 @@ impl Project { if let Some(source) = media_source { source.copy_media_to(publisher.stage())?; source.copy_chat_sessions_to(publisher.stage())?; + if self.thumbnail.is_none() { + source.copy_thumbnail_to(publisher.stage())?; + } + } + publisher.publish() + } + + /// Replace the same bundle represented by an owned retained root. + /// + /// Windows refuses to rename a directory while a process still owns an + /// open directory handle to it. Complete same-target transactions therefore + /// stage and copy through the retained authority first, explicitly close + /// that authority, and only then enter the existing journaled publication + /// commit. Save-As keeps using [`Self::publish_complete_to`] because its + /// source and destination are distinct. + pub fn publish_complete_replacing_root( + &self, + bundle: impl AsRef, + media_source: ProjectRoot, + ) -> Result { + let encoded = EncodedProject::prepare(self)?; + let publisher = ProjectRoot::begin_replace(bundle.as_ref())?; + encoded.write_to(publisher.stage())?; + media_source.copy_media_to(publisher.stage())?; + media_source.copy_chat_sessions_to(publisher.stage())?; + if self.thumbnail.is_none() { + media_source.copy_thumbnail_to(publisher.stage())?; + } + drop(media_source); + publisher.publish() + } + + /// Replace the owned source bundle while adding one generated media leaf + /// directly to the unpublished sibling stage. + /// + /// This keeps the media bytes, `media.json`, and `generation-log.json` in + /// one directory-publication transaction. In particular, callers do not + /// need to retain an open handle inside the live target across its Windows + /// rename commit point. + pub fn publish_complete_replacing_root_with_media( + &self, + bundle: impl AsRef, + media_source: ProjectRoot, + media_leaf: &str, + media_byte_size: u64, + media: &mut dyn std::io::Read, + ) -> Result { + let encoded = EncodedProject::prepare(self)?; + let publisher = ProjectRoot::begin_replace(bundle.as_ref())?; + encoded.write_to(publisher.stage())?; + media_source.copy_media_to(publisher.stage())?; + publisher + .stage() + .write_new_media_leaf(media_leaf, media_byte_size, media)?; + media_source.copy_chat_sessions_to(publisher.stage())?; + if self.thumbnail.is_none() { + media_source.copy_thumbnail_to(publisher.stage())?; } + drop(media_source); publisher.publish() } } @@ -338,6 +436,13 @@ impl EncodedProject { /// Produce the exact byte snapshot before any destination path is created. fn prepare(project: &Project) -> Result { project.compatibility.ensure_writable()?; + project + .timeline + .validate_nested_sequences() + .map_err(|reason| ProjectError::InvalidTimeline { + file: layout::TIMELINE_FILE, + reason, + })?; Ok(Self { timeline: encode_component(layout::TIMELINE_FILE, &project.timeline)?, manifest: encode_component(layout::MANIFEST_FILE, &project.manifest)?, @@ -551,6 +656,87 @@ mod tests { )); } + #[test] + fn project_open_rejects_unsafe_project_media_and_proxy_paths() { + for (index, unsafe_path) in [ + "../private.mov", + "media/../../private.mov", + "/private.mov", + r"C:\private.mov", + "C:private.mov", + ] + .into_iter() + .enumerate() + { + let tmp = TmpDir::new(&format!("unsafe-media-path-{index}")); + let bundle = tmp.path().join("Unsafe.opentake"); + Project::new(&bundle).save().unwrap(); + let manifest = serde_json::json!({ + "version": 2, + "entries": [{ + "id": "asset-1", + "name": "clip.mov", + "type": "video", + "source": { "project": { "relativePath": unsafe_path } }, + "duration": 1.0 + }], + "folders": [] + }); + fs::write( + bundle.join(layout::MANIFEST_FILE), + serde_json::to_vec(&manifest).unwrap(), + ) + .unwrap(); + + assert!(Project::open(&bundle).is_err()); + + let mut proxy_manifest = manifest; + proxy_manifest["entries"][0]["source"] = serde_json::json!({ + "project": { "relativePath": "media/valid.mov" } + }); + proxy_manifest["entries"][0]["proxy"] = serde_json::json!({ + "relativePath": unsafe_path, + "sourceSha256": "00", + "width": 320, + "height": 180 + }); + fs::write( + bundle.join(layout::MANIFEST_FILE), + serde_json::to_vec(&proxy_manifest).unwrap(), + ) + .unwrap(); + assert!(Project::open(&bundle).is_err()); + } + } + + #[test] + fn project_open_accepts_nested_project_media_path() { + let tmp = TmpDir::new("safe-media-path"); + let bundle = tmp.path().join("Safe.opentake"); + Project::new(&bundle).save().unwrap(); + let manifest = serde_json::json!({ + "version": 2, + "entries": [{ + "id": "asset-1", + "name": "clip.mov", + "type": "video", + "source": { "project": { "relativePath": "media/nested/clip.mov" } }, + "duration": 1.0 + }], + "folders": [] + }); + fs::write( + bundle.join(layout::MANIFEST_FILE), + serde_json::to_vec(&manifest).unwrap(), + ) + .unwrap(); + + assert_eq!( + Project::open(&bundle).unwrap().manifest.entries[0].id, + "asset-1" + ); + } + #[cfg(unix)] #[test] fn retained_root_save_never_writes_an_ambient_replacement() { @@ -637,6 +823,103 @@ mod tests { ); } + #[test] + fn complete_publish_replaces_the_owned_source_root() { + let tmp = TmpDir::new("complete-same-target"); + let target = tmp.path().join("Project.opentake"); + let mut project = Project::new(&target); + project.timeline.fps = 24; + project.save().unwrap(); + fs::create_dir_all(target.join("media")).unwrap(); + fs::write(target.join("media/clip.bin"), b"media").unwrap(); + fs::write(target.join("thumbnail.jpg"), b"cover").unwrap(); + let source_root = ProjectRoot::open(&target).unwrap(); + + project.timeline.fps = 48; + let published = project + .publish_complete_replacing_root(&target, source_root) + .expect("same-target publication must release the old root before rename"); + + assert_eq!( + Project::open_from_root(&published).unwrap().timeline.fps, + 48 + ); + assert_eq!(fs::read(target.join("media/clip.bin")).unwrap(), b"media"); + assert_eq!(fs::read(target.join("thumbnail.jpg")).unwrap(), b"cover"); + } + + #[test] + fn complete_publish_streams_generated_media_into_the_new_bundle() { + let tmp = TmpDir::new("complete-generated-media"); + let target = tmp.path().join("Project.opentake"); + let mut project = Project::new(&target); + project.timeline.fps = 24; + project.save().unwrap(); + fs::create_dir_all(target.join("media")).unwrap(); + fs::write(target.join("media/source.bin"), b"source").unwrap(); + let source_root = ProjectRoot::open(&target).unwrap(); + let mut generated = std::io::Cursor::new(b"generated"); + + project + .publish_complete_replacing_root_with_media( + &target, + source_root, + "output.bin", + 9, + &mut generated, + ) + .expect("generated media must share the bundle publication commit"); + + assert_eq!( + fs::read(target.join("media/source.bin")).unwrap(), + b"source" + ); + assert_eq!( + fs::read(target.join("media/output.bin")).unwrap(), + b"generated" + ); + } + + #[test] + fn generated_media_stream_failure_preserves_the_live_bundle_byte_exact() { + struct FailingReader(bool); + + impl std::io::Read for FailingReader { + fn read(&mut self, buffer: &mut [u8]) -> std::io::Result { + if self.0 { + return Err(std::io::Error::other("injected media read failure")); + } + self.0 = true; + let bytes = b"partial"; + buffer[..bytes.len()].copy_from_slice(bytes); + Ok(bytes.len()) + } + } + + let tmp = TmpDir::new("complete-generated-media-failure"); + let target = tmp.path().join("Project.opentake"); + let project = Project::new(&target); + project.save().unwrap(); + fs::create_dir_all(target.join("media")).unwrap(); + fs::write(target.join("media/source.bin"), b"source").unwrap(); + let before = tree_receipt(&target); + let source_root = ProjectRoot::open(&target).unwrap(); + let mut generated = FailingReader(false); + + project + .publish_complete_replacing_root_with_media( + &target, + source_root, + "output.bin", + 14, + &mut generated, + ) + .expect_err("a failed generated media stream must abort publication"); + + assert_eq!(tree_receipt(&target), before); + assert!(!target.join("media/output.bin").exists()); + } + #[cfg(unix)] #[test] fn media_copy_failure_leaves_an_existing_target_tree_byte_exact() { diff --git a/crates/opentake-project/src/compatibility.rs b/crates/opentake-project/src/compatibility.rs index 4bf6cf6b..716b9359 100644 --- a/crates/opentake-project/src/compatibility.rs +++ b/crates/opentake-project/src/compatibility.rs @@ -5,8 +5,8 @@ //! that serde cannot see after a Swift-compatible `try?` fallback. use opentake_domain::{ - AnimPair, Clip, Crop, Fill, Keyframe, KeyframeTrack, KeyframeValueWireShape, Rgba, Shadow, - TextStyle, Timeline, Track, Transform, + AnimPair, Clip, Crop, Fill, Keyframe, KeyframeTrack, KeyframeValueWireShape, NestedSequence, + Rgba, Shadow, TextStyle, Timeline, Track, Transform, }; use serde_json::Value; use uuid::Uuid; @@ -27,6 +27,25 @@ pub(crate) struct TimelineFallback { /// and clip ordering; a Track.clips fallback yields an empty decoded vector and /// is therefore skipped safely. pub(crate) fn repair_timeline_ids(timeline: &mut Timeline, document: &Value) { + repair_timeline_ids_inner(timeline, document); +} + +fn repair_timeline_ids_inner(timeline: &mut Timeline, document: &Value) { + if let Some(raw_sequences) = document + .get(Timeline::NESTED_SEQUENCES_WIRE_FIELD) + .and_then(Value::as_array) + { + for (sequence_index, sequence) in timeline.nested_sequences.iter_mut().enumerate() { + let Some(raw_timeline) = raw_sequences + .get(sequence_index) + .and_then(|value| value.get(NestedSequence::TIMELINE_WIRE_FIELD)) + else { + continue; + }; + repair_timeline_ids_inner(&mut sequence.timeline, raw_timeline); + } + } + let Some(raw_tracks) = document .get(Timeline::TRACKS_WIRE_FIELD) .and_then(Value::as_array) @@ -112,6 +131,44 @@ pub(crate) fn scan_timeline( failed_tracks: &[bool], ignored: &mut Vec, ) { + scan_timeline_inner(document, "", file, failed_tracks, ignored); +} + +fn scan_timeline_inner( + document: &Value, + prefix: &str, + file: &str, + failed_tracks: &[bool], + ignored: &mut Vec, +) { + if let Some(sequences) = document + .get(Timeline::NESTED_SEQUENCES_WIRE_FIELD) + .and_then(Value::as_array) + { + for (sequence_index, sequence) in sequences.iter().enumerate() { + let sequence_path = prefixed( + prefix, + &format!("{}.{sequence_index}", Timeline::NESTED_SEQUENCES_WIRE_FIELD), + ); + scan_object_keys( + Some(sequence), + &sequence_path, + NestedSequence::WIRE_FIELDS, + file, + ignored, + ); + if let Some(child) = sequence.get(NestedSequence::TIMELINE_WIRE_FIELD) { + scan_timeline_inner( + child, + &format!("{sequence_path}.{}", NestedSequence::TIMELINE_WIRE_FIELD), + file, + &[], + ignored, + ); + } + } + } + let Some(tracks) = document .get(Timeline::TRACKS_WIRE_FIELD) .and_then(Value::as_array) @@ -120,7 +177,10 @@ pub(crate) fn scan_timeline( }; for (track_index, track) in tracks.iter().enumerate() { - let track_path = format!("{}.{track_index}", Timeline::TRACKS_WIRE_FIELD); + let track_path = prefixed( + prefix, + &format!("{}.{track_index}", Timeline::TRACKS_WIRE_FIELD), + ); for field in Track::TOLERANT_SCALAR_WIRE_FIELDS { scan_future_scalar_shape( track.get(*field), @@ -219,6 +279,14 @@ pub(crate) fn scan_timeline( } } +fn prefixed(prefix: &str, suffix: &str) -> String { + if prefix.is_empty() { + suffix.to_string() + } else { + format!("{prefix}.{suffix}") + } +} + fn scan_decodable_clip_unknowns(clip: &Value, path: &str, file: &str, ignored: &mut Vec) { let Ok(bytes) = serde_json::to_vec(clip) else { return; diff --git a/crates/opentake-project/src/edl.rs b/crates/opentake-project/src/edl.rs index bde2bdf6..ba4eaa68 100644 --- a/crates/opentake-project/src/edl.rs +++ b/crates/opentake-project/src/edl.rs @@ -48,7 +48,7 @@ //! no cross-platform tape/source-timecode reader — see `fcpxml.rs`); the source //! window is `[trim_start, trim_start + source_frames_consumed)`. -use opentake_domain::{Clip, MediaManifest, MediaResolver, Timeline, Track}; +use opentake_domain::{Clip, ClipType, MediaManifest, MediaResolver, Timeline, Track}; /// Reel name for every event. Real source-tape names need a tape-timecode /// reader OpenTake lacks; `AX` ("auxiliary") is the CMX3600 convention for @@ -100,14 +100,30 @@ impl Builder<'_> { out } - /// Clips of the topmost video track, sorted by start frame. CMX3600 holds a - /// single video track, so we pick the first visual track in timeline order. + /// Clips of the topmost editorial-media track, sorted by start frame. + /// + /// `ClipType::is_visual()` also includes text and Lottie tracks. Those are + /// overlays rather than CMX3600 video events, so selecting the first visual + /// track would export captions as `Offline` clips and hide the real video + /// track below. Pick the first track that actually contains video/image + /// media and omit overlay clips even when a mixed visual track contains + /// them. fn top_video_clips(&self) -> Vec { - let track: Option<&Track> = self.timeline.tracks.iter().find(|t| t.kind.is_visual()); - let Some(track) = track else { - return Vec::new(); - }; - let mut clips: Vec = track.clips.clone(); + let mut clips = self + .timeline + .tracks + .iter() + .filter(|track: &&Track| track.kind.is_visual()) + .find_map(|track| { + let clips: Vec = track + .clips + .iter() + .filter(|clip| matches!(clip.media_type, ClipType::Video | ClipType::Image)) + .cloned() + .collect(); + (!clips.is_empty()).then_some(clips) + }) + .unwrap_or_default(); clips.sort_by_key(|c| c.start_frame); clips } @@ -183,7 +199,7 @@ fn format_timecode(frame: i32, fps: i32, drop_frame: bool) -> String { #[cfg(test)] mod tests { use super::*; - use opentake_domain::{ClipType, MediaManifestEntry, MediaSource}; + use opentake_domain::{MediaManifestEntry, MediaSource}; fn entry(id: &str, name: &str, kind: ClipType, duration: f64) -> MediaManifestEntry { MediaManifestEntry { @@ -199,6 +215,8 @@ mod tests { source_height: Some(1080), source_fps: Some(30.0), has_audio: Some(true), + color: None, + proxy: None, folder_id: None, cached_remote_url: None, cached_remote_url_expires_at: None, @@ -367,6 +385,37 @@ mod tests { assert!(!edl.contains("002 AX")); } + #[test] + fn caption_overlay_track_does_not_shadow_video_track() { + let mut tl = Timeline::new(); + tl.fps = 30; + + let mut captions = Track::new("captions", ClipType::Text); + let mut caption = Clip::new("caption", "caption-media", 0, 90); + caption.media_type = ClipType::Text; + caption.source_clip_type = ClipType::Text; + caption.text_content = Some("Do not export me as Offline".into()); + captions.clips.push(caption); + + let mut video = Track::new("video", ClipType::Video); + video.clips.push(Clip::new("shot", "v1", 0, 120)); + + // Top-to-bottom order matches the real editor: captions above video. + tl.tracks.push(captions); + tl.tracks.push(video); + + let edl = export_edl( + &tl, + &manifest(vec![entry("v1", "talking-head.mp4", ClipType::Video, 4.0)]), + ); + + assert!(edl.contains("* FROM CLIP NAME: talking-head.mp4")); + assert!(!edl.contains("Do not export me")); + assert!(!edl.contains("* FROM CLIP NAME: Offline")); + assert!(edl.contains("001 AX")); + assert!(!edl.contains("002 AX")); + } + #[test] fn empty_timeline_is_header_only() { let tl = Timeline::new(); diff --git a/crates/opentake-project/src/error.rs b/crates/opentake-project/src/error.rs index d0543a58..42e2675e 100644 --- a/crates/opentake-project/src/error.rs +++ b/crates/opentake-project/src/error.rs @@ -50,6 +50,15 @@ pub enum ProjectError { blockers: Vec, }, + /// The decoded timeline graph is structurally unsafe to edit or render. + #[error("invalid timeline graph in {file}: {reason}")] + InvalidTimeline { file: &'static str, reason: String }, + + /// A project-local media/proxy path could escape or change meaning on a + /// different host platform. + #[error("invalid media manifest in {file}: {reason}")] + InvalidMediaManifest { file: &'static str, reason: String }, + /// Publication could not install the staged bundle or restore the prior /// target. The retained backup is deliberately left in place and the next /// save attempt will recover it before doing new work. diff --git a/crates/opentake-project/src/fcpxml.rs b/crates/opentake-project/src/fcpxml.rs index 17985e53..3b978857 100644 --- a/crates/opentake-project/src/fcpxml.rs +++ b/crates/opentake-project/src/fcpxml.rs @@ -1194,6 +1194,8 @@ mod tests { source_height: Some(1080), source_fps: Some(30.0), has_audio: Some(true), + color: None, + proxy: None, folder_id: None, cached_remote_url: None, cached_remote_url_expires_at: None, @@ -1272,6 +1274,8 @@ mod tests { source_height: None, source_fps: None, has_audio: None, + color: None, + proxy: None, folder_id: None, cached_remote_url: None, cached_remote_url_expires_at: None, @@ -1539,9 +1543,11 @@ mod tests { for xml in [&injected, &plain] { assert!(xml.contains("0")); - // 该文件 的 string 为 00:00:00:00;整份文档不含 999。 + // 该文件 的 string 为 00:00:00:00;未命中的 + // 注入值不能成为 timecode 帧。不要检查裸字符串 `999`,因为 + // 临时素材路径包含进程号,PID 本身可能恰好含有这三位数字。 assert!(xml.contains("00:00:00:00")); - assert!(!xml.contains("999")); + assert!(!xml.contains("999")); } fs::remove_dir_all(&dir).ok(); } diff --git a/crates/opentake-project/src/fcpxml_modern_tests.rs b/crates/opentake-project/src/fcpxml_modern_tests.rs index a6cb97de..e32e3bb4 100644 --- a/crates/opentake-project/src/fcpxml_modern_tests.rs +++ b/crates/opentake-project/src/fcpxml_modern_tests.rs @@ -20,6 +20,8 @@ fn entry(id: &str, name: &str, kind: ClipType, duration: f64) -> MediaManifestEn source_height: Some(1080), source_fps: Some(30.0), has_audio: Some(kind == ClipType::Video || kind == ClipType::Audio), + color: None, + proxy: None, folder_id: None, cached_remote_url: None, cached_remote_url_expires_at: None, diff --git a/crates/opentake-project/src/gen_log.rs b/crates/opentake-project/src/gen_log.rs index e5739f94..1ea15e86 100644 --- a/crates/opentake-project/src/gen_log.rs +++ b/crates/opentake-project/src/gen_log.rs @@ -17,6 +17,8 @@ use serde::{Deserialize, Serialize}; use uuid::Uuid; +use opentake_domain::GenerationJobStatus; + fn default_version() -> i64 { 1 } @@ -79,6 +81,26 @@ pub struct GenerationLogEntry { /// Apple-reference-date seconds. `None` when unknown. #[serde(skip_serializing_if = "Option::is_none")] pub created_at: Option, + /// Provider-neutral durable job identity. Never a signed URL or credential. + #[serde(skip_serializing_if = "Option::is_none")] + pub job_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub provider: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub provider_job_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub asset_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub status: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub progress: Option, + /// Fixed application-owned code only; provider diagnostic text is private. + #[serde(skip_serializing_if = "Option::is_none")] + pub error_code: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub source_asset_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub source_clip_id: Option, } impl GenerationLogEntry { @@ -94,6 +116,49 @@ impl GenerationLogEntry { model: model.into(), cost_credits, created_at, + job_id: None, + provider: None, + provider_job_id: None, + asset_id: None, + status: None, + progress: None, + error_code: None, + source_asset_id: None, + source_clip_id: None, + } + } + + /// Construct one append-only job lifecycle event without provider secrets. + #[allow(clippy::too_many_arguments)] + pub fn job_event( + id: impl Into, + job_id: impl Into, + model: impl Into, + cost_credits: Option, + provider: impl Into, + provider_job_id: Option, + asset_id: impl Into, + status: GenerationJobStatus, + progress: Option, + error_code: Option, + created_at: Option, + source_asset_id: Option, + source_clip_id: Option, + ) -> Self { + Self { + id: id.into(), + job_id: Some(job_id.into()), + model: model.into(), + provider: Some(provider.into()), + provider_job_id, + asset_id: Some(asset_id.into()), + status: Some(status), + progress, + error_code, + cost_credits, + created_at, + source_asset_id, + source_clip_id, } } } @@ -114,6 +179,15 @@ impl<'de> Deserialize<'de> for GenerationLogEntry { created_at: Option, // Legacy: dollars as a float. Only consulted when costCredits is absent. cost: Option, + job_id: Option, + provider: Option, + provider_job_id: Option, + asset_id: Option, + status: Option, + progress: Option, + error_code: Option, + source_asset_id: Option, + source_clip_id: Option, } let raw = Raw::deserialize(deserializer)?; let cost_credits = match raw.cost_credits { @@ -128,6 +202,15 @@ impl<'de> Deserialize<'de> for GenerationLogEntry { model: raw.model, cost_credits, created_at: raw.created_at, + job_id: raw.job_id, + provider: raw.provider, + provider_job_id: raw.provider_job_id, + asset_id: raw.asset_id, + status: raw.status, + progress: raw.progress, + error_code: raw.error_code, + source_asset_id: raw.source_asset_id, + source_clip_id: raw.source_clip_id, }) } } diff --git a/crates/opentake-project/src/layout.rs b/crates/opentake-project/src/layout.rs index 4c096214..d50b1771 100644 --- a/crates/opentake-project/src/layout.rs +++ b/crates/opentake-project/src/layout.rs @@ -28,6 +28,9 @@ pub const THUMBNAIL_FILE: &str = "thumbnail.jpg"; /// convention point inside this directory. pub const MEDIA_DIR: &str = "media"; +/// `media/luts/` — content-addressed, project-managed `.cube` files. +pub const LUTS_DIR: &str = "luts"; + /// `chat-sessions/` — one `.json` per agent chat session. /// /// OpenTake-specific: upstream stores these under `chat/` @@ -61,6 +64,11 @@ pub fn media_dir(bundle: &Path) -> PathBuf { bundle.join(MEDIA_DIR) } +/// Absolute path to the project-managed LUT directory. +pub fn luts_dir(bundle: &Path) -> PathBuf { + media_dir(bundle).join(LUTS_DIR) +} + /// Absolute path to the `chat-sessions/` directory inside `bundle`. pub fn chat_sessions_dir(bundle: &Path) -> PathBuf { bundle.join(CHAT_SESSIONS_DIR) diff --git a/crates/opentake-project/src/lib.rs b/crates/opentake-project/src/lib.rs index 6517ec75..f6bed6c8 100644 --- a/crates/opentake-project/src/lib.rs +++ b/crates/opentake-project/src/lib.rs @@ -52,6 +52,7 @@ pub mod fcpxml_modern; pub mod gen_log; pub mod layout; pub mod otio; +mod path_policy; mod project_root; mod safe_fs; pub mod xmlnode; @@ -64,7 +65,8 @@ pub use fcpxml::{export_xmeml, export_xmeml_with_timecodes}; pub use fcpxml_modern::export_fcpxml; pub use gen_log::{GenerationLog, GenerationLogEntry}; pub use otio::export_otio; -pub use project_root::ProjectRoot; +pub use path_policy::is_safe_project_asset_relative_path; +pub use project_root::{ProjectRoot, ProjectRootIdentity}; // Re-export the domain types a caller needs to construct/inspect a project, so // downstream crates can depend on just `opentake-project` for persistence work. diff --git a/crates/opentake-project/src/otio.rs b/crates/opentake-project/src/otio.rs index 6d446e92..24ea2f13 100644 --- a/crates/opentake-project/src/otio.rs +++ b/crates/opentake-project/src/otio.rs @@ -279,6 +279,8 @@ mod tests { source_height: Some(1080), source_fps: Some(24.0), has_audio: Some(true), + color: None, + proxy: None, folder_id: None, cached_remote_url: None, cached_remote_url_expires_at: None, diff --git a/crates/opentake-project/src/path_policy.rs b/crates/opentake-project/src/path_policy.rs new file mode 100644 index 00000000..d9970d91 --- /dev/null +++ b/crates/opentake-project/src/path_policy.rs @@ -0,0 +1,19 @@ +//! Portable validation for paths stored inside `.opentake` bundles. + +use std::path::{Component, Path}; + +/// Return whether `value` is a portable, non-empty bundle-relative path made +/// exclusively from ordinary components. +pub fn is_safe_project_asset_relative_path(value: &str) -> bool { + // Bundle paths move between host platforms. Reject Windows separators, + // drive prefixes and ADS syntax even when parsing on Unix. + if value.is_empty() || value.contains(['\\', ':']) { + return false; + } + let path = Path::new(value); + !path.is_absolute() + && path + .components() + .all(|component| matches!(component, Component::Normal(_))) + && path.components().next().is_some() +} diff --git a/crates/opentake-project/src/project_root.rs b/crates/opentake-project/src/project_root.rs index 42c39ae8..0910d022 100644 --- a/crates/opentake-project/src/project_root.rs +++ b/crates/opentake-project/src/project_root.rs @@ -11,6 +11,79 @@ use same_file::Handle; use crate::error::{ProjectError, Result}; +const TIMELINE_COMPONENT_MAX_BYTES: usize = 64 * 1024 * 1024; +const MANIFEST_COMPONENT_MAX_BYTES: usize = 32 * 1024 * 1024; +const GENERATION_LOG_COMPONENT_MAX_BYTES: usize = 16 * 1024 * 1024; +const THUMBNAIL_COMPONENT_MAX_BYTES: usize = 16 * 1024 * 1024; +const PUBLISH_MARKER_FILE: &str = ".opentake-publish-marker"; +const PUBLISH_MARKER_MAX_BYTES: usize = 256; +const TRANSACTION_JOURNAL_MAX_BYTES: usize = 4 * 1024; + +fn project_component_max_bytes(name: &str) -> Option { + match name { + crate::layout::TIMELINE_FILE => Some(TIMELINE_COMPONENT_MAX_BYTES), + crate::layout::MANIFEST_FILE => Some(MANIFEST_COMPONENT_MAX_BYTES), + crate::layout::GENERATION_LOG_FILE => Some(GENERATION_LOG_COMPONENT_MAX_BYTES), + crate::layout::THUMBNAIL_FILE => Some(THUMBNAIL_COMPONENT_MAX_BYTES), + PUBLISH_MARKER_FILE => Some(PUBLISH_MARKER_MAX_BYTES), + _ => None, + } +} + +fn read_bounded_regular_file( + file: &mut cap_std::fs::File, + path: &Path, + max_bytes: usize, + description: &str, +) -> Result> { + let metadata = file + .metadata() + .map_err(|error| ProjectError::io(path, error))?; + if !metadata.is_file() { + return Err(ProjectError::io( + path, + std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!("{description} is not a nofollow regular file"), + ), + )); + } + if metadata.len() > max_bytes as u64 { + return Err(ProjectError::io( + path, + std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!("{description} exceeds the {max_bytes}-byte limit"), + ), + )); + } + read_bounded_contents(file, path, metadata.len() as usize, max_bytes, description) +} + +fn read_bounded_contents( + reader: &mut impl Read, + path: &Path, + initial_capacity: usize, + max_bytes: usize, + description: &str, +) -> Result> { + let mut bytes = Vec::with_capacity(initial_capacity.min(max_bytes)); + Read::by_ref(reader) + .take(max_bytes as u64 + 1) + .read_to_end(&mut bytes) + .map_err(|error| ProjectError::io(path, error))?; + if bytes.len() > max_bytes { + return Err(ProjectError::io( + path, + std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!("{description} grew beyond the {max_bytes}-byte limit"), + ), + )); + } + Ok(bytes) +} + /// Retained authority for one concrete `.opentake` bundle directory. /// /// The final bundle component is always opened no-follow. Consequently a path @@ -23,6 +96,18 @@ pub struct ProjectRoot { name: OsString, dir: Dir, identity: Handle, + stable_identity: ProjectRootIdentity, +} + +/// Cross-process identity of one retained project directory. +/// +/// `volume`/`file` are `(st_dev, st_ino)` on Unix and +/// `(volume serial number, file index)` on Windows. They are obtained from an +/// already-open no-follow directory handle, never by trusting an ambient path. +#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct ProjectRootIdentity { + pub volume: u64, + pub file: u64, } impl std::fmt::Debug for ProjectRoot { @@ -64,18 +149,21 @@ impl ProjectRoot { let dir = parent .open_dir_nofollow(&name) .map_err(|error| ProjectError::io(path, error))?; - let identity = Handle::from_file( - dir.try_clone() - .map_err(|error| ProjectError::io(path, error))? - .into_std_file(), - ) - .map_err(|error| ProjectError::io(path, error))?; + let identity_file = dir + .try_clone() + .map_err(|error| ProjectError::io(path, error))? + .into_std_file(); + let stable_identity = stable_project_root_identity(&identity_file) + .map_err(|error| ProjectError::io(path, error))?; + let identity = + Handle::from_file(identity_file).map_err(|error| ProjectError::io(path, error))?; Ok(Self { path: path.to_path_buf(), parent, name, dir, identity, + stable_identity, }) } @@ -129,6 +217,77 @@ impl ProjectRoot { &self.identity } + /// Serializable identity derived from the retained root handle. This is + /// used to bind isolated asset-reader results back to the exact project + /// session that authorized them. + pub fn stable_identity(&self) -> ProjectRootIdentity { + self.stable_identity + } + + /// Open a project-local asset through this retained bundle capability. + /// Every directory and the final leaf are opened no-follow, so an ambient + /// rename/replacement of the `.opentake` pathname cannot redirect the read. + pub fn open_asset_file(&self, relative: &Path) -> Result { + if relative.is_absolute() { + return Err(ProjectError::io( + self.path.join(relative), + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "project asset path must be relative", + ), + )); + } + let components = relative.components().collect::>(); + if components.is_empty() + || components + .iter() + .any(|component| !matches!(component, Component::Normal(_))) + { + return Err(ProjectError::io( + self.path.join(relative), + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "project asset path contains an unsafe component", + ), + )); + } + + let mut directory = self + .dir + .try_clone() + .map_err(|error| ProjectError::io(&self.path, error))?; + for component in &components[..components.len() - 1] { + let Component::Normal(name) = component else { + unreachable!("components were validated above"); + }; + directory = directory + .open_dir_nofollow(name) + .map_err(|error| ProjectError::io(self.path.join(relative), error))?; + } + + let Component::Normal(name) = components[components.len() - 1] else { + unreachable!("components were validated above"); + }; + let mut options = OpenOptions::new(); + options.read(true).follow(FollowSymlinks::No); + #[cfg(unix)] + options.custom_flags(libc::O_NONBLOCK); + #[cfg(windows)] + { + use cap_std::fs::OpenOptionsExt; + use windows_sys::Win32::Storage::FileSystem::{ + FILE_FLAG_OPEN_NO_RECALL, FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE, + }; + options + .share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE) + .custom_flags(FILE_FLAG_OPEN_NO_RECALL); + } + directory + .open_with(name, &options) + .map(cap_std::fs::File::into_std) + .map_err(|error| ProjectError::io(self.path.join(relative), error)) + } + /// Diagnostic comparison for a caller-supplied path alias. Same-project /// saves continue through this root even when the logical spelling differs. pub fn matches_path(&self, path: impl AsRef) -> Result { @@ -157,6 +316,57 @@ impl ProjectRoot { self.copy_directory_component_to(destination, crate::layout::MEDIA_DIR, "media-copy") } + /// Write one fresh media leaf into this retained bundle. + /// + /// Complete generation publication uses this only on an unpublished stage, + /// after the existing media tree has been copied. The final leaf is created + /// with `create_new`, streamed without an ambient destination path, checked + /// against the downloader's exact byte count, synced, and kept only after + /// every write succeeds. + pub(crate) fn write_new_media_leaf( + &self, + name: &str, + expected_bytes: u64, + source: &mut dyn Read, + ) -> Result<()> { + validate_leaf(name).map_err(|error| { + ProjectError::io(self.path.join(crate::layout::MEDIA_DIR).join(name), error) + })?; + let media_path = self.path.join(crate::layout::MEDIA_DIR); + match self.dir.create_dir(crate::layout::MEDIA_DIR) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {} + Err(error) => return Err(ProjectError::io(&media_path, error)), + } + let media = self + .dir + .open_dir_nofollow(crate::layout::MEDIA_DIR) + .map_err(|error| ProjectError::io(&media_path, error))?; + let mut leaf = TransactionLeaf::create(&media, name) + .map_err(|error| ProjectError::io(media_path.join(name), error))?; + let copied = std::io::copy( + &mut source.take(expected_bytes.saturating_add(1)), + leaf.handle.as_file_mut(), + ) + .map_err(|error| ProjectError::io(media_path.join(name), error))?; + if copied != expected_bytes { + return Err(ProjectError::io( + media_path.join(name), + std::io::Error::new( + std::io::ErrorKind::InvalidData, + "generated media size changed before publication", + ), + )); + } + leaf.handle + .as_file_mut() + .flush() + .and_then(|()| leaf.handle.as_file().sync_all()) + .map_err(|error| ProjectError::io(media_path.join(name), error))?; + leaf.cleanup_on_drop = false; + Ok(()) + } + /// Copy project-local Agent conversations during complete-bundle /// publication (Save As / archive) through retained no-follow roots. pub fn copy_chat_sessions_to(&self, destination: &ProjectRoot) -> Result<()> { @@ -167,6 +377,14 @@ impl ProjectRoot { ) } + /// Preserve the optional project cover across complete-bundle publication. + pub(crate) fn copy_thumbnail_to(&self, destination: &ProjectRoot) -> Result<()> { + if let Some(bytes) = self.read_optional(crate::layout::THUMBNAIL_FILE)? { + destination.write_atomic(crate::layout::THUMBNAIL_FILE, &bytes)?; + } + Ok(()) + } + fn copy_directory_component_to( &self, destination: &ProjectRoot, @@ -239,6 +457,16 @@ impl ProjectRoot { } pub(crate) fn read_optional(&self, name: &str) -> Result>> { + let path = self.path.join(name); + let max_bytes = project_component_max_bytes(name).ok_or_else(|| { + ProjectError::io( + &path, + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "project component has no configured byte limit", + ), + ) + })?; let mut options = OpenOptions::new(); options.read(true).follow(FollowSymlinks::No); #[cfg(unix)] @@ -246,25 +474,9 @@ impl ProjectRoot { let mut file = match self.dir.open_with(name, &options) { Ok(file) => file, Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), - Err(error) => return Err(ProjectError::io(self.path.join(name), error)), + Err(error) => return Err(ProjectError::io(&path, error)), }; - if !file - .metadata() - .map_err(|error| ProjectError::io(self.path.join(name), error))? - .is_file() - { - return Err(ProjectError::io( - self.path.join(name), - std::io::Error::new( - std::io::ErrorKind::InvalidData, - "project component is not a nofollow regular file", - ), - )); - } - let mut bytes = Vec::new(); - file.read_to_end(&mut bytes) - .map_err(|error| ProjectError::io(self.path.join(name), error))?; - Ok(Some(bytes)) + read_bounded_regular_file(&mut file, &path, max_bytes, "project component").map(Some) } pub(crate) fn write_atomic(&self, name: &str, bytes: &[u8]) -> Result<()> { @@ -377,6 +589,99 @@ impl ProjectRoot { Ok(()) } + /// Read one project-managed LUT through retained no-follow directories. + pub fn read_lut(&self, name: &str, max_bytes: usize) -> Result>> { + validate_leaf(name).map_err(|error| { + ProjectError::io( + self.path + .join(crate::layout::MEDIA_DIR) + .join(crate::layout::LUTS_DIR) + .join(name), + error, + ) + })?; + let Some(directory) = self.luts_directory(false)? else { + return Ok(None); + }; + let path = self + .path + .join(crate::layout::MEDIA_DIR) + .join(crate::layout::LUTS_DIR) + .join(name); + let mut options = OpenOptions::new(); + options.read(true).follow(FollowSymlinks::No); + #[cfg(unix)] + options.custom_flags(libc::O_NONBLOCK); + let mut file = match directory.open_with(name, &options) { + Ok(file) => file, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(ProjectError::io(path, error)), + }; + let metadata = file + .metadata() + .map_err(|error| ProjectError::io(&path, error))?; + if !metadata.is_file() || metadata.len() > max_bytes as u64 { + return Err(ProjectError::io( + path, + std::io::Error::new( + std::io::ErrorKind::InvalidData, + "LUT is not a bounded nofollow regular file", + ), + )); + } + let mut bytes = Vec::with_capacity(metadata.len() as usize); + Read::by_ref(&mut file) + .take(max_bytes as u64 + 1) + .read_to_end(&mut bytes) + .map_err(|error| ProjectError::io(&path, error))?; + if bytes.len() > max_bytes { + return Err(ProjectError::io( + path, + std::io::Error::new( + std::io::ErrorKind::InvalidData, + "LUT grew beyond the configured byte limit", + ), + )); + } + Ok(Some(bytes)) + } + + /// Atomically publish one validated, content-addressed LUT under + /// `media/luts/`. Callers validate both the bytes and digest before entry. + pub fn write_lut_atomic(&self, name: &str, bytes: &[u8]) -> Result<()> { + validate_leaf(name).map_err(|error| { + ProjectError::io( + self.path + .join(crate::layout::MEDIA_DIR) + .join(crate::layout::LUTS_DIR) + .join(name), + error, + ) + })?; + let directory = self + .luts_directory(true)? + .expect("create=true returns a directory"); + let directory_path = self + .path + .join(crate::layout::MEDIA_DIR) + .join(crate::layout::LUTS_DIR); + let tmp_name = unique_temp_name(name); + let mut tmp = TransactionLeaf::create(&directory, &tmp_name) + .map_err(|error| ProjectError::io(directory_path.join(&tmp_name), error))?; + tmp.handle + .as_file_mut() + .write_all(bytes) + .map_err(|error| ProjectError::io(directory_path.join(&tmp_name), error))?; + tmp.handle + .as_file() + .sync_all() + .map_err(|error| ProjectError::io(directory_path.join(&tmp_name), error))?; + tmp.replace(&directory, Path::new(name)) + .map_err(|error| ProjectError::io(directory_path.join(name), error))?; + tmp.cleanup_on_drop = false; + Ok(()) + } + /// List no-follow regular leaves in `chat-sessions/`. Callers own the /// filename policy (for example selecting only `.json`). pub fn list_chat_session_files(&self, max_entries: usize) -> Result> { @@ -449,6 +754,112 @@ impl ProjectRoot { .map(Some) .map_err(|error| ProjectError::io(path, error)) } + + fn luts_directory(&self, create: bool) -> Result> { + let media_path = self.path.join(crate::layout::MEDIA_DIR); + match self.dir.symlink_metadata(crate::layout::MEDIA_DIR) { + Ok(metadata) if metadata.is_dir() && !metadata.file_type().is_symlink() => {} + Ok(_) => { + return Err(ProjectError::io( + media_path, + std::io::Error::new( + std::io::ErrorKind::InvalidData, + "media must be a nofollow directory", + ), + )); + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound && create => { + self.dir + .create_dir(crate::layout::MEDIA_DIR) + .or_else(|error| { + if error.kind() == std::io::ErrorKind::AlreadyExists { + Ok(()) + } else { + Err(error) + } + }) + .map_err(|error| ProjectError::io(&media_path, error))?; + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(ProjectError::io(&media_path, error)), + } + let media = self + .dir + .open_dir_nofollow(crate::layout::MEDIA_DIR) + .map_err(|error| ProjectError::io(&media_path, error))?; + let luts_path = media_path.join(crate::layout::LUTS_DIR); + match media.symlink_metadata(crate::layout::LUTS_DIR) { + Ok(metadata) if metadata.is_dir() && !metadata.file_type().is_symlink() => {} + Ok(_) => { + return Err(ProjectError::io( + luts_path, + std::io::Error::new( + std::io::ErrorKind::InvalidData, + "luts must be a nofollow directory", + ), + )); + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound && create => { + media + .create_dir(crate::layout::LUTS_DIR) + .or_else(|error| { + if error.kind() == std::io::ErrorKind::AlreadyExists { + Ok(()) + } else { + Err(error) + } + }) + .map_err(|error| ProjectError::io(&luts_path, error))?; + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(ProjectError::io(&luts_path, error)), + } + media + .open_dir_nofollow(crate::layout::LUTS_DIR) + .map(Some) + .map_err(|error| ProjectError::io(luts_path, error)) + } +} + +#[cfg(unix)] +fn stable_project_root_identity(file: &fs::File) -> std::io::Result { + use std::os::unix::fs::MetadataExt; + + let metadata = file.metadata()?; + Ok(ProjectRootIdentity { + volume: metadata.dev(), + file: metadata.ino(), + }) +} + +#[cfg(target_os = "windows")] +fn stable_project_root_identity(file: &fs::File) -> std::io::Result { + use std::os::windows::io::AsRawHandle; + use windows_sys::Win32::Foundation::HANDLE; + use windows_sys::Win32::Storage::FileSystem::{ + GetFileInformationByHandle, BY_HANDLE_FILE_INFORMATION, + }; + + // `std::os::windows::fs::MetadataExt::volume_serial_number/file_index` are + // unstable (rust-lang/rust#63010); use the stable handle query instead, + // mirroring src-tauri's retained_file_etag. + let mut information = BY_HANDLE_FILE_INFORMATION::default(); + // SAFETY: `file` owns a live handle and `information` is writable. + if unsafe { + GetFileInformationByHandle( + file.as_raw_handle() as HANDLE, + std::ptr::addr_of_mut!(information), + ) + } == 0 + { + return Err(std::io::Error::last_os_error()); + } + let file_index = + (u64::from(information.nFileIndexHigh) << 32) | u64::from(information.nFileIndexLow); + Ok(ProjectRootIdentity { + volume: u64::from(information.dwVolumeSerialNumber), + file: file_index, + }) } /// One complete-bundle sibling publication. The persistent lock leaf @@ -464,6 +875,8 @@ pub(crate) struct BundlePublisher { backup_name: OsString, journal_name: OsString, journal: PublishJournal, + target_identity: Option, + stage_identity: ProjectRootIdentity, stage: Option, _lock: std::fs::File, _lock_identity: Handle, @@ -542,8 +955,8 @@ impl BundlePublisher { &journal_name, )?; clear_idle_publish_marker(&parent, &parent_path, &target_name)?; - let target_exists = inspect_directory(&parent, &parent_path, &target_name, false)?; - let journal = PublishJournal::new(target_exists); + let target_identity = directory_identity(&parent, &parent_path, &target_name, false)?; + let journal = PublishJournal::new(target_identity); write_new_file_artifact(&parent, &parent_path, &journal_name, &journal.encode())?; let stage_name = stage_artifact_name(&target_name, &journal.nonce); parent @@ -557,6 +970,7 @@ impl BundlePublisher { .map_err(|error| ProjectError::io(&parent_path, error))?, stage_name.clone(), )?; + let stage_identity = stage.stable_identity(); let publisher = Self { target_path: target_path.to_path_buf(), parent_path, @@ -566,6 +980,8 @@ impl BundlePublisher { backup_name, journal_name, journal, + target_identity, + stage_identity, stage: Some(stage), _lock: lock, _lock_identity: lock_identity, @@ -583,15 +999,33 @@ impl BundlePublisher { } pub(crate) fn publish(mut self) -> Result { - #[cfg(test)] - if FAIL_PUBLISH_AFTER_BACKUP.with(|fail| fail.replace(false)) { - return self.publish_with_hook(|| { - Err(std::io::Error::other( - "injected publication failure after backup", - )) - }); + let result = { + #[cfg(test)] + if FAIL_PUBLISH_AFTER_BACKUP.with(|fail| fail.replace(false)) { + self.publish_with_hook(|| { + Err(std::io::Error::other( + "injected publication failure after backup", + )) + }) + } else { + self.publish_with_hook(|| Ok(())) + } + + #[cfg(not(test))] + self.publish_with_hook(|| Ok(())) + }; + + // A caller may immediately start the next complete-bundle save while + // retaining the returned ProjectRoot. Make that successful handoff + // explicit instead of depending on the two cloned lock handles being + // dropped at the end of this function. Error paths retain the lock + // through Drop's staged-artifact cleanup. Closing the handles remains + // the fallback; an unlock error cannot safely turn an already + // committed publication into a reported failure. + if result.is_ok() { + let _ = self._lock.unlock(); } - self.publish_with_hook(|| Ok(())) + result } fn publish_with_hook( @@ -602,7 +1036,10 @@ impl BundlePublisher { .stage .as_ref() .expect("bundle publisher owns its stage before publication"); - if !stage.is_current_namespace()? || !marker_matches(stage, &self.journal.nonce)? { + if stage.stable_identity() != self.stage_identity + || !stage.is_current_namespace()? + || !marker_matches(stage, &self.journal.nonce)? + { return Err(ProjectError::io( self.parent_path.join(&self.stage_name), std::io::Error::new( @@ -620,17 +1057,20 @@ impl BundlePublisher { .expect("validated bundle stage remains owned before publication"), ); - let target_exists = - inspect_directory(&self.parent, &self.parent_path, &self.target_name, false)?; - if target_exists != self.journal.had_target { + let current_target_identity = + directory_identity(&self.parent, &self.parent_path, &self.target_name, false)?; + if current_target_identity != self.target_identity { + let message = if current_target_identity.is_some() != self.target_identity.is_some() { + "bundle target existence changed after transaction preparation" + } else { + "bundle target identity changed after transaction preparation" + }; return Err(ProjectError::io( &self.target_path, - std::io::Error::new( - std::io::ErrorKind::InvalidData, - "bundle target existence changed after transaction preparation", - ), + std::io::Error::new(std::io::ErrorKind::InvalidData, message), )); } + let target_exists = current_target_identity.is_some(); if inspect_directory(&self.parent, &self.parent_path, &self.backup_name, true)? { return Err(ProjectError::io( self.parent_path.join(&self.backup_name), @@ -737,6 +1177,22 @@ impl BundlePublisher { }); } let backup_cleaned = if target_exists { + // The backup name must still denote the exact original target + // directory that this transaction backed up. A hook or ambient + // actor that rebound a foreign object at the backup name must + // never be deleted: fail closed and preserve it for recovery + // instead of silently destroying foreign data. + let backup_identity = + directory_identity(&self.parent, &self.parent_path, &self.backup_name, true)?; + if backup_identity != self.target_identity { + return Err(ProjectError::io( + self.parent_path.join(&self.backup_name), + std::io::Error::new( + std::io::ErrorKind::InvalidData, + "bundle backup identity changed after transaction preparation", + ), + )); + } #[cfg(test)] if FAIL_BACKUP_CLEANUP.with(|fail| fail.replace(false)) { return Ok(root); @@ -907,8 +1363,6 @@ fn finish_aborted_publish_cleanup( remove_file_artifact(parent, parent_path, journal_name) } -const PUBLISH_MARKER_FILE: &str = ".opentake-publish-marker"; - #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum PublishPhase { Prepared, @@ -920,11 +1374,12 @@ enum PublishPhase { struct PublishJournal { nonce: String, had_target: bool, + target_identity: Option, phase: PublishPhase, } impl PublishJournal { - fn new(had_target: bool) -> Self { + fn new(target_identity: Option) -> Self { use std::sync::atomic::{AtomicU64, Ordering}; static NEXT: AtomicU64 = AtomicU64::new(0); let sequence = NEXT.fetch_add(1, Ordering::Relaxed); @@ -934,16 +1389,23 @@ impl PublishJournal { .as_nanos(); Self { nonce: format!("{:x}-{:x}-{sequence:x}", std::process::id(), nanos), - had_target, + had_target: target_identity.is_some(), + target_identity, phase: PublishPhase::Prepared, } } fn encode(&self) -> Vec { + let (target_volume, target_file) = match self.target_identity { + Some(identity) => (identity.volume.to_string(), identity.file.to_string()), + None => ("none".to_string(), "none".to_string()), + }; format!( - "version=1\nnonce={}\nhad_target={}\nphase={}\n", + "version=2\nnonce={}\nhad_target={}\ntarget_volume={}\ntarget_file={}\nphase={}\n", self.nonce, u8::from(self.had_target), + target_volume, + target_file, match self.phase { PublishPhase::Prepared => "prepared", PublishPhase::BackedUp => "backed_up", @@ -960,6 +1422,8 @@ impl PublishJournal { let mut version = None; let mut nonce = None; let mut had_target = None; + let mut target_volume = None; + let mut target_file = None; let mut phase = None; for line in document.lines() { let (key, value) = line.split_once('=').ok_or_else(|| { @@ -969,6 +1433,8 @@ impl PublishJournal { "version" if version.replace(value).is_none() => {} "nonce" if nonce.replace(value).is_none() => {} "had_target" if had_target.replace(value).is_none() => {} + "target_volume" if target_volume.replace(value).is_none() => {} + "target_file" if target_file.replace(value).is_none() => {} "phase" if phase.replace(value).is_none() => {} _ => { return Err(std::io::Error::new( @@ -978,7 +1444,7 @@ impl PublishJournal { } } } - if version != Some("1") { + if !matches!(version, Some("1" | "2")) { return Err(std::io::Error::new( std::io::ErrorKind::InvalidData, "unsupported journal version", @@ -1000,6 +1466,39 @@ impl PublishJournal { )) } }; + let target_identity = match version { + Some("1") if target_volume.is_none() && target_file.is_none() => None, + Some("2") => match (target_volume, target_file) { + (Some("none"), Some("none")) if !had_target => None, + (Some(volume), Some(file)) if had_target => { + let volume = volume.parse::().map_err(|_| { + std::io::Error::new( + std::io::ErrorKind::InvalidData, + "invalid journal target volume identity", + ) + })?; + let file = file.parse::().map_err(|_| { + std::io::Error::new( + std::io::ErrorKind::InvalidData, + "invalid journal target file identity", + ) + })?; + Some(ProjectRootIdentity { volume, file }) + } + _ => { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "journal target existence and identity disagree", + )) + } + }, + _ => { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "legacy journal contains unexpected identity fields", + )) + } + }; let phase = match phase { Some("prepared") => PublishPhase::Prepared, Some("backed_up") => PublishPhase::BackedUp, @@ -1014,6 +1513,7 @@ impl PublishJournal { Ok(Self { nonce, had_target, + target_identity, phase, }) } @@ -1058,6 +1558,31 @@ fn inspect_directory( } } +fn directory_identity( + parent: &Dir, + parent_path: &Path, + name: &OsStr, + artifact: bool, +) -> Result> { + if !inspect_directory(parent, parent_path, name, artifact)? { + return Ok(None); + } + let root = ProjectRoot::open_from_parent( + &parent_path.join(name), + parent + .try_clone() + .map_err(|error| ProjectError::io(parent_path, error))?, + name.to_owned(), + )?; + if !root.is_current_namespace()? { + return Err(ProjectError::io( + parent_path.join(name), + std::io::Error::other("bundle directory identity changed during inspection"), + )); + } + Ok(Some(root.stable_identity())) +} + fn remove_directory_artifact(parent: &Dir, parent_path: &Path, name: &OsStr) -> Result<()> { if !inspect_directory(parent, parent_path, name, true)? { return Ok(()); @@ -1103,28 +1628,20 @@ fn remove_directory_artifact(parent: &Dir, parent_path: &Path, name: &OsStr) -> } fn read_file_artifact(parent: &Dir, parent_path: &Path, name: &OsStr) -> Result> { + let path = parent_path.join(name); let mut options = OpenOptions::new(); options.read(true).follow(FollowSymlinks::No); + #[cfg(unix)] + options.custom_flags(libc::O_NONBLOCK); let mut file = parent .open_with(name, &options) - .map_err(|error| ProjectError::io(parent_path.join(name), error))?; - if !file - .metadata() - .map_err(|error| ProjectError::io(parent_path.join(name), error))? - .is_file() - { - return Err(ProjectError::io( - parent_path.join(name), - std::io::Error::new( - std::io::ErrorKind::InvalidData, - "bundle transaction journal is not a nofollow regular file", - ), - )); - } - let mut bytes = Vec::new(); - file.read_to_end(&mut bytes) - .map_err(|error| ProjectError::io(parent_path.join(name), error))?; - Ok(bytes) + .map_err(|error| ProjectError::io(&path, error))?; + read_bounded_regular_file( + &mut file, + &path, + TRANSACTION_JOURNAL_MAX_BYTES, + "bundle transaction journal", + ) } fn write_new_file_artifact( @@ -1806,6 +2323,198 @@ mod tests { } } + #[cfg(unix)] + #[test] + fn project_asset_open_stays_bound_to_retained_root_after_namespace_rebind() { + let tmp = TmpDir::new("asset-root-rebind"); + let selected = tmp.path().join("Selected.opentake"); + let retained = tmp.path().join("Retained-A.opentake"); + let relative = Path::new("media/nested/clip.mp4"); + fs::create_dir_all(selected.join("media/nested")).unwrap(); + fs::write(selected.join(relative), b"project-a").unwrap(); + let root = ProjectRoot::open(&selected).unwrap(); + + fs::rename(&selected, &retained).unwrap(); + fs::create_dir_all(selected.join("media/nested")).unwrap(); + fs::write(selected.join(relative), b"project-b").unwrap(); + + let mut asset = root.open_asset_file(relative).unwrap(); + let mut bytes = Vec::new(); + asset.read_to_end(&mut bytes).unwrap(); + assert_eq!(bytes, b"project-a"); + assert!(!root.is_current_namespace().unwrap()); + } + + /// On Windows cap-std opens retained directory handles without + /// FILE_SHARE_DELETE, so while the root is retained the namespace cannot be + /// mutated: the rename fails closed instead of rebinding. Assert that + /// hardening, then prove the rename works once the root is dropped. + #[cfg(target_os = "windows")] + #[test] + fn project_asset_open_blocks_namespace_rebind_while_retained() { + let tmp = TmpDir::new("asset-root-rebind"); + let selected = tmp.path().join("Selected.opentake"); + let retained = tmp.path().join("Retained-A.opentake"); + let relative = Path::new("media/nested/clip.mp4"); + fs::create_dir_all(selected.join("media/nested")).unwrap(); + fs::write(selected.join(relative), b"project-a").unwrap(); + let root = ProjectRoot::open(&selected).unwrap(); + + assert!(fs::rename(&selected, &retained).is_err()); + assert!(root.is_current_namespace().unwrap()); + + drop(root); + fs::rename(&selected, &retained).unwrap(); + assert_eq!(fs::read(retained.join(relative)).unwrap(), b"project-a"); + } + + #[test] + fn project_asset_open_rejects_non_relative_components() { + let tmp = TmpDir::new("asset-invalid-relative"); + let bundle = tmp.path().join("Selected.opentake"); + let root = ProjectRoot::create(&bundle).unwrap(); + + assert!(root.open_asset_file(Path::new("../outside.mp4")).is_err()); + assert!(root.open_asset_file(tmp.path()).is_err()); + assert!(root.open_asset_file(Path::new(".")).is_err()); + } + + #[cfg(unix)] + #[test] + fn project_asset_open_rejects_symlinked_directories_and_leaves() { + use std::os::unix::fs::symlink; + + let tmp = TmpDir::new("asset-symlinks"); + let bundle = tmp.path().join("Selected.opentake"); + let outside = tmp.path().join("outside"); + fs::create_dir_all(&bundle).unwrap(); + fs::create_dir_all(&outside).unwrap(); + fs::write(outside.join("secret.mp4"), b"secret").unwrap(); + symlink(&outside, bundle.join("linked-dir")).unwrap(); + symlink(outside.join("secret.mp4"), bundle.join("linked-file.mp4")).unwrap(); + let root = ProjectRoot::open(&bundle).unwrap(); + + assert!(root + .open_asset_file(Path::new("linked-dir/secret.mp4")) + .is_err()); + assert!(root.open_asset_file(Path::new("linked-file.mp4")).is_err()); + } + + #[test] + fn project_component_reads_enforce_each_configured_byte_limit() { + let tmp = TmpDir::new("component-read-limits"); + let bundle = tmp.path().join("Bounded.opentake"); + let root = ProjectRoot::create(&bundle).unwrap(); + let cases = [ + (crate::layout::TIMELINE_FILE, TIMELINE_COMPONENT_MAX_BYTES), + (crate::layout::MANIFEST_FILE, MANIFEST_COMPONENT_MAX_BYTES), + ( + crate::layout::GENERATION_LOG_FILE, + GENERATION_LOG_COMPONENT_MAX_BYTES, + ), + (crate::layout::THUMBNAIL_FILE, THUMBNAIL_COMPONENT_MAX_BYTES), + (PUBLISH_MARKER_FILE, PUBLISH_MARKER_MAX_BYTES), + ]; + + for (name, max_bytes) in cases { + let file = fs::File::create(bundle.join(name)).unwrap(); + file.set_len(max_bytes as u64 + 1).unwrap(); + + let error = match root.read_optional(name) { + Err(error) => error, + Ok(_) => panic!("metadata above the configured limit was accepted for {name}"), + }; + assert!(error.to_string().contains(name), "{error}"); + assert!(error.to_string().contains("byte limit"), "{error}"); + } + } + + #[test] + fn project_component_read_accepts_the_exact_marker_limit() { + let tmp = TmpDir::new("component-read-boundary"); + let bundle = tmp.path().join("Boundary.opentake"); + let root = ProjectRoot::create(&bundle).unwrap(); + let marker = vec![b'a'; PUBLISH_MARKER_MAX_BYTES]; + fs::write(bundle.join(PUBLISH_MARKER_FILE), &marker).unwrap(); + + assert_eq!( + root.read_optional(PUBLISH_MARKER_FILE).unwrap(), + Some(marker) + ); + } + + #[test] + fn bounded_stream_read_rejects_growth_after_the_metadata_boundary() { + let path = Path::new("growing-project-component"); + let mut exact = std::io::Cursor::new(b"1234".to_vec()); + assert_eq!( + read_bounded_contents(&mut exact, path, 4, 4, "project component").unwrap(), + b"1234" + ); + + let mut grown = std::io::Cursor::new(b"12345".to_vec()); + let error = read_bounded_contents(&mut grown, path, 4, 4, "project component") + .expect_err("MAX+1 must identify a file that grew after metadata inspection"); + assert!(error.to_string().contains("grew beyond"), "{error}"); + } + + #[test] + fn transaction_journal_reads_enforce_the_exact_and_over_limit_boundaries() { + let tmp = TmpDir::new("journal-read-boundary"); + let parent = Dir::open_ambient_dir(tmp.path(), ambient_authority()).unwrap(); + let name = OsStr::new(".Bounded.opentake.opentake-journal"); + let path = tmp.path().join(name); + let bytes = vec![b'a'; TRANSACTION_JOURNAL_MAX_BYTES]; + fs::write(&path, &bytes).unwrap(); + assert_eq!( + read_file_artifact(&parent, tmp.path(), name).unwrap(), + bytes + ); + + fs::File::create(&path) + .unwrap() + .set_len(TRANSACTION_JOURNAL_MAX_BYTES as u64 + 1) + .unwrap(); + let error = read_file_artifact(&parent, tmp.path(), name) + .expect_err("an oversized journal must be rejected before allocation"); + assert!(error.to_string().contains("byte limit"), "{error}"); + } + + #[cfg(unix)] + #[test] + fn transaction_journal_read_rejects_a_fifo_without_blocking() { + use std::process::Command; + use std::sync::mpsc; + use std::time::Duration; + + let tmp = TmpDir::new("journal-fifo"); + let name = OsString::from(".Blocked.opentake.opentake-journal"); + let fifo = tmp.path().join(&name); + assert!(Command::new("mkfifo") + .arg(&fifo) + .status() + .unwrap() + .success()); + let parent_path = tmp.path().to_path_buf(); + let parent = Dir::open_ambient_dir(&parent_path, ambient_authority()).unwrap(); + let (sent, received) = mpsc::channel(); + let reader = std::thread::spawn(move || { + sent.send(read_file_artifact(&parent, &parent_path, &name)) + .unwrap(); + }); + let result = match received.recv_timeout(Duration::from_millis(250)) { + Ok(result) => result, + Err(_) => { + let _writer = fs::OpenOptions::new().write(true).open(&fifo).unwrap(); + let _ = received.recv_timeout(Duration::from_secs(1)); + reader.join().unwrap(); + panic!("transaction journal FIFO open blocked instead of failing closed"); + } + }; + reader.join().unwrap(); + assert!(result.is_err(), "a FIFO must never be parsed as a journal"); + } + #[test] fn failed_atomic_replace_removes_the_capability_relative_temp_leaf() { let tmp = TmpDir::new("failed-replace-cleanup"); @@ -2117,6 +2826,38 @@ mod tests { .exists()); } + #[test] + fn publish_releases_the_transaction_lock_before_returning() { + let tmp = TmpDir::new("publish-lock-handoff"); + let target = tmp.path().join("Existing.opentake"); + fs::create_dir_all(&target).unwrap(); + fs::write(target.join("project.json"), b"initial timeline").unwrap(); + let lock_path = tmp.path().join(".Existing.opentake.opentake-lock"); + + for generation in 0..32 { + let publisher = ProjectRoot::begin_replace(&target).unwrap(); + let expected = format!("timeline generation {generation}"); + publisher + .stage() + .write_atomic("project.json", expected.as_bytes()) + .unwrap(); + let root = publisher.publish().unwrap(); + + let lock = fs::OpenOptions::new() + .read(true) + .write(true) + .open(&lock_path) + .unwrap(); + lock.try_lock() + .expect("publish must hand off its transaction lock before returning"); + lock.unlock().unwrap(); + assert_eq!( + root.read_optional("project.json").unwrap().unwrap(), + expected.as_bytes() + ); + } + } + #[test] fn postcommit_backup_cleanup_failure_returns_success_and_recovers_on_retry() { let tmp = TmpDir::new("postcommit-cleanup"); @@ -2278,7 +3019,7 @@ mod tests { let target = tmp.path().join("New.opentake"); let target_name = target.file_name().unwrap(); let journal_name = artifact_name(target_name, ".opentake-journal"); - let journal = PublishJournal::new(false); + let journal = PublishJournal::new(None); let stage_name = stage_artifact_name(target_name, &journal.nonce); let parent = Dir::open_ambient_dir(tmp.path(), ambient_authority()).unwrap(); write_new_file_artifact(&parent, tmp.path(), &journal_name, &journal.encode()).unwrap(); @@ -2299,7 +3040,7 @@ mod tests { let target = tmp.path().join("New.opentake"); let target_name = target.file_name().unwrap(); let journal_name = artifact_name(target_name, ".opentake-journal"); - let journal = PublishJournal::new(false); + let journal = PublishJournal::new(None); let parent = Dir::open_ambient_dir(tmp.path(), ambient_authority()).unwrap(); write_new_file_artifact(&parent, tmp.path(), &journal_name, &journal.encode()).unwrap(); @@ -2394,7 +3135,7 @@ mod tests { let target = tmp.path().join("New.opentake"); let target_name = target.file_name().unwrap(); let journal_name = artifact_name(target_name, ".opentake-journal"); - let journal = PublishJournal::new(false); + let journal = PublishJournal::new(None); let unknown_stage = stage_artifact_name(target_name, "dead-beef-0"); let parent = Dir::open_ambient_dir(tmp.path(), ambient_authority()).unwrap(); write_new_file_artifact(&parent, tmp.path(), &journal_name, &journal.encode()).unwrap(); @@ -2417,7 +3158,8 @@ mod tests { fs::write(target.join("project.json"), b"old timeline").unwrap(); let target_name = target.file_name().unwrap(); let journal_name = artifact_name(target_name, ".opentake-journal"); - let mut journal = PublishJournal::new(true); + let mut journal = + PublishJournal::new(Some(ProjectRoot::open(&target).unwrap().stable_identity())); journal.phase = PublishPhase::AbortedRestored; let unknown_stage = stage_artifact_name(target_name, "dead-beef-0"); let parent = Dir::open_ambient_dir(tmp.path(), ambient_authority()).unwrap(); @@ -2440,7 +3182,8 @@ mod tests { fs::create_dir_all(&target).unwrap(); fs::write(target.join("project.json"), b"old timeline").unwrap(); let target_name = target.file_name().unwrap(); - let journal = PublishJournal::new(true); + let journal = + PublishJournal::new(Some(ProjectRoot::open(&target).unwrap().stable_identity())); let legacy_stage = artifact_name(target_name, ".opentake-stage"); let legacy_path = create_recovery_stage(tmp.path(), &legacy_stage, Some(&journal.nonce)); write_recovery_journal(tmp.path(), target_name, &journal); @@ -2478,7 +3221,8 @@ mod tests { fs::create_dir_all(&target).unwrap(); fs::write(target.join("project.json"), b"old timeline").unwrap(); let target_name = target.file_name().unwrap(); - let journal = PublishJournal::new(true); + let journal = + PublishJournal::new(Some(ProjectRoot::open(&target).unwrap().stable_identity())); let exact_stage = stage_artifact_name(target_name, &journal.nonce); let legacy_stage = artifact_name(target_name, ".opentake-stage"); match case { @@ -2523,7 +3267,14 @@ mod tests { let tmp = TmpDir::new(tag); let target = tmp.path().join("Existing.opentake"); let target_name = target.file_name().unwrap(); - let mut journal = PublishJournal::new(!matches!(case, Case::NoPriorTarget)); + let mut journal = PublishJournal::new(if matches!(case, Case::NoPriorTarget) { + None + } else { + Some(ProjectRootIdentity { + volume: u64::MAX, + file: u64::MAX - 1, + }) + }); journal.phase = PublishPhase::AbortedRestored; let stage_name = stage_artifact_name(target_name, &journal.nonce); create_recovery_stage(tmp.path(), &stage_name, Some(&journal.nonce)); @@ -2806,6 +3557,72 @@ mod tests { drop(first); } + #[test] + fn publish_refuses_a_different_target_that_rebinds_after_begin() { + let tmp = TmpDir::new("publish-target-rebound-after-begin"); + let target = tmp.path().join("Existing.opentake"); + let original = tmp.path().join("Original.opentake"); + fs::create_dir_all(&target).unwrap(); + fs::write(target.join("project.json"), b"original timeline").unwrap(); + + let publisher = ProjectRoot::begin_replace(&target).unwrap(); + publisher + .stage() + .write_atomic("project.json", b"staged timeline") + .unwrap(); + fs::rename(&target, &original).unwrap(); + fs::create_dir_all(&target).unwrap(); + fs::write(target.join("project.json"), b"replacement timeline").unwrap(); + + let error = publisher + .publish() + .expect_err("a rebound target must fail closed before publication"); + + assert!(error.to_string().contains("identity"), "{error}"); + assert_eq!( + fs::read(target.join("project.json")).unwrap(), + b"replacement timeline" + ); + assert_eq!( + fs::read(original.join("project.json")).unwrap(), + b"original timeline" + ); + } + + #[test] + fn publish_never_deletes_a_foreign_bundle_rebound_at_backup_name() { + let tmp = TmpDir::new("publish-backup-rebound-before-cleanup"); + let target = tmp.path().join("Existing.opentake"); + fs::create_dir_all(&target).unwrap(); + fs::write(target.join("project.json"), b"original timeline").unwrap(); + + let mut publisher = ProjectRoot::begin_replace(&target).unwrap(); + publisher + .stage() + .write_atomic("project.json", b"new timeline") + .unwrap(); + let backup = tmp.path().join(&publisher.backup_name); + let preserved_original = tmp.path().join("preserved-original.opentake"); + + let result = publisher.publish_with_hook(|| { + fs::rename(&backup, &preserved_original)?; + fs::create_dir_all(&backup)?; + fs::write(backup.join("project.json"), b"foreign replacement")?; + Ok(()) + }); + + assert!(result.is_err(), "backup identity mismatch must fail closed"); + assert_eq!( + fs::read(backup.join("project.json")).unwrap(), + b"foreign replacement", + "a foreign object rebound at the backup name must survive" + ); + assert_eq!( + fs::read(preserved_original.join("project.json")).unwrap(), + b"original timeline" + ); + } + #[test] fn first_save_refuses_a_target_that_appears_after_staging() { let tmp = TmpDir::new("first-save-target-appeared"); diff --git a/crates/opentake-project/src/safe_fs/tests.rs b/crates/opentake-project/src/safe_fs/tests.rs index 9deb61b3..bdd69b84 100644 --- a/crates/opentake-project/src/safe_fs/tests.rs +++ b/crates/opentake-project/src/safe_fs/tests.rs @@ -109,6 +109,119 @@ fn component_accepts_safe_names_and_rejects_too_long_and_unsafe_names() { } } +#[cfg(windows)] +struct WindowsContractDir(std::path::PathBuf); + +#[cfg(windows)] +impl WindowsContractDir { + fn new() -> Self { + static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + let id = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let path = std::env::temp_dir().join(format!( + "opentake-windows-contract-{}-{id}", + std::process::id() + )); + std::fs::create_dir(&path).expect("create Windows contract fixture"); + Self(path) + } +} + +#[cfg(windows)] +impl Drop for WindowsContractDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +#[cfg(windows)] +#[test] +fn windows_contract() { + use std::io::SeekFrom; + + let fixture = WindowsContractDir::new(); + let root = capture_absolute_directory(&fixture.0, DirectoryAccess::MutateChildren) + .expect("capture local Windows fixture"); + + let stage_name = ComponentName::new("stage").unwrap(); + let quarantine_name = ComponentName::new("quarantine").unwrap(); + let nested_name = ComponentName::new("nested").unwrap(); + let leaf_name = ComponentName::new("leaf.bin").unwrap(); + let stage = create_stage_dir_new(&root, &stage_name, CreatePermissions::Inherit) + .expect("create retained stage"); + let nested = create_dir_new( + stage.directory(), + &nested_name, + CreatePermissions::Inherit, + DirectoryAccess::MutateChildren, + ) + .expect("create nested directory"); + let mut leaf = create_file_new(&nested, &leaf_name, CreatePermissions::Inherit) + .expect("create retained file"); + leaf.write_all(b"windows-capability-relative") + .expect("write retained file"); + leaf.flush().expect("flush retained file"); + leaf.sync_all().expect("sync retained file"); + leaf.seek(SeekFrom::Start(0)).expect("rewind retained file"); + let mut bytes = [0; 27]; + assert_eq!(leaf.read(&mut bytes).unwrap(), bytes.len()); + assert_eq!(&bytes, b"windows-capability-relative"); + drop(leaf); + drop(nested); + + let quarantine = quarantine_stage(stage, &root, quarantine_name.clone()) + .expect("quarantine retained stage without replacement"); + cleanup_quarantined_tree(quarantine).expect("delete quarantined tree by retained handles"); + assert!(matches!( + query_child_nofollow(&root, &quarantine_name).unwrap(), + ChildState::Absent + )); + + let published_name = ComponentName::new("published").unwrap(); + let published_stage_name = ComponentName::new("publish-stage").unwrap(); + let published_stage = + create_stage_dir_new(&root, &published_stage_name, CreatePermissions::Inherit) + .expect("create publish stage"); + publish_stage_noreplace(published_stage, &root, published_name.clone()) + .expect("publish retained stage without replacement"); + assert!(matches!( + query_child_nofollow(&root, &published_name).unwrap(), + ChildState::Present(EntryMetadata { + kind: EntryKind::Directory, + .. + }) + )); + + let collision_stage_name = ComponentName::new("collision-stage").unwrap(); + let collision_stage = + create_stage_dir_new(&root, &collision_stage_name, CreatePermissions::Inherit) + .expect("create collision stage"); + assert!(matches!( + publish_stage_noreplace(collision_stage, &root, published_name), + Err(SafeFsError::AlreadyExists { + operation: SafeFsOperation::RenameNoReplaceSameParent, + }) + )); + assert!(matches!( + query_child_nofollow(&root, &collision_stage_name).unwrap(), + ChildState::Present(EntryMetadata { + kind: EntryKind::Directory, + .. + }) + )); +} + +#[cfg(windows)] +#[test] +fn synchronous_nt_pending_is_invariant_error() { + assert!(matches!( + super::windows::synchronous_pending_contract_for_test(), + Err(SafeFsError::Os { + operation: SafeFsOperation::ReadFile, + raw: RawOsError::NtStatus { .. }, + }) + )); +} + #[cfg(any(target_os = "linux", target_os = "macos"))] mod unix_contract { use super::super::capability::CleanupCapability; diff --git a/crates/opentake-project/src/safe_fs/windows.rs b/crates/opentake-project/src/safe_fs/windows.rs index e5be68c7..a0865f60 100644 --- a/crates/opentake-project/src/safe_fs/windows.rs +++ b/crates/opentake-project/src/safe_fs/windows.rs @@ -23,22 +23,24 @@ use windows_sys::Win32::Foundation::{ STATUS_OBJECT_NAME_NOT_FOUND, STATUS_OBJECT_PATH_NOT_FOUND, STATUS_OBJECT_TYPE_MISMATCH, STATUS_PENDING, STATUS_REPARSE_POINT_ENCOUNTERED, STATUS_SHARING_VIOLATION, UNICODE_STRING, }; -use windows_sys::Win32::Security::SECURITY_DESCRIPTOR; +use windows_sys::Win32::Security::*; use windows_sys::Win32::Storage::FileSystem::{ CreateFileW, FileAttributeTagInfo, FileIdInfo, FileRemoteProtocolInfo, FileStandardInfo, GetDriveTypeW, GetFileInformationByHandleEx, GetVolumeInformationByHandleW, GetVolumeNameForVolumeMountPointW, GetVolumePathNameW, DELETE, FILE_ACCESS_RIGHTS, - FILE_ADD_FILE, FILE_ADD_SUBDIRECTORY, FILE_ATTRIBUTE_NORMAL, FILE_ATTRIBUTE_REPARSE_POINT, - FILE_ATTRIBUTE_TAG_INFO, FILE_DELETE_CHILD, FILE_FLAGS_AND_ATTRIBUTES, - FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT, FILE_ID_INFO, FILE_LIST_DIRECTORY, - FILE_READ_ATTRIBUTES, FILE_READ_DATA, FILE_REMOTE_PROTOCOL_INFO, FILE_SHARE_MODE, - FILE_SHARE_READ, FILE_SHARE_WRITE, FILE_STANDARD_INFO, FILE_TRAVERSE, FILE_WRITE_DATA, - GET_FILEEX_INFO_LEVELS, MAXIMUM_REPARSE_DATA_BUFFER_SIZE, OPEN_EXISTING, READ_CONTROL, - SYNCHRONIZE, + FILE_ADD_FILE, FILE_ADD_SUBDIRECTORY, FILE_ALL_ACCESS, FILE_ATTRIBUTE_NORMAL, + FILE_ATTRIBUTE_REPARSE_POINT, FILE_ATTRIBUTE_TAG_INFO, FILE_DELETE_CHILD, + FILE_FLAGS_AND_ATTRIBUTES, FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT, + FILE_ID_INFO, FILE_LIST_DIRECTORY, FILE_READ_ATTRIBUTES, FILE_READ_DATA, + FILE_REMOTE_PROTOCOL_INFO, FILE_SHARE_MODE, FILE_SHARE_READ, FILE_SHARE_WRITE, + FILE_STANDARD_INFO, FILE_TRAVERSE, FILE_WRITE_DATA, GET_FILEEX_INFO_LEVELS, + MAXIMUM_REPARSE_DATA_BUFFER_SIZE, OPEN_EXISTING, READ_CONTROL, SYNCHRONIZE, }; use windows_sys::Win32::System::Ioctl::FSCTL_GET_REPARSE_POINT; -use windows_sys::Win32::System::SystemServices::FILE_CS_FLAG_CASE_SENSITIVE_DIR; -use windows_sys::Win32::System::Threading::GetCurrentProcess; +use windows_sys::Win32::System::SystemServices::{ + ACCESS_ALLOWED_ACE_TYPE, FILE_CS_FLAG_CASE_SENSITIVE_DIR, SECURITY_DESCRIPTOR_REVISION, +}; +use windows_sys::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken}; use windows_sys::Win32::System::IO::IO_STATUS_BLOCK; const SHARE: FILE_SHARE_MODE = FILE_SHARE_READ | FILE_SHARE_WRITE; @@ -48,6 +50,7 @@ const DIRECTORY_BUFFER_BYTES: usize = 64 * 1024; const REPARSE_HEADER_BYTES: usize = 8; const STATUS_SUCCESS: NTSTATUS = 0; const BOOL_FALSE: BOOL = 0; +const BOOL_TRUE: BOOL = 1; const DRIVE_REMOVABLE: u32 = 2; const DRIVE_FIXED: u32 = 3; @@ -340,6 +343,16 @@ fn complete_nt( Ok(()) } +#[cfg(test)] +pub(super) fn synchronous_pending_contract_for_test() -> Result<()> { + let mut iosb = IO_STATUS_BLOCK::default(); + // Initialize the Status member even though `complete_nt` must reject the + // returned STATUS_PENDING before reading it. + iosb.Anonymous.Status = STATUS_SUCCESS; + iosb.Information = usize::MAX; + complete_nt(SafeFsOperation::ReadFile, STATUS_PENDING, &iosb) +} + #[allow(clippy::too_many_arguments)] // Mirrors the fixed NtCreateFile operation contract. fn nt_create_relative( parent: HANDLE, @@ -1319,18 +1332,496 @@ fn require_mutation(parent: &DirectoryAuthority, operation: SafeFsOperation) -> } } -fn owner_only_refusal() -> Result { - Err(SafeFsError::UnsupportedSecureFilesystem { +struct OwnerOnlySecurity { + sid: Vec, + _acl: Vec, + descriptor: Box, + ace_flags: ACE_FLAGS, +} + +impl OwnerOnlySecurity { + fn new(directory: bool) -> Result { + let operation = SafeFsOperation::VerifySecurityDescriptor; + let mut token_raw = null_mut(); + // SAFETY: the current-process pseudo-handle is valid and the output pointer is writable. + if unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token_raw) } == 0 { + return Err(last_win32(operation)); + } + let token = OwnedHandle::new(token_raw, operation)?; + let mut needed = 0u32; + // SAFETY: documented sizing call with a null output buffer. + let first = + unsafe { GetTokenInformation(token.raw(), TokenOwner, null_mut(), 0, &mut needed) }; + if first != 0 + || unsafe { GetLastError() } + != windows_sys::Win32::Foundation::ERROR_INSUFFICIENT_BUFFER + || needed < size_of::() as u32 + { + return Err(last_win32(operation)); + } + let mut token_words = vec![0usize; (needed as usize).div_ceil(size_of::())]; + // SAFETY: aligned storage is writable for exactly `needed` bytes. + if unsafe { + GetTokenInformation( + token.raw(), + TokenOwner, + token_words.as_mut_ptr().cast(), + needed, + &mut needed, + ) + } == 0 + { + return Err(last_win32(operation)); + } + // SAFETY: the successful TokenOwner query initialized a TOKEN_OWNER value. + let owner = unsafe { (*(token_words.as_ptr().cast::())).Owner }; + if owner.is_null() || unsafe { IsValidSid(owner) } == 0 { + return Err(last_win32(operation)); + } + // SAFETY: `owner` is a validated SID returned in the live token buffer. + let sid_len = usize::try_from(unsafe { GetLengthSid(owner) }).map_err(|_| { + SafeFsError::InvalidNativeBuffer { + operation, + reason: NativeBufferReason::SecurityDescriptorMalformed, + } + })?; + let mut sid = vec![0usize; sid_len.div_ceil(size_of::())]; + // SAFETY: destination capacity is at least sid_len and owner is a validated SID. + if unsafe { CopySid(sid_len as u32, sid.as_mut_ptr().cast(), owner) } == 0 { + return Err(last_win32(operation)); + } + drop(token_words); + drop(token); + + let acl_bytes = size_of::() + .checked_add(size_of::() - size_of::()) + .and_then(|value| value.checked_add(sid_len)) + .ok_or(SafeFsError::InvalidNativeBuffer { + operation, + reason: NativeBufferReason::LengthOverflow, + })?; + let acl_len = u32::try_from(acl_bytes).map_err(|_| SafeFsError::InvalidNativeBuffer { + operation, + reason: NativeBufferReason::LengthOverflow, + })?; + if acl_bytes > u16::MAX as usize { + return Err(SafeFsError::InvalidNativeBuffer { + operation, + reason: NativeBufferReason::LengthOverflow, + }); + } + let mut acl = vec![0usize; acl_bytes.div_ceil(size_of::())]; + let ace_flags = if directory { + OBJECT_INHERIT_ACE | CONTAINER_INHERIT_ACE + } else { + 0 + }; + // SAFETY: aligned ACL storage and the copied SID remain live inside Self. + if unsafe { InitializeAcl(acl.as_mut_ptr().cast(), acl_len, ACL_REVISION) } == 0 + || unsafe { + AddAccessAllowedAceEx( + acl.as_mut_ptr().cast(), + ACL_REVISION, + ace_flags, + FILE_ALL_ACCESS, + sid.as_mut_ptr().cast(), + ) + } == 0 + { + return Err(last_win32(operation)); + } + // SAFETY: SECURITY_DESCRIPTOR is a C POD initialized immediately below. + let mut descriptor = Box::::new(unsafe { std::mem::zeroed() }); + // SAFETY: the boxed descriptor has a stable address and ACL storage remains owned by Self. + if unsafe { + InitializeSecurityDescriptor( + (&mut *descriptor as *mut SECURITY_DESCRIPTOR).cast(), + SECURITY_DESCRIPTOR_REVISION, + ) + } == 0 + || unsafe { + SetSecurityDescriptorDacl( + (&mut *descriptor as *mut SECURITY_DESCRIPTOR).cast(), + BOOL_TRUE, + acl.as_mut_ptr().cast(), + BOOL_FALSE, + ) + } == 0 + || unsafe { + SetSecurityDescriptorControl( + (&mut *descriptor as *mut SECURITY_DESCRIPTOR).cast(), + SE_DACL_PROTECTED, + SE_DACL_PROTECTED, + ) + } == 0 + { + return Err(last_win32(operation)); + } + Ok(Self { + sid, + _acl: acl, + descriptor, + ace_flags, + }) + } + + fn descriptor_ptr(&self) -> *const SECURITY_DESCRIPTOR { + &*self.descriptor + } +} + +fn malformed_security() -> SafeFsError { + SafeFsError::InvalidNativeBuffer { operation: SafeFsOperation::VerifySecurityDescriptor, - reason: SecureFilesystemReason::UnsupportedTarget, - }) + reason: NativeBufferReason::SecurityDescriptorMalformed, + } } -fn require_inherited_permissions(permissions: CreatePermissions) -> Result<()> { - match permissions { - CreatePermissions::Inherit => Ok(()), - CreatePermissions::OwnerOnly => owner_only_refusal(), +fn checked_subslice( + base: usize, + length: usize, + pointer: usize, + needed: usize, +) -> Result> { + let end = base.checked_add(length).ok_or_else(malformed_security)?; + let pointer_end = pointer.checked_add(needed).ok_or_else(malformed_security)?; + if pointer < base || pointer_end > end { + return Err(malformed_security()); } + Ok(pointer - base..pointer_end - base) +} + +fn checked_sid_length(buffer: &[u8], sid: *const c_void) -> Result { + const SID_PREFIX: usize = 8; + let range = checked_subslice( + buffer.as_ptr() as usize, + buffer.len(), + sid as usize, + SID_PREFIX, + )?; + let count = usize::from(buffer[range.start + 1]); + let length = SID_PREFIX + .checked_add( + count + .checked_mul(size_of::()) + .ok_or_else(malformed_security)?, + ) + .ok_or_else(malformed_security)?; + checked_subslice(buffer.as_ptr() as usize, buffer.len(), sid as usize, length)?; + // SAFETY: the SID prefix and every declared sub-authority are inside buffer. + if unsafe { IsValidSid(sid.cast_mut()) } == 0 { + return Err(malformed_security()); + } + // SAFETY: IsValidSid accepted the fully bounded SID. + if usize::try_from(unsafe { GetLengthSid(sid.cast_mut()) }).map_err(|_| malformed_security())? + != length + { + return Err(malformed_security()); + } + Ok(length) +} + +fn verify_single_owner_ace( + descriptor_bytes: &[u8], + dacl: *mut ACL, + acl_bytes_in_use: usize, + ace: *mut c_void, + expected: &OwnerOnlySecurity, +) -> Result<()> { + let dacl_start = dacl as usize; + let dacl_range = checked_subslice( + descriptor_bytes.as_ptr() as usize, + descriptor_bytes.len(), + dacl_start, + acl_bytes_in_use.max(size_of::()), + )?; + if acl_bytes_in_use < size_of::() || dacl_range.len() != acl_bytes_in_use { + return Err(malformed_security()); + } + let ace_start = ace as usize; + checked_subslice( + dacl_start, + acl_bytes_in_use, + ace_start, + size_of::(), + )?; + // SAFETY: only the fixed ACE header bytes were bounds checked; read unaligned. + let header = + unsafe { std::ptr::read_unaligned(ace.cast::()) }; + if u32::from(header.AceType) != ACCESS_ALLOWED_ACE_TYPE { + return Err(malformed_security()); + } + let ace_size = usize::from(header.AceSize); + let sid_offset = offset_of!(ACCESS_ALLOWED_ACE, SidStart); + if ace_size < sid_offset.checked_add(8).ok_or_else(malformed_security)? { + return Err(malformed_security()); + } + checked_subslice(dacl_start, acl_bytes_in_use, ace_start, ace_size)?; + let sid_ptr = ace_start + .checked_add(sid_offset) + .ok_or_else(malformed_security)? as *const c_void; + let sid_length = checked_sid_length(descriptor_bytes, sid_ptr)?; + if sid_offset + .checked_add(sid_length) + .ok_or_else(malformed_security)? + != ace_size + { + return Err(malformed_security()); + } + // SAFETY: ACE type, size, ACL bounds and SID range were established above. + let allowed = unsafe { std::ptr::read_unaligned(ace.cast::()) }; + let expected_flags = u8::try_from(expected.ace_flags).map_err(|_| malformed_security())?; + if allowed.Header.AceFlags != expected_flags + || allowed.Mask != FILE_ALL_ACCESS + || unsafe { EqualSid(sid_ptr.cast_mut(), expected.sid.as_ptr().cast_mut().cast()) } == 0 + { + return Err(malformed_security()); + } + Ok(()) +} + +fn verify_owner_only(handle: HANDLE, expected: &OwnerOnlySecurity) -> Result<()> { + let operation = SafeFsOperation::VerifySecurityDescriptor; + let information = OWNER_SECURITY_INFORMATION | DACL_SECURITY_INFORMATION; + let mut needed = 0u32; + // SAFETY: documented sizing call against a retained handle. + unsafe { GetKernelObjectSecurity(handle, information, null_mut(), 0, &mut needed) }; + if unsafe { GetLastError() } != windows_sys::Win32::Foundation::ERROR_INSUFFICIENT_BUFFER { + return Err(last_win32(operation)); + } + let mut words = vec![0usize; (needed as usize).div_ceil(size_of::())]; + // SAFETY: aligned storage is writable for exactly needed bytes. + if unsafe { + GetKernelObjectSecurity( + handle, + information, + words.as_mut_ptr().cast(), + needed, + &mut needed, + ) + } == 0 + { + return Err(last_win32(operation)); + } + // SAFETY: the successful query initialized exactly `needed` bytes. + let descriptor_bytes = + unsafe { std::slice::from_raw_parts_mut(words.as_mut_ptr().cast::(), needed as usize) }; + if descriptor_bytes.len() < size_of::() { + return Err(malformed_security()); + } + let descriptor = descriptor_bytes.as_mut_ptr().cast::(); + let mut control = 0u16; + let mut revision = 0u32; + let mut owner = null_mut(); + let mut owner_defaulted = BOOL_FALSE; + let mut dacl = null_mut(); + let mut present = BOOL_FALSE; + let mut defaulted = BOOL_FALSE; + // SAFETY: the kernel returned a self-relative descriptor in aligned storage. + if unsafe { GetSecurityDescriptorControl(descriptor.cast(), &mut control, &mut revision) } == 0 + || unsafe { + GetSecurityDescriptorOwner(descriptor.cast(), &mut owner, &mut owner_defaulted) + } == 0 + || unsafe { + GetSecurityDescriptorDacl(descriptor.cast(), &mut present, &mut dacl, &mut defaulted) + } == 0 + || control & SE_DACL_PROTECTED == 0 + || owner_defaulted != BOOL_FALSE + || present == BOOL_FALSE + || defaulted != BOOL_FALSE + || dacl.is_null() + || owner.is_null() + { + return Err(malformed_security()); + } + #[cfg(test)] + let descriptor_fixture = take_owner_descriptor_fixture(); + #[cfg(test)] + if descriptor_fixture == Some(OwnerDescriptorFixture::NullOwner) { + owner = null_mut(); + } + #[cfg(test)] + if descriptor_fixture == Some(OwnerDescriptorFixture::InvalidOwner) { + owner = descriptor_bytes + .as_mut_ptr() + .wrapping_add(descriptor_bytes.len() - 1) + .cast(); + } + if owner.is_null() { + return Err(malformed_security()); + } + checked_sid_length(descriptor_bytes, owner.cast_const())?; + // SAFETY: owner SID is fully bounded and validated in descriptor_bytes. + if unsafe { EqualSid(owner, expected.sid.as_ptr().cast_mut().cast()) } == 0 { + return Err(malformed_security()); + } + #[cfg(test)] + if descriptor_fixture == Some(OwnerDescriptorFixture::DaclOutOfRange) { + dacl = descriptor_bytes + .as_mut_ptr() + .wrapping_add(descriptor_bytes.len() + 1) + .cast(); + } + checked_subslice( + descriptor_bytes.as_ptr() as usize, + descriptor_bytes.len(), + dacl as usize, + size_of::(), + )?; + let mut acl_info = ACL_SIZE_INFORMATION::default(); + // SAFETY: the DACL fixed header is bounded and output is writable. + if unsafe { + GetAclInformation( + dacl, + (&mut acl_info as *mut ACL_SIZE_INFORMATION).cast(), + size_of::() as u32, + AclSizeInformation, + ) + } == 0 + { + return Err(malformed_security()); + } + #[cfg(test)] + if descriptor_fixture == Some(OwnerDescriptorFixture::WrongAceCount) { + acl_info.AceCount = 2; + } + if acl_info.AceCount != 1 { + return Err(malformed_security()); + } + #[cfg(test)] + if descriptor_fixture == Some(OwnerDescriptorFixture::AclBytesOutOfRange) { + acl_info.AclBytesInUse = u32::MAX; + } + let acl_bytes_in_use = + usize::try_from(acl_info.AclBytesInUse).map_err(|_| malformed_security())?; + checked_subslice( + descriptor_bytes.as_ptr() as usize, + descriptor_bytes.len(), + dacl as usize, + acl_bytes_in_use.max(size_of::()), + )?; + let mut ace = null_mut(); + // SAFETY: the ACL and AclBytesInUse are bounded inside descriptor storage. + if unsafe { GetAce(dacl, 0, &mut ace) } == 0 || ace.is_null() { + return Err(last_win32(operation)); + } + #[cfg(test)] + if descriptor_fixture == Some(OwnerDescriptorFixture::AceOutOfRange) { + ace = descriptor_bytes + .as_mut_ptr() + .wrapping_add(descriptor_bytes.len() + 1) + .cast(); + } + #[cfg(test)] + if let Some(fixture) = descriptor_fixture { + // SAFETY: GetAce returned storage inside the already bounded single-entry + // ACL. Mutations remain inside that allocation and are consumed by the + // release bounds-first verifier before any kernel call. + unsafe { + let header = ace.cast::(); + match fixture { + OwnerDescriptorFixture::WrongAceType => (*header).AceType = 0x7f, + OwnerDescriptorFixture::UndersizedAce => { + (*header).AceSize = + size_of::() as u16; + } + OwnerDescriptorFixture::OversizedSid => { + let sid = (ace as *mut u8).add(offset_of!(ACCESS_ALLOWED_ACE, SidStart)); + *sid.add(1) = u8::MAX; + } + OwnerDescriptorFixture::InvalidSid => { + let sid = (ace as *mut u8).add(offset_of!(ACCESS_ALLOWED_ACE, SidStart)); + *sid = 0; + } + OwnerDescriptorFixture::NullOwner + | OwnerDescriptorFixture::InvalidOwner + | OwnerDescriptorFixture::DaclOutOfRange + | OwnerDescriptorFixture::AclBytesOutOfRange + | OwnerDescriptorFixture::WrongAceCount + | OwnerDescriptorFixture::AceOutOfRange => {} + } + } + } + verify_single_owner_ace(descriptor_bytes, dacl, acl_bytes_in_use, ace, expected) +} + +#[cfg(test)] +static FORCE_DACL_VERIFY_FAILURE: std::sync::atomic::AtomicBool = + std::sync::atomic::AtomicBool::new(false); + +#[cfg(test)] +fn force_next_owner_verification_failure() { + FORCE_DACL_VERIFY_FAILURE.store(true, std::sync::atomic::Ordering::SeqCst); +} + +#[cfg(test)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum OwnerDescriptorFixture { + WrongAceType, + UndersizedAce, + OversizedSid, + InvalidSid, + NullOwner, + InvalidOwner, + DaclOutOfRange, + AclBytesOutOfRange, + WrongAceCount, + AceOutOfRange, +} + +#[cfg(test)] +static OWNER_DESCRIPTOR_FIXTURE: std::sync::OnceLock< + std::sync::Mutex>, +> = std::sync::OnceLock::new(); + +#[cfg(test)] +struct OwnerDescriptorFixtureGuard; + +#[cfg(test)] +impl Drop for OwnerDescriptorFixtureGuard { + fn drop(&mut self) { + *OWNER_DESCRIPTOR_FIXTURE + .get_or_init(Default::default) + .lock() + .expect("owner descriptor fixture mutex poisoned") = None; + } +} + +#[cfg(test)] +fn install_owner_descriptor_fixture( + fixture: OwnerDescriptorFixture, +) -> OwnerDescriptorFixtureGuard { + let mut slot = OWNER_DESCRIPTOR_FIXTURE + .get_or_init(Default::default) + .lock() + .expect("owner descriptor fixture mutex poisoned"); + assert!( + slot.is_none(), + "owner descriptor tests require --test-threads=1" + ); + *slot = Some(fixture); + OwnerDescriptorFixtureGuard +} + +#[cfg(test)] +fn take_owner_descriptor_fixture() -> Option { + OWNER_DESCRIPTOR_FIXTURE + .get_or_init(Default::default) + .lock() + .expect("owner descriptor fixture mutex poisoned") + .take() +} + +fn verify_created_owner_only(handle: HANDLE, expected: &OwnerOnlySecurity) -> Result<()> { + inject_windows_create_failure( + WindowsCreateFailurePoint::SecurityVerification, + SafeFsOperation::VerifySecurityDescriptor, + )?; + #[cfg(test)] + if FORCE_DACL_VERIFY_FAILURE.swap(false, std::sync::atomic::Ordering::SeqCst) { + return Err(malformed_security()); + } + verify_owner_only(handle, expected) } #[allow(clippy::arc_with_non_send_sync)] // Arc retains the HANDLE-bearing parent chain; it is not shared publicly. @@ -1445,7 +1936,13 @@ fn create_directory_contract( SafeFsOperation::CreateDirectory }; require_mutation(parent, operation)?; - require_inherited_permissions(permissions)?; + let security = match permissions { + CreatePermissions::OwnerOnly => Some(OwnerOnlySecurity::new(true)?), + CreatePermissions::Inherit => None, + }; + let security_descriptor = security + .as_ref() + .map_or(null(), OwnerOnlySecurity::descriptor_ptr); let handle = nt_create_relative( parent.native.node.handle.raw(), name, @@ -1454,7 +1951,7 @@ fn create_directory_contract( contract.disposition, contract.options, contract.attributes, - null(), + security_descriptor, operation, )?; let validated = @@ -1475,6 +1972,9 @@ fn create_directory_contract( kind: opened.kind, }); } + if let Some(expected) = &security { + verify_created_owner_only(handle.raw(), expected)?; + } inject_windows_create_failure(WindowsCreateFailurePoint::CaseProof, operation)?; let case_mode = query_case_mode(handle.raw())?; inject_windows_create_failure(WindowsCreateFailurePoint::SnapshotAssembly, operation)?; @@ -1638,20 +2138,6 @@ fn collect_revalidation_proof(directory: &DirectoryAuthority) -> Result(operation: SafeFsOperation) -> Result { - Err(SafeFsError::UnsupportedSecureFilesystem { - operation, - reason: SecureFilesystemReason::UnsupportedTarget, - }) -} - -fn mutation_refusal(operation: SafeFsOperation) -> Result { - Err(SafeFsError::UnsupportedAtomicPublish { - operation, - reason: AtomicPublishReason::PrimitiveUnavailable, - }) -} - #[allow(clippy::arc_with_non_send_sync)] // Arc retains the HANDLE-bearing namespace chain; it is not shared publicly. pub(super) fn capture_absolute_directory( path: &Path, @@ -1906,11 +2392,10 @@ pub(super) fn create_stage_dir_new( name: &ComponentName, permissions: CreatePermissions, ) -> Result { - require_inherited_permissions(permissions)?; let directory = create_directory_contract( parent, name, - CreatePermissions::Inherit, + permissions, DirectoryAccess::Stage, contract_for_operation(OpenOperation::CreateStage), )?; @@ -1939,7 +2424,13 @@ pub(super) fn create_file_new( permissions: CreatePermissions, ) -> Result { require_mutation(parent, SafeFsOperation::CreateFile)?; - require_inherited_permissions(permissions)?; + let security = match permissions { + CreatePermissions::OwnerOnly => Some(OwnerOnlySecurity::new(false)?), + CreatePermissions::Inherit => None, + }; + let security_descriptor = security + .as_ref() + .map_or(null(), OwnerOnlySecurity::descriptor_ptr); let contract = contract_for_operation(OpenOperation::CreateFile); let handle = nt_create_relative( parent.native.node.handle.raw(), @@ -1949,7 +2440,7 @@ pub(super) fn create_file_new( contract.disposition, contract.options, contract.attributes, - null(), + security_descriptor, SafeFsOperation::CreateFile, )?; let validated = @@ -1980,6 +2471,9 @@ pub(super) fn create_file_new( kind: opened.kind, }); } + if let Some(expected) = &security { + verify_created_owner_only(handle.raw(), expected)?; + } Ok(opened) })(); let opened = match validated { @@ -2049,35 +2543,364 @@ pub(super) fn metadata_from_file(file: &NativeFile) -> Result { ) } +fn rename_retained_noreplace( + native: &NativeDirectory, + parent: &DirectoryAuthority, + target: &ComponentName, +) -> Result<()> { + require_mutation(parent, SafeFsOperation::RenameNoReplaceSameParent)?; + if matches!( + query_child_nofollow(parent, target)?, + ChildState::Present(_) + ) { + return Err(SafeFsError::AlreadyExists { + operation: SafeFsOperation::RenameNoReplaceSameParent, + }); + } + if !native.delete_right { + return Err(raw_nt( + SafeFsOperation::RenameNoReplaceSameParent, + STATUS_ACCESS_DENIED, + )); + } + let buffer = RenameInformationBuffer::new(parent.native.node.handle.raw(), target)?; + let mut iosb = IO_STATUS_BLOCK::default(); + // SAFETY: the retained DELETE source and parent handles plus the aligned, + // initialized variable-length buffer remain live for this synchronous call. + let status = unsafe { + NtSetInformationFile( + native.node.handle.raw(), + &mut iosb, + buffer.as_ptr(), + buffer.used, + FileRenameInformation, + ) + }; + if status < STATUS_SUCCESS { + return Err(map_rename_failure( + status, + true, + native.delete_right, + query_child_nofollow(parent, target), + )); + } + complete_nt(SafeFsOperation::RenameNoReplaceSameParent, status, &iosb) +} + +fn verify_same_parent(expected: &DirectoryAuthority, actual: &DirectoryAuthority) -> Result<()> { + if expected.opened.identity == actual.opened.identity && expected.snapshot == actual.snapshot { + Ok(()) + } else { + Err(SafeFsError::NamespaceChanged { + operation: SafeFsOperation::RenameNoReplaceSameParent, + }) + } +} + pub(super) fn quarantine_stage( - _: StageCapability, - _: &DirectoryAuthority, - _: ComponentName, + stage: StageCapability, + parent: &DirectoryAuthority, + quarantine_name: ComponentName, ) -> Result { - mutation_refusal(SafeFsOperation::QuarantineNoReplace) + let StageCapability { + parent: owned_parent, + directory, + original_name, + opened, + } = stage; + verify_same_parent(&owned_parent, parent)?; + revalidate_namespace(parent)?; + rename_retained_noreplace(&directory.native, parent, &quarantine_name)?; + Ok(QuarantinedCapability { + parent: owned_parent, + directory, + original_name, + quarantine_name, + opened, + }) } pub(super) fn publish_stage_noreplace( - _: StageCapability, - _: &DirectoryAuthority, - _: ComponentName, + stage: StageCapability, + parent: &DirectoryAuthority, + destination: ComponentName, ) -> Result<()> { - mutation_refusal(SafeFsOperation::PublishNoReplace) + let StageCapability { + parent: owned_parent, + directory, + opened, + .. + } = stage; + verify_same_parent(&owned_parent, parent)?; + revalidate_namespace(parent)?; + if directory.opened.identity != opened.identity { + return Err(SafeFsError::IdentityChanged { + operation: SafeFsOperation::RenameNoReplaceSameParent, + expected: opened.identity, + actual: directory.opened.identity.clone(), + }); + } + rename_retained_noreplace(&directory.native, parent, &destination)?; + drop(directory); + Ok(()) } +#[allow(clippy::arc_with_non_send_sync)] // Arc retains the HANDLE parent chain; capabilities never cross threads. pub(super) fn open_cleanup_child_nofollow( - _: &QuarantinedCapability, - _: &ComponentName, + quarantined: &QuarantinedCapability, + name: &ComponentName, ) -> Result { - filesystem_refusal(SafeFsOperation::OpenCleanupEntry) + let parent = &quarantined.directory; + let metadata = match query_child_nofollow(parent, name)? { + ChildState::Absent => { + return Err(SafeFsError::NotFound { + operation: SafeFsOperation::OpenCleanupEntry, + }) + } + ChildState::Present(metadata) => metadata, + }; + let contract = contract_for_operation(match metadata.kind { + EntryKind::Directory => OpenOperation::CleanupDir, + EntryKind::SymlinkOrReparse => OpenOperation::CleanupReparse, + _ => OpenOperation::CleanupFile, + }); + let handle = nt_create_relative( + parent.native.node.handle.raw(), + name, + parent.case_mode, + contract.desired, + contract.disposition, + contract.options, + contract.attributes, + null(), + SafeFsOperation::OpenCleanupEntry, + )?; + let filesystem = + parent + .opened + .filesystem + .as_ref() + .ok_or(SafeFsError::UnsupportedSecureFilesystem { + operation: SafeFsOperation::ProbeFilesystem, + reason: SecureFilesystemReason::FilesystemProbeUnavailable, + })?; + let opened = query_entry_metadata(handle.raw(), filesystem, SafeFsOperation::QueryMetadata)?; + if opened.identity != metadata.identity { + return Err(SafeFsError::IdentityChanged { + operation: SafeFsOperation::QueryMetadata, + expected: metadata.identity, + actual: opened.identity, + }); + } + if opened.kind != metadata.kind { + return Err(SafeFsError::UnsupportedEntryType { + operation: SafeFsOperation::OpenCleanupEntry, + kind: opened.kind, + }); + } + if opened.kind == EntryKind::Directory { + let duplicated_parent = duplicate_directory(parent)?; + let child_case = query_case_mode(handle.raw())?; + let child_snapshot = append_snapshot(&parent.snapshot, name.clone(), &opened, child_case)?; + let node = Arc::new(DirectoryNode { + handle, + parent: Some(Arc::clone(&parent.native.node)), + name: Some(name.clone()), + case_mode: child_case, + metadata: opened.clone(), + volume: parent.native.node.volume.clone(), + }); + let directory = DirectoryAuthority { + anchor: Arc::clone(&parent.anchor), + native: NativeDirectory { + node, + access: DirectoryAccess::MutateChildren, + delete_right: true, + }, + access: DirectoryAccess::MutateChildren, + opened: opened.clone(), + case_mode: child_case, + snapshot: child_snapshot, + }; + Ok(CleanupCapability::Directory(Box::new( + QuarantinedCapability { + parent: duplicated_parent, + directory, + original_name: name.clone(), + quarantine_name: name.clone(), + opened, + }, + ))) + } else { + Ok(CleanupCapability::Entry(Box::new(CleanupEntry { + parent: duplicate_directory(parent)?, + native: NativeFile { + handle, + opened: opened.clone(), + access: FileAccess::Read, + delete_right: true, + }, + name: name.clone(), + opened, + access: CleanupAccess::Delete, + }))) + } +} + +#[cfg(test)] +type BeforeRetainedDeleteHook = + Arc Result<()> + Send + Sync>; + +#[cfg(test)] +static BEFORE_RETAINED_DELETE_HOOK: std::sync::OnceLock< + std::sync::Mutex>, +> = std::sync::OnceLock::new(); + +#[cfg(test)] +struct BeforeRetainedDeleteHookGuard; + +#[cfg(test)] +impl Drop for BeforeRetainedDeleteHookGuard { + fn drop(&mut self) { + *BEFORE_RETAINED_DELETE_HOOK + .get_or_init(Default::default) + .lock() + .expect("retained-delete hook mutex poisoned") = None; + } +} + +#[cfg(test)] +fn install_before_retained_delete_hook( + hook: BeforeRetainedDeleteHook, +) -> BeforeRetainedDeleteHookGuard { + let mut slot = BEFORE_RETAINED_DELETE_HOOK + .get_or_init(Default::default) + .lock() + .expect("retained-delete hook mutex poisoned"); + assert!( + slot.is_none(), + "retained-delete tests require --test-threads=1" + ); + *slot = Some(hook); + BeforeRetainedDeleteHookGuard +} + +fn run_before_retained_delete_hook( + handle: HANDLE, + parent: &DirectoryAuthority, + name: &ComponentName, +) -> Result<()> { + #[cfg(test)] + { + let hook = BEFORE_RETAINED_DELETE_HOOK + .get_or_init(Default::default) + .lock() + .expect("retained-delete hook mutex poisoned") + .clone(); + if let Some(hook) = hook { + return hook(handle, parent, name); + } + } + let _ = (handle, parent, name); + Ok(()) +} + +fn dispose_retained( + mut native: NativeFile, + parent: &DirectoryAuthority, + name: &ComponentName, + expected_kind: EntryKind, + operation: SafeFsOperation, +) -> Result<()> { + if !native.delete_right { + return Err(SafeFsError::Os { + operation, + raw: RawOsError::Win32(windows_sys::Win32::Foundation::ERROR_ACCESS_DENIED), + }); + } + if native.opened.kind != expected_kind { + return Err(SafeFsError::UnsupportedEntryType { + operation, + kind: native.opened.kind, + }); + } + run_before_retained_delete_hook(native.handle.raw(), parent, name)?; + mark_delete_handle(native.handle.raw(), operation)?; + native.delete_right = false; + drop(native); + Ok(()) } -pub(super) fn delete_quarantined_entry(_: CleanupCapability) -> Result<()> { - filesystem_refusal(SafeFsOperation::DeleteQuarantinedEntry) +pub(super) fn delete_quarantined_entry(cleanup: CleanupCapability) -> Result<()> { + match cleanup { + CleanupCapability::Entry(entry) => { + let CleanupEntry { + parent, + native, + name, + opened, + access: CleanupAccess::Delete, + } = *entry; + if native.opened.identity != opened.identity { + return Err(SafeFsError::IdentityChanged { + operation: SafeFsOperation::DeleteQuarantinedEntry, + expected: opened.identity, + actual: native.opened.identity, + }); + } + dispose_retained( + native, + &parent, + &name, + opened.kind, + SafeFsOperation::DeleteQuarantinedEntry, + ) + } + CleanupCapability::Directory(_) => Err(SafeFsError::UnsupportedEntryType { + operation: SafeFsOperation::DeleteQuarantinedEntry, + kind: EntryKind::Directory, + }), + } } -pub(super) fn delete_quarantined_empty_directory(_: QuarantinedCapability) -> Result<()> { - filesystem_refusal(SafeFsOperation::DeleteQuarantinedEmptyDirectory) +pub(super) fn delete_quarantined_empty_directory(quarantined: QuarantinedCapability) -> Result<()> { + let QuarantinedCapability { + parent, + directory, + quarantine_name, + opened, + .. + } = quarantined; + if directory.opened.identity != opened.identity || !directory.native.delete_right { + return Err(SafeFsError::IdentityChanged { + operation: SafeFsOperation::DeleteQuarantinedEmptyDirectory, + expected: opened.identity, + actual: directory.opened.identity, + }); + } + let native = NativeFile { + handle: Arc::try_unwrap(directory.native.node) + .map_err(|node| { + SafeFsError::io( + SafeFsOperation::DeleteQuarantinedEmptyDirectory, + io::Error::other(format!( + "directory handle still shared: {}", + Arc::strong_count(&node) + )), + ) + })? + .handle, + opened: directory.opened, + access: FileAccess::Read, + delete_right: true, + }; + dispose_retained( + native, + &parent, + &quarantine_name, + EntryKind::Directory, + SafeFsOperation::DeleteQuarantinedEmptyDirectory, + ) } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -2088,7 +2911,6 @@ enum WindowsCreateFailurePoint { CaseProof, SnapshotAssembly, ParentDuplicate, - #[allow(dead_code)] // Task 7A test-only removes this when it constructs the variant. SecurityVerification, } @@ -2174,6 +2996,7 @@ mod tests { use super::*; use std::fs; use std::path::PathBuf; + use std::process::Command; use std::sync::atomic::{AtomicU64, Ordering}; struct TestDir(PathBuf); @@ -2210,6 +3033,22 @@ mod tests { .expect("capture fixture root") } + fn present_for_test() -> ChildState { + ChildState::Present(EntryMetadata { + identity: StableIdentity::Windows { + volume_serial: 7, + file_id: [3; 16], + }, + kind: EntryKind::RegularFile, + len: 0, + link_count: 1, + filesystem: Some(LocalFilesystemSnapshot::Windows { + volume_guid: vec![1], + serial: 7, + }), + }) + } + #[test] fn remote_protocol_query_buffer_initializes_required_header() { let buffer = remote_protocol_query_buffer(SafeFsOperation::ProbeVolume).unwrap(); @@ -2268,6 +3107,364 @@ mod tests { assert_eq!(enumerate(&b).unwrap(), vec![name("data")]); } + #[test] + fn owner_only_file_directory_stage_succeed_and_rollback() { + let temp = TestDir::new("owner-only"); + let authority = root(&temp); + + let file = create_file_new(&authority, &name("file"), CreatePermissions::OwnerOnly) + .expect("owner-only file creation succeeds"); + drop(file); + let directory = create_dir_new( + &authority, + &name("directory"), + CreatePermissions::OwnerOnly, + DirectoryAccess::MutateChildren, + ) + .expect("owner-only directory creation succeeds"); + drop(directory); + let stage = create_stage_dir_new(&authority, &name("stage"), CreatePermissions::OwnerOnly) + .expect("owner-only stage creation succeeds"); + drop(stage); + for value in ["file", "directory", "stage"] { + assert!(matches!( + query_child_nofollow(&authority, &name(value)).unwrap(), + ChildState::Present(_) + )); + } + + force_next_owner_verification_failure(); + assert!(matches!( + create_file_new( + &authority, + &name("rollback-file"), + CreatePermissions::OwnerOnly + ), + Err(SafeFsError::InvalidNativeBuffer { + operation: SafeFsOperation::VerifySecurityDescriptor, + reason: NativeBufferReason::SecurityDescriptorMalformed, + }) + )); + force_next_owner_verification_failure(); + assert!(matches!( + create_dir_new( + &authority, + &name("rollback-directory"), + CreatePermissions::OwnerOnly, + DirectoryAccess::MutateChildren, + ), + Err(SafeFsError::InvalidNativeBuffer { + operation: SafeFsOperation::VerifySecurityDescriptor, + reason: NativeBufferReason::SecurityDescriptorMalformed, + }) + )); + force_next_owner_verification_failure(); + assert!(matches!( + create_stage_dir_new( + &authority, + &name("rollback-stage"), + CreatePermissions::OwnerOnly, + ), + Err(SafeFsError::InvalidNativeBuffer { + operation: SafeFsOperation::VerifySecurityDescriptor, + reason: NativeBufferReason::SecurityDescriptorMalformed, + }) + )); + for value in ["rollback-file", "rollback-directory", "rollback-stage"] { + assert!(matches!( + query_child_nofollow(&authority, &name(value)).unwrap(), + ChildState::Absent + )); + } + } + + #[test] + fn windows_post_create_security_failure_rolls_back_same_handle() { + let temp = TestDir::new("security-rollback"); + let authority = root(&temp); + let _failure = + install_windows_create_failure(WindowsCreateFailurePoint::SecurityVerification); + assert!(matches!( + create_file_new(&authority, &name("leaf"), CreatePermissions::OwnerOnly), + Err(SafeFsError::Io { + operation: SafeFsOperation::VerifySecurityDescriptor, + .. + }) + )); + assert!(matches!( + query_child_nofollow(&authority, &name("leaf")).unwrap(), + ChildState::Absent + )); + } + + fn assert_owner_descriptor_fixture_rejected(fixture: OwnerDescriptorFixture, leaf: &str) { + let temp = TestDir::new(leaf); + let authority = root(&temp); + let _fixture = install_owner_descriptor_fixture(fixture); + assert!(matches!( + create_file_new(&authority, &name(leaf), CreatePermissions::OwnerOnly), + Err(SafeFsError::InvalidNativeBuffer { + operation: SafeFsOperation::VerifySecurityDescriptor, + reason: NativeBufferReason::SecurityDescriptorMalformed, + }) + )); + assert!(matches!( + query_child_nofollow(&authority, &name(leaf)).unwrap(), + ChildState::Absent + )); + } + + #[test] + fn owner_only_dacl_rejects_wrong_ace_type() { + assert_owner_descriptor_fixture_rejected( + OwnerDescriptorFixture::WrongAceType, + "wrong-ace-type", + ); + } + + #[test] + fn owner_only_dacl_rejects_undersized_ace_and_out_of_range_acl_fields() { + for (fixture, leaf) in [ + (OwnerDescriptorFixture::UndersizedAce, "undersized-ace"), + (OwnerDescriptorFixture::DaclOutOfRange, "dacl-out-of-range"), + ( + OwnerDescriptorFixture::AclBytesOutOfRange, + "acl-bytes-out-of-range", + ), + (OwnerDescriptorFixture::WrongAceCount, "wrong-ace-count"), + (OwnerDescriptorFixture::AceOutOfRange, "ace-out-of-range"), + ] { + assert_owner_descriptor_fixture_rejected(fixture, leaf); + } + } + + #[test] + fn owner_only_dacl_rejects_oversized_sid() { + assert_owner_descriptor_fixture_rejected( + OwnerDescriptorFixture::OversizedSid, + "oversized-sid", + ); + } + + #[test] + fn owner_only_dacl_rejects_invalid_sid() { + assert_owner_descriptor_fixture_rejected(OwnerDescriptorFixture::InvalidSid, "invalid-sid"); + } + + #[test] + fn owner_only_dacl_rejects_null_or_invalid_owner() { + assert_owner_descriptor_fixture_rejected(OwnerDescriptorFixture::NullOwner, "null-owner"); + assert_owner_descriptor_fixture_rejected( + OwnerDescriptorFixture::InvalidOwner, + "invalid-owner", + ); + } + + #[test] + fn quarantine_and_publish_success_do_not_self_conflict() { + let quarantine_temp = TestDir::new("quarantine-success"); + let authority = root(&quarantine_temp); + let stage = + create_stage_dir_new(&authority, &name("stage"), CreatePermissions::Inherit).unwrap(); + let quarantined = quarantine_stage(stage, &authority, name("quarantine")) + .expect("retained quarantine rename succeeds"); + drop(quarantined); + assert!(!quarantine_temp.path().join("stage").exists()); + assert!(quarantine_temp.path().join("quarantine").is_dir()); + + let publish_temp = TestDir::new("publish-success"); + let authority = root(&publish_temp); + let stage = + create_stage_dir_new(&authority, &name("stage"), CreatePermissions::Inherit).unwrap(); + publish_stage_noreplace(stage, &authority, name("destination")) + .expect("retained publish rename succeeds"); + assert!(!publish_temp.path().join("stage").exists()); + assert!(publish_temp.path().join("destination").is_dir()); + } + + #[test] + fn rename_never_replaces_any_target_kind() { + assert!(matches!( + map_rename_failure(STATUS_ACCESS_DENIED, true, true, Ok(present_for_test())), + SafeFsError::AlreadyExists { .. } + )); + assert!(matches!( + map_rename_failure(STATUS_ACCESS_DENIED, true, true, Ok(ChildState::Absent)), + SafeFsError::Os { + raw: RawOsError::NtStatus { + status: STATUS_ACCESS_DENIED, + .. + }, + .. + } + )); + for kind in ["file", "empty-dir", "nonempty-dir", "reparse"] { + let temp = TestDir::new(kind); + let target = temp.path().join("target"); + let external = temp.path().join("external"); + match kind { + "file" => fs::write(&target, b"keep-file").unwrap(), + "empty-dir" => fs::create_dir(&target).unwrap(), + "nonempty-dir" => { + fs::create_dir(&target).unwrap(); + fs::write(target.join("keep"), b"tree").unwrap(); + } + "reparse" => { + fs::create_dir(&external).unwrap(); + fs::write(external.join("keep"), b"outside").unwrap(); + let output = Command::new("cmd") + .args(["/C", "mklink", "/J"]) + .arg(&target) + .arg(&external) + .output() + .unwrap(); + assert!( + output.status.success(), + "mklink failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + _ => unreachable!(), + } + let authority = root(&temp); + let before = match query_child_nofollow(&authority, &name("target")).unwrap() { + ChildState::Present(value) => value, + ChildState::Absent => panic!("collision target absent"), + }; + let stage = + create_stage_dir_new(&authority, &name("stage"), CreatePermissions::Inherit) + .unwrap(); + assert!(matches!( + publish_stage_noreplace(stage, &authority, name("target")), + Err(SafeFsError::AlreadyExists { .. }) + )); + let after = match query_child_nofollow(&authority, &name("target")).unwrap() { + ChildState::Present(value) => value, + ChildState::Absent => panic!("collision target removed"), + }; + assert_eq!(after.identity, before.identity); + match kind { + "file" => assert_eq!(fs::read(&target).unwrap(), b"keep-file"), + "nonempty-dir" => assert_eq!(fs::read(target.join("keep")).unwrap(), b"tree"), + "reparse" => assert_eq!(fs::read(external.join("keep")).unwrap(), b"outside"), + _ => assert!(target.is_dir()), + } + } + } + + #[test] + fn cleanup_quarantined_tree_deletes_nested_reparse_without_traversal() { + let temp = TestDir::new("cleanup-tree"); + let external = temp.path().join("external"); + fs::create_dir(&external).unwrap(); + fs::write(external.join("keep"), b"outside-bytes").unwrap(); + let authority = root(&temp); + let stage = + create_stage_dir_new(&authority, &name("stage"), CreatePermissions::Inherit).unwrap(); + let nested = create_dir_new( + stage.directory(), + &name("nested"), + CreatePermissions::Inherit, + DirectoryAccess::MutateChildren, + ) + .unwrap(); + let mut file = create_file_new(&nested, &name("data"), CreatePermissions::Inherit).unwrap(); + file.write_all(b"inside").unwrap(); + drop(file); + drop(nested); + let output = Command::new("cmd") + .args(["/C", "mklink", "/J"]) + .arg(temp.path().join("stage").join("nested").join("link")) + .arg(&external) + .output() + .unwrap(); + assert!( + output.status.success(), + "mklink failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + let quarantine = quarantine_stage(stage, &authority, name("quarantine")).unwrap(); + super::super::cleanup_quarantined_tree(quarantine) + .expect("common recursive cleanup succeeds"); + assert!(matches!( + query_child_nofollow(&authority, &name("quarantine")).unwrap(), + ChildState::Absent + )); + assert_eq!(fs::read(external.join("keep")).unwrap(), b"outside-bytes"); + } + + #[test] + fn retained_delete_is_safe_when_real_name_rebinds_or_is_blocked() { + use std::sync::atomic::{AtomicBool, Ordering}; + + let temp = TestDir::new("delete-rebound"); + let authority = root(&temp); + let stage = + create_stage_dir_new(&authority, &name("stage"), CreatePermissions::Inherit).unwrap(); + let mut file = + create_file_new(stage.directory(), &name("leaf"), CreatePermissions::Inherit).unwrap(); + file.write_all(b"original").unwrap(); + drop(file); + let quarantine = quarantine_stage(stage, &authority, name("quarantine")).unwrap(); + let cleanup = open_cleanup_child_nofollow(&quarantine, &name("leaf")).unwrap(); + let expected_source = match &cleanup { + CleanupCapability::Entry(entry) => entry.native.handle.raw() as usize, + CleanupCapability::Directory(_) => panic!("leaf opened as a directory"), + }; + let quarantine_path = temp.path().join("quarantine"); + let rebound = Arc::new(AtomicBool::new(false)); + let hook_rebound = Arc::clone(&rebound); + let _guard = install_before_retained_delete_hook(Arc::new( + move |source, parent, _old_name| { + if source as usize != expected_source { + return Ok(()); + } + let buffer = RenameInformationBuffer::new( + parent.native.node.handle.raw(), + &name("moved-original"), + )?; + let mut iosb = IO_STATUS_BLOCK::default(); + // SAFETY: source is the retained DELETE handle and all inputs + // remain live for this synchronous test-only rename. + let status = unsafe { + NtSetInformationFile( + source, + &mut iosb, + buffer.as_ptr(), + buffer.used, + FileRenameInformation, + ) + }; + if status == STATUS_SUCCESS { + complete_nt(SafeFsOperation::RenameNoReplaceSameParent, status, &iosb)?; + fs::write(quarantine_path.join("leaf"), b"replacement") + .map_err(|error| SafeFsError::io(SafeFsOperation::CreateFile, error))?; + hook_rebound.store(true, Ordering::SeqCst); + } else { + assert_eq!( + status, STATUS_SHARING_VIOLATION, + "Windows may reject the simulated same-handle rename, but no other failure is expected" + ); + } + Ok(()) + }, + )); + delete_quarantined_entry(cleanup).unwrap(); + if rebound.load(Ordering::SeqCst) { + assert_eq!( + fs::read(temp.path().join("quarantine").join("leaf")).unwrap(), + b"replacement" + ); + } else { + assert!(!temp.path().join("quarantine").join("leaf").exists()); + } + assert!(!temp + .path() + .join("quarantine") + .join("moved-original") + .exists()); + } + fn assert_file_create_failure_rolls_back(point: WindowsCreateFailurePoint, label: &str) { let temp = TestDir::new(label); let authority = root(&temp); diff --git a/crates/opentake-project/tests/archive.rs b/crates/opentake-project/tests/archive.rs index 7dfb2be6..835524cc 100644 --- a/crates/opentake-project/tests/archive.rs +++ b/crates/opentake-project/tests/archive.rs @@ -23,6 +23,8 @@ fn entry(id: &str, name: &str, kind: ClipType, source: MediaSource) -> MediaMani source_height: None, source_fps: None, has_audio: None, + color: None, + proxy: None, folder_id: None, cached_remote_url: None, cached_remote_url_expires_at: None, diff --git a/crates/opentake-project/tests/compound_roundtrip.rs b/crates/opentake-project/tests/compound_roundtrip.rs new file mode 100644 index 00000000..75c0c849 --- /dev/null +++ b/crates/opentake-project/tests/compound_roundtrip.rs @@ -0,0 +1,116 @@ +//! Persistence and compatibility boundaries for editable compound clips. + +mod common; + +use opentake_domain::{Clip, ClipType, NestedSequence, Timeline, Track}; +use opentake_project::{Project, ProjectError}; + +use common::{write_file, TempDir}; + +fn nested_timeline() -> Timeline { + let mut child = Timeline::new(); + let mut child_track = Track::new("child-track", ClipType::Video); + child_track + .clips + .push(Clip::new("child-clip", "asset-a", 2, 12)); + child.tracks.push(child_track); + + let mut root = Timeline::new(); + root.nested_sequences + .push(NestedSequence::new("sequence-a", "Scene A", child)); + let mut root_track = Track::new("root-track", ClipType::Video); + root_track + .clips + .push(Clip::new_nested("compound-a", "sequence-a", 10, 20)); + root.tracks.push(root_track); + root +} + +#[test] +fn compound_clip_roundtrips_nested_timeline() { + let temp = TempDir::new("compound-roundtrip"); + let bundle = temp.child("Compound.opentake"); + let mut project = Project::new(&bundle); + project.timeline = nested_timeline(); + + project.save().expect("save nested timeline"); + let reopened = Project::open(&bundle).expect("open nested timeline"); + + assert_eq!(reopened.timeline, project.timeline); + assert_eq!( + reopened.timeline.tracks[0].clips[0] + .nested_sequence_id + .as_deref(), + Some("sequence-a") + ); + reopened + .timeline + .validate_nested_sequences() + .expect("reopened graph stays valid"); +} + +#[test] +fn nested_future_fields_make_project_read_only() { + let temp = TempDir::new("compound-future-field"); + let bundle = temp.child("Future.opentake"); + std::fs::create_dir_all(&bundle).unwrap(); + write_file( + &bundle.join("project.json"), + br#"{ + "nestedSequences": [{ + "id": "sequence-a", + "name": "A", + "timeline": { + "tracks": [{ + "id": "child-track", + "type": "video", + "futureTrackFlag": true, + "clips": [] + }] + } + }], + "tracks": [] + }"#, + ); + + let project = Project::open(&bundle).expect("unknown field opens read-only"); + assert!(project.compatibility().is_read_only()); + assert!(project.compatibility().blockers().iter().any(|blocker| { + blocker == "project.json:nestedSequences.0.timeline.tracks.0.futureTrackFlag" + })); +} + +#[test] +fn recursive_nested_graph_fails_open_and_save() { + let temp = TempDir::new("compound-cycle"); + let bundle = temp.child("Cycle.opentake"); + let mut a = Timeline::new(); + let mut a_track = Track::new("a-track", ClipType::Video); + a_track.clips.push(Clip::new_nested("a-to-b", "b", 0, 10)); + a.tracks.push(a_track); + let mut b = Timeline::new(); + let mut b_track = Track::new("b-track", ClipType::Video); + b_track.clips.push(Clip::new_nested("b-to-a", "a", 0, 10)); + b.tracks.push(b_track); + + let mut project = Project::new(&bundle); + project.timeline.nested_sequences = vec![ + NestedSequence::new("a", "A", a), + NestedSequence::new("b", "B", b), + ]; + let error = project.save().expect_err("cycle must not be persisted"); + assert!(matches!(error, ProjectError::InvalidTimeline { .. })); + assert!( + !bundle.exists(), + "failed preflight must not create a bundle" + ); + + std::fs::create_dir_all(&bundle).unwrap(); + write_file( + &bundle.join("project.json"), + serde_json::to_string(&project.timeline).unwrap().as_bytes(), + ); + let error = Project::open(&bundle).expect_err("cycle must fail open"); + assert!(matches!(error, ProjectError::InvalidTimeline { .. })); + assert!(error.to_string().contains("a -> b -> a")); +} diff --git a/crates/opentake-project/tests/lut_storage.rs b/crates/opentake-project/tests/lut_storage.rs new file mode 100644 index 00000000..672d8a82 --- /dev/null +++ b/crates/opentake-project/tests/lut_storage.rs @@ -0,0 +1,36 @@ +#[allow(dead_code)] +mod common; + +use common::TempDir; +use opentake_project::{Project, ProjectRoot}; + +#[test] +fn managed_lut_is_bounded_nofollow_and_carried_by_complete_save_as() { + let temp = TempDir::new("lut-storage"); + let source = temp.child("Source.opentake"); + Project::new(&source).save().expect("create source bundle"); + let source_root = ProjectRoot::open(&source).expect("retain source root"); + let name = format!("{}.cube", "0123456789abcdef".repeat(4)); + let bytes = b"LUT_3D_SIZE 17\n# acceptance bytes\n"; + source_root + .write_lut_atomic(&name, bytes) + .expect("publish managed LUT"); + assert_eq!( + source_root.read_lut(&name, 4096).unwrap().as_deref(), + Some(bytes.as_slice()) + ); + assert!( + source_root.read_lut(&name, 4).is_err(), + "read cap is enforced" + ); + + let destination = temp.child("Destination.opentake"); + let destination_root = Project::new(&destination) + .publish_complete_to(&destination, Some(&source_root)) + .expect("complete Save As"); + assert_eq!( + destination_root.read_lut(&name, 4096).unwrap().as_deref(), + Some(bytes.as_slice()), + "nested media/luts asset must travel with Save As" + ); +} diff --git a/crates/opentake-project/tests/roundtrip.rs b/crates/opentake-project/tests/roundtrip.rs index 9dc25d92..4154f946 100644 --- a/crates/opentake-project/tests/roundtrip.rs +++ b/crates/opentake-project/tests/roundtrip.rs @@ -55,6 +55,8 @@ fn sample_project(bundle: &Path) -> Project { source_height: Some(2160), source_fps: Some(24.0), has_audio: Some(true), + color: None, + proxy: None, folder_id: Some("folder-1".into()), cached_remote_url: None, cached_remote_url_expires_at: None, @@ -72,6 +74,8 @@ fn sample_project(bundle: &Path) -> Project { source_height: None, source_fps: None, has_audio: None, + color: None, + proxy: None, folder_id: None, cached_remote_url: None, cached_remote_url_expires_at: None, diff --git a/crates/opentake-project/tests/schema_compat.rs b/crates/opentake-project/tests/schema_compat.rs index b2c1f799..a6230ae5 100644 --- a/crates/opentake-project/tests/schema_compat.rs +++ b/crates/opentake-project/tests/schema_compat.rs @@ -40,6 +40,20 @@ fn write_known_bundle(bundle: &Path) { "interpolationOut": "smooth" }] }, + "loudnessNormalization": { + "targetLufs": -16.0, + "truePeakCeilingDbtp": -1.0, + "inputIntegratedLufs": -24.0, + "inputTruePeakDbtp": -12.0, + "gainDb": 8.0, + "outputIntegratedLufs": -16.0, + "outputTruePeakDbtp": -2.0 + }, + "audioDenoise": { + "mode": "voice", + "strength": 0.8, + "previewEnabled": true + }, "effects": [{"name": "blur", "params": {}, "enabled": true}], "masks": [{ "shape": {"kind": "circle", "center": {"x": 0.5, "y": 0.5}, "radius": {"x": 0.5, "y": 0.5}}, @@ -458,3 +472,16 @@ fn known_schema_remains_writable() { assert_eq!(saved_as.timeline.fps, 60); assert!(!saved_as.compatibility().is_read_only()); } + +/// Composite acceptance entry tracked by the data-safety implementation plan. +/// It exercises strict required components, read-only recovery for optional +/// corruption, unknown-field preservation, and the writable save/reopen path. +#[test] +fn cross_cutting_project_safety_acceptance() { + unknown_top_level_timeline_field_blocks_writes_without_changing_bytes(); + unknown_nested_manifest_entry_and_source_fields_block_writes(); + malformed_optional_generation_log_opens_but_blocks_writes(); + malformed_manifest_contract_matches_authoritative_source(); + trailing_required_json_remains_a_strict_open_error(); + known_schema_remains_writable(); +} diff --git a/crates/opentake-project/tests/upstream_compat.rs b/crates/opentake-project/tests/upstream_compat.rs index 9ecd57c8..878ce2d2 100644 --- a/crates/opentake-project/tests/upstream_compat.rs +++ b/crates/opentake-project/tests/upstream_compat.rs @@ -12,7 +12,7 @@ mod common; -use opentake_domain::{ClipType, MediaSource}; +use opentake_domain::{ClipType, MediaManifest, MediaSource}; use opentake_project::{Project, ProjectError}; use serde_json::{json, Value}; @@ -241,6 +241,31 @@ fn applies_clip_defaults_for_omitted_fields() { assert_eq!(clip_a.end_frame(), 90); // source_frames_consumed = round(90 * 2.0) = 180. assert_eq!(clip_a.source_frames_consumed(), 180); + + // A missing persisted version is the legacy schema (1), even though a + // newly constructed manifest starts at the current schema (2). Explicit + // persisted versions must never be overwritten by the compatibility path. + assert_eq!(project.manifest.version, 1); + assert_eq!(MediaManifest::default().version, 2); + let explicit: MediaManifest = serde_json::from_value(json!({ + "version": 2, + "entries": [], + "folders": [] + })) + .unwrap(); + assert_eq!(explicit.version, 2); + + // Persisting the upgraded representation and opening it again must keep + // both the decoded defaults and the legacy manifest version exactly. + project.save().unwrap(); + let reopened = Project::open(&bundle).unwrap(); + assert_eq!(reopened.timeline, project.timeline); + assert_eq!(reopened.manifest, project.manifest); + let reopened_clip = &reopened.timeline.tracks[0].clips[0]; + assert_eq!(reopened_clip.trim_end_frame, 0); + assert_eq!(reopened_clip.opacity, 1.0); + assert!(reopened_clip.opacity_track.is_none()); + assert!(reopened_clip.link_group_id.is_none()); } #[test] @@ -309,7 +334,10 @@ fn parses_manifest_with_missing_version_and_tagged_sources() { fn migrates_generation_log_legacy_cost_and_version() { let (_tmp, bundle) = make_upstream_bundle("compat-genlog"); let project = Project::open(&bundle).unwrap(); - let log = project.generation_log.expect("generation log present"); + let log = project + .generation_log + .as_ref() + .expect("generation log present"); // Missing top-level version -> 1. assert_eq!(log.version, 1); @@ -330,6 +358,18 @@ fn migrates_generation_log_legacy_cost_and_version() { assert!(modern.created_at.is_none()); assert_eq!(log.total_credits(), 342); + + // The generated fallback id is synthesized only once. Saving and + // reopening must retain the migrated version, costs, and stable identity. + let expected_log = log.clone(); + project.save().unwrap(); + let reopened = Project::open(&bundle).unwrap(); + assert_eq!(reopened.generation_log.as_ref(), Some(&expected_log)); + let reopened_log = reopened.generation_log.as_ref().unwrap(); + assert_eq!(reopened_log.version, 1); + assert_eq!(reopened_log.entries[0].cost_credits, Some(42)); + assert_eq!(reopened_log.entries[1].id, modern.id); + assert_eq!(reopened_log.total_credits(), 342); } #[test] diff --git a/crates/opentake-render/Cargo.toml b/crates/opentake-render/Cargo.toml index 4da54ba1..828fab9a 100644 --- a/crates/opentake-render/Cargo.toml +++ b/crates/opentake-render/Cargo.toml @@ -17,6 +17,7 @@ thiserror = "2" wgpu = { version = "23", default-features = false, features = ["wgsl", "metal"] } # POD uniforms uploaded to the GPU (mat3x2 / crop_uv / opacity / flags). bytemuck = { version = "1", features = ["derive"] } +half = "2" # Block on wgpu's async device/queue/map calls from synchronous render code. pollster = "0.4" # Text shaping + layout + glyph rasterization for timeline text clips (upstream @@ -28,3 +29,6 @@ cosmic-text = { version = "0.12", default-features = false, features = ["std", " [dev-dependencies] # PNG read-back round-trip checks in the GPU smoke test (offline, no assets). image = { version = "0.25", default-features = false, features = ["png"] } +serde_json = { workspace = true } +opentake-media = { workspace = true } +opentake-ops = { workspace = true } diff --git a/crates/opentake-render/src/gpu/compositor.rs b/crates/opentake-render/src/gpu/compositor.rs index 7790111d..edd5de1c 100644 --- a/crates/opentake-render/src/gpu/compositor.rs +++ b/crates/opentake-render/src/gpu/compositor.rs @@ -9,39 +9,55 @@ use std::rc::Rc; use bytemuck::{Pod, Zeroable}; -use opentake_domain::{ColorGrade, LiftGammaGain, MaskShape}; +use opentake_domain::{ + validate_effect_chain, ColorGrade, LiftGammaGain, LutReference, MaskShape, MAX_EFFECTS_PER_CLIP, +}; -use crate::gpu::texture::GpuTexture; +use crate::gpu::texture::{GpuLutTexture, GpuTexture}; use crate::gpu::RenderError; use crate::plan::{FramePlan, LayerDraw, RenderSize, TextureSource}; use crate::source::DecodedFrame; +use opentake_domain::{MAX_MASKS_PER_CLIP, MAX_POLYGON_MASK_POINTS}; /// Maximum masks evaluated in-shader per draw (mirrors `MASK_CAP` in -/// `shader.wgsl`). Extra masks on a clip beyond this are ignored by the -/// compositor (the domain still stores and unit-tests all of them). -const MASK_CAP: usize = 4; +/// `shader.wgsl`). The shared edit-command validation prevents authored data +/// from exceeding this fixed uniform capacity. +const MASK_CAP: usize = MAX_MASKS_PER_CLIP; /// Flag bits packed into `canvas_op_flags[3]` (bitcast to u32 in WGSL). const FLAG_PREMULTIPLY: u32 = 1; const FLAG_GRADE: u32 = 2; const FLAG_CHROMA: u32 = 4; -/// Mask kind tags (mirror `MaskShape` / the WGSL `MASK_*` consts). Polygon masks -/// are not rendered in-shader (see shader TODO); they encode as `MASK_NOOP` which -/// the shader treats as a full-coverage circle (no clipping). +/// Mask kind tags and polygon point cap mirror the WGSL constants. const MASK_LINEAR: f32 = 0.0; const MASK_CIRCLE: f32 = 1.0; -/// A circle large enough to cover the whole canvas — used to make an unsupported -/// (polygon) mask a no-op instead of silently clipping. -const MASK_NOOP_GEO: [f32; 4] = [0.5, 0.5, 8.0, 8.0]; +const MASK_POLY: f32 = 2.0; +const POLY_POINT_CAP: usize = MAX_POLYGON_MASK_POINTS; + +/// Effect kind tags mirror the closed registry and WGSL implementation. +const EFFECT_GRAYSCALE: f32 = 0.0; +const EFFECT_SEPIA: f32 = 1.0; +const EFFECT_INVERT: f32 = 2.0; + +#[repr(C)] +#[derive(Clone, Copy, Pod, Zeroable, Default)] +struct EffectGpu { + // (kind, amount, pad, pad) + data: [f32; 4], +} /// One mask in the uniform (mirrors WGSL `MaskGpu`): `head = (kind, feather, -/// invert, pad)`, `geo` packs the shape geometry. +/// invert, polygon-point-count)`, `geo` packs linear/circle geometry, and +/// `points` carries a bounded pen path. #[repr(C)] #[derive(Clone, Copy, Pod, Zeroable, Default)] struct MaskGpu { head: [f32; 4], geo: [f32; 4], + transform: [f32; 4], + transform_meta: [f32; 4], + points: [[f32; 4]; POLY_POINT_CAP], } /// Uniform mirror of WGSL `struct U` (SPEC §3.2), extended with the A-tier color @@ -50,59 +66,99 @@ struct MaskGpu { #[repr(C)] #[derive(Clone, Copy, Pod, Zeroable)] struct Uniforms { - affine0: [f32; 4], // a, b, c, d - crop_uv: [f32; 4], // u0, v0, u1, v1 - affine1_nat: [f32; 4], // tx, ty, natW, natH - canvas_op_flags: [f32; 4], // canvasW, canvasH, opacity, flags-as-f32 - grade_exp_wb: [f32; 4], // exposure, wb_r, wb_g, wb_b - grade_lift: [f32; 4], // lift_r, lift_g, lift_b, contrast - grade_gamma: [f32; 4], // gamma_r, gamma_g, gamma_b, saturation - grade_gain: [f32; 4], // gain_r, gain_g, gain_b, pad - chroma0: [f32; 4], // key_r, key_g, key_b, similarity - chroma1: [f32; 4], // smoothness, spill, pad, pad - mask_meta: [f32; 4], // mask_count, pad, pad, pad + affine0: [f32; 4], // a, b, c, d + crop_uv: [f32; 4], // u0, v0, u1, v1 + affine1_nat: [f32; 4], // tx, ty, natW, natH + canvas_op_flags: [f32; 4], // canvasW, canvasH, opacity, flags-as-f32 + grade_exp_wb: [f32; 4], // exposure, wb_r, wb_g, wb_b + grade_lift: [f32; 4], // lift_r, lift_g, lift_b, contrast + grade_gamma: [f32; 4], // gamma_r, gamma_g, gamma_b, saturation + grade_gain: [f32; 4], // gain_r, gain_g, gain_b, pad + hsl_secondary_meta: [f32; 4], // enabled, hue center, full width, feather + hsl_secondary_adjust: [f32; 4], // hue shift, saturation, lightness, pad + lut_meta: [f32; 4], // enabled, intensity, table size, pad + lut_domain_min: [f32; 4], // min r/g/b, pad + lut_domain_scale: [f32; 4], // reciprocal domain span r/g/b, pad + chroma0: [f32; 4], // key_r, key_g, key_b, similarity + chroma1: [f32; 4], // smoothness, spill, pad, pad + mask_meta: [f32; 4], // mask_count, pad, pad, pad masks: [MaskGpu; MASK_CAP], + effect_meta: [f32; 4], // effect_count, pad, pad, pad + effects: [EffectGpu; MAX_EFFECTS_PER_CLIP], +} + +#[derive(Clone, Copy)] +struct GradeBlocks { + exp_wb: [f32; 4], + lift: [f32; 4], + gamma: [f32; 4], + gain: [f32; 4], + hsl_meta: [f32; 4], + hsl_adjust: [f32; 4], } /// Identity color-grade uniform block (exposure 0, wb/gain 1, lift 0, gamma 1, /// contrast 0, saturation 1). Used when a draw has no grade. -fn identity_grade_blocks() -> ([f32; 4], [f32; 4], [f32; 4], [f32; 4]) { - ( - [0.0, 1.0, 1.0, 1.0], // exposure, wb - [0.0, 0.0, 0.0, 0.0], // lift, contrast - [1.0, 1.0, 1.0, 1.0], // gamma, saturation - [1.0, 1.0, 1.0, 0.0], // gain, pad - ) +fn identity_grade_blocks() -> GradeBlocks { + GradeBlocks { + exp_wb: [0.0, 1.0, 1.0, 1.0], // exposure, wb + lift: [0.0, 0.0, 0.0, 0.0], // lift, contrast + gamma: [1.0, 1.0, 1.0, 1.0], // gamma, saturation + gain: [1.0, 1.0, 1.0, 0.0], // gain, pad + hsl_meta: [0.0, 0.0, 1.0, 0.0], // disabled, center, width, feather + hsl_adjust: [0.0; 4], // hue shift, saturation, lightness, pad + } } -/// Pack a [`ColorGrade`] into the four uniform vec4 blocks the shader reads. The +/// Pack a [`ColorGrade`] into the six uniform vec4 blocks the shader reads. The /// white balance is resolved to per-channel gain CPU-side (the shader multiplies /// it directly), keeping the WGSL mirror of `ColorGrade::apply_linear` simple. -fn grade_blocks(g: &ColorGrade) -> ([f32; 4], [f32; 4], [f32; 4], [f32; 4]) { +fn grade_blocks(g: &ColorGrade) -> GradeBlocks { let wb = g.white_balance_gain(); let LiftGammaGain { lift, gamma, gain } = g.lift_gamma_gain; - ( - [g.exposure as f32, wb.r as f32, wb.g as f32, wb.b as f32], - [ + let (hsl_meta, hsl_adjust) = + g.hsl_secondary + .map_or(([0.0, 0.0, 1.0, 0.0], [0.0; 4]), |secondary| { + ( + [ + 1.0, + secondary.hue_center as f32, + secondary.hue_width as f32, + secondary.feather as f32, + ], + [ + secondary.hue_shift as f32, + secondary.saturation as f32, + secondary.lightness as f32, + 0.0, + ], + ) + }); + GradeBlocks { + exp_wb: [g.exposure as f32, wb.r as f32, wb.g as f32, wb.b as f32], + lift: [ lift.r as f32, lift.g as f32, lift.b as f32, g.contrast as f32, ], - [ + gamma: [ gamma.r as f32, gamma.g as f32, gamma.b as f32, g.saturation as f32, ], - [gain.r as f32, gain.g as f32, gain.b as f32, 0.0], - ) + gain: [gain.r as f32, gain.g as f32, gain.b as f32, 0.0], + hsl_meta, + hsl_adjust, + } } /// Pack a draw's masks into the fixed-capacity uniform array, returning the count -/// the shader should evaluate. Linear + circle masks encode directly; polygon -/// masks (unsupported in-shader) encode as a full-coverage no-op so they neither -/// clip nor crash. Masks beyond [`MASK_CAP`] are dropped. +/// the shader should evaluate. Polygon paths are bounded to [`POLY_POINT_CAP`] +/// points. The shared edit-command validation prevents authored data from +/// exceeding either fixed GPU capacity; the `min`/`break` here is a deterministic +/// defensive fallback for an in-memory timeline that bypassed that boundary. fn pack_masks(draw: &LayerDraw<'_>) -> ([MaskGpu; MASK_CAP], f32) { let mut out = [MaskGpu::default(); MASK_CAP]; let mut n = 0usize; @@ -111,7 +167,8 @@ fn pack_masks(draw: &LayerDraw<'_>) -> ([MaskGpu; MASK_CAP], f32) { break; } let invert = if mask.invert { 1.0 } else { 0.0 }; - let (kind, geo) = match &mask.shape { + let mut points = [[0.0; 4]; POLY_POINT_CAP]; + let (kind, geo, point_count) = match &mask.shape { MaskShape::Linear { point, normal } => ( MASK_LINEAR, [ @@ -120,6 +177,7 @@ fn pack_masks(draw: &LayerDraw<'_>) -> ([MaskGpu; MASK_CAP], f32) { normal.x as f32, normal.y as f32, ], + 0, ), MaskShape::Circle { center, radius } => ( MASK_CIRCLE, @@ -129,31 +187,160 @@ fn pack_masks(draw: &LayerDraw<'_>) -> ([MaskGpu; MASK_CAP], f32) { radius.x as f32, radius.y as f32, ], + 0, ), - // Polygon masks are unsupported in-shader (TODO: storage buffer for - // points). Encode as a full-canvas circle so they are a visual no-op - // rather than silently clipping. - MaskShape::Poly { .. } => (MASK_CIRCLE, MASK_NOOP_GEO), + MaskShape::Poly { points: path } => { + let point_count = path.len().min(POLY_POINT_CAP); + for (target, point) in points.iter_mut().zip(path).take(point_count) { + *target = [point.x as f32, point.y as f32, 0.0, 0.0]; + } + (MASK_POLY, [0.0; 4], point_count) + } }; out[n] = MaskGpu { - head: [kind, mask.feather as f32, invert, 0.0], + head: [kind, mask.feather as f32, invert, point_count as f32], geo, + transform: [ + mask.transform.offset.x as f32, + mask.transform.offset.y as f32, + mask.transform.scale.x as f32, + mask.transform.scale.y as f32, + ], + transform_meta: [ + mask.transform.rotation_degrees.to_radians() as f32, + 0.0, + 0.0, + 0.0, + ], + points, }; n += 1; } (out, n as f32) } +fn pack_effects( + draw: &LayerDraw<'_>, +) -> Result<([EffectGpu; MAX_EFFECTS_PER_CLIP], f32), RenderError> { + validate_effect_chain(draw.effects)?; + let mut out = [EffectGpu::default(); MAX_EFFECTS_PER_CLIP]; + let mut count = 0usize; + for effect in draw.effects.iter().filter(|effect| effect.enabled) { + let kind = match effect.name.as_str() { + "grayscale" => EFFECT_GRAYSCALE, + "sepia" => EFFECT_SEPIA, + "invert" => EFFECT_INVERT, + _ => unreachable!("validate_effect_chain accepts only registered effects"), + }; + out[count] = EffectGpu { + data: [kind, effect.registered_param("amount")? as f32, 0.0, 0.0], + }; + count += 1; + } + Ok((out, count as f32)) +} + /// Working color format. The PoC composites in the sRGB non-linear domain /// (SPEC §3.7): an `Rgba8Unorm` target stores raw encoded bytes and blends them /// directly, matching AVFoundation most closely. Read-back returns those bytes. const RT_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm; +/// Frame reconstruction requested from the media resolver when source and +/// project rates differ. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum TextureInterpolationMode { + Nearest, + Blend, + OpticalFlow, +} + +/// Deterministic recovery policy when the requested optical-flow backend is +/// unavailable for a resolver/device. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum TextureInterpolationFallback { + Nearest, + Blend, + Error, +} + +/// Explicit source/target-rate contract shared by preview and export. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct TextureInterpolationConfig { + pub source_fps: f64, + pub target_fps: f64, + pub mode: TextureInterpolationMode, + pub fallback: TextureInterpolationFallback, +} + +impl TextureInterpolationConfig { + pub fn new( + source_fps: f64, + target_fps: f64, + mode: TextureInterpolationMode, + fallback: TextureInterpolationFallback, + ) -> Result { + if !source_fps.is_finite() || source_fps <= 0.0 { + return Err("source_fps must be finite and greater than zero"); + } + if !target_fps.is_finite() || target_fps <= 0.0 { + return Err("target_fps must be finite and greater than zero"); + } + Ok(Self { + source_fps, + target_fps, + mode, + fallback, + }) + } + + /// Backward-compatible resolver behavior for callers that have not selected + /// a rate-conversion mode. + pub const fn passthrough() -> Self { + Self { + source_fps: 1.0, + target_fps: 1.0, + mode: TextureInterpolationMode::Nearest, + fallback: TextureInterpolationFallback::Nearest, + } + } +} + +/// Complete per-layer texture request. Keeping the interpolation contract on +/// the request prevents preview/export adapters from silently selecting +/// different reconstruction modes. +#[derive(Clone, Copy, Debug)] +pub struct TextureResolveRequest<'a> { + pub source: &'a TextureSource, + pub source_frame: i64, + pub interpolation: TextureInterpolationConfig, +} + /// Resolves a draw's [`TextureSource`] + source frame to a GPU texture. The /// compositor is decode-agnostic; the integrating layer (or a test) supplies /// pixels (e.g. via [`crate::source::FrameProvider`] + a cache). pub trait TextureResolver { fn resolve(&mut self, source: &TextureSource, source_frame: i64) -> Option>; + + /// Resolve through an explicit rate-conversion contract. Existing + /// resolvers remain nearest-frame compatible; optical-flow-aware resolvers + /// override this method and apply the requested fallback policy before GPU + /// upload. + fn resolve_with_interpolation( + &mut self, + request: TextureResolveRequest<'_>, + ) -> Option> { + self.resolve(request.source, request.source_frame) + } + + /// Resolve a validated project-managed LUT reference. The default keeps + /// source-only resolvers source-compatible; the compositor still fails a + /// draw carrying a LUT when no asset is returned. + fn resolve_lut( + &mut self, + _reference: &LutReference, + ) -> Result>, RenderError> { + Ok(None) + } } /// A textured-quad compositor bound to one device. @@ -161,6 +348,7 @@ pub struct Compositor { pipeline: wgpu::RenderPipeline, bind_group_layout: wgpu::BindGroupLayout, sampler: wgpu::Sampler, + fallback_lut: GpuLutTexture, } impl Compositor { @@ -200,6 +388,22 @@ impl Compositor { ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), count: None, }, + wgpu::BindGroupLayoutEntry { + binding: 3, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + view_dimension: wgpu::TextureViewDimension::D3, + multisampled: false, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 4, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }, ], }); @@ -264,10 +468,38 @@ impl Compositor { ..Default::default() }); + // A bound texture is required even when a draw has no active LUT. The + // shader never samples this uninitialized 1x1 fallback when disabled. + let fallback_texture = device.create_texture(&wgpu::TextureDescriptor { + label: Some("opentake-render inactive LUT binding"), + size: wgpu::Extent3d { + width: 1, + height: 1, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D3, + format: wgpu::TextureFormat::Rgba16Float, + usage: wgpu::TextureUsages::TEXTURE_BINDING, + view_formats: &[], + }); + let fallback_view = fallback_texture.create_view(&wgpu::TextureViewDescriptor { + dimension: Some(wgpu::TextureViewDimension::D3), + ..Default::default() + }); + Compositor { pipeline, bind_group_layout, sampler, + fallback_lut: GpuLutTexture { + texture: fallback_texture, + view: fallback_view, + size: 1, + domain_min: [0.0; 3], + domain_max: [1.0; 3], + }, } } @@ -284,6 +516,28 @@ impl Compositor { size: RenderSize, frame_plan: &FramePlan<'_>, resolver: &mut dyn TextureResolver, + ) -> Result { + self.render_to_rgba_with_interpolation( + device, + queue, + size, + frame_plan, + resolver, + TextureInterpolationConfig::passthrough(), + ) + } + + /// Render with an explicit source/target-rate interpolation policy. Preview + /// and export pass the same value here so their resolver behavior cannot + /// drift independently. + pub fn render_to_rgba_with_interpolation( + &self, + device: &wgpu::Device, + queue: &wgpu::Queue, + size: RenderSize, + frame_plan: &FramePlan<'_>, + resolver: &mut dyn TextureResolver, + interpolation: TextureInterpolationConfig, ) -> Result { let rt = device.create_texture(&wgpu::TextureDescriptor { label: Some("opentake-render target"), @@ -306,11 +560,26 @@ impl Compositor { struct Prepared { bind_group: wgpu::BindGroup, _tex: Rc, + _lut: Option>, } let mut prepared: Vec = Vec::with_capacity(frame_plan.draws.len()); for draw in &frame_plan.draws { - let Some(tex) = resolver.resolve(draw.source, draw.source_frame) else { + // Reject invalid persisted data even when the source is offline; + // an unknown effect or malformed grade must never degrade into an + // unchanged frame or reach the GPU as NaN/Inf uniforms. + let (effects, effect_count) = pack_effects(draw)?; + if let Some(grade) = draw.color_grade { + grade.validate()?; + } + if let Some(reference) = draw.lut { + reference.validate()?; + } + let Some(tex) = resolver.resolve_with_interpolation(TextureResolveRequest { + source: draw.source, + source_frame: draw.source_frame, + interpolation, + }) else { continue; }; // Assemble flags + the A-tier parameter blocks for this draw. @@ -319,7 +588,7 @@ impl Compositor { } else { 0 }; - let (grade_exp_wb, grade_lift, grade_gamma, grade_gain) = match draw.color_grade { + let grade = match draw.color_grade { Some(g) if !g.is_identity() => { flags |= FLAG_GRADE; grade_blocks(g) @@ -342,6 +611,33 @@ impl Compositor { None => ([0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 0.0, 0.0]), }; let (masks, mask_count) = pack_masks(draw); + let resolved_lut = match draw.lut { + Some(reference) => Some( + resolver + .resolve_lut(reference)? + .ok_or_else(|| RenderError::MissingLut(reference.id.clone()))?, + ), + None => None, + }; + let (lut_meta, lut_domain_min, lut_domain_scale) = match draw.lut { + Some(reference) => { + let parsed = resolved_lut.as_ref().expect("resolved above"); + let domain_scale: [f32; 3] = std::array::from_fn(|channel| { + 1.0 / (parsed.domain_max[channel] - parsed.domain_min[channel]) + }); + ( + [1.0, reference.intensity as f32, parsed.size as f32, 0.0], + [ + parsed.domain_min[0], + parsed.domain_min[1], + parsed.domain_min[2], + 0.0, + ], + [domain_scale[0], domain_scale[1], domain_scale[2], 0.0], + ) + } + None => ([0.0; 4], [0.0; 4], [1.0, 1.0, 1.0, 0.0]), + }; let u = Uniforms { affine0: [ draw.affine[0] as f32, @@ -374,14 +670,21 @@ impl Compositor { draw.opacity as f32, f32::from_bits(flags), ], - grade_exp_wb, - grade_lift, - grade_gamma, - grade_gain, + grade_exp_wb: grade.exp_wb, + grade_lift: grade.lift, + grade_gamma: grade.gamma, + grade_gain: grade.gain, + hsl_secondary_meta: grade.hsl_meta, + hsl_secondary_adjust: grade.hsl_adjust, + lut_meta, + lut_domain_min, + lut_domain_scale, chroma0, chroma1, mask_meta: [mask_count, 0.0, 0.0, 0.0], masks, + effect_meta: [effect_count, 0.0, 0.0, 0.0], + effects, }; let ubuf = device.create_buffer(&wgpu::BufferDescriptor { label: Some("opentake-render uniform"), @@ -391,6 +694,10 @@ impl Compositor { }); queue.write_buffer(&ubuf, 0, bytemuck::bytes_of(&u)); + let lut_view = resolved_lut + .as_ref() + .map_or(&self.fallback_lut.view, |lut| &lut.view); + let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { label: Some("opentake-render bind group"), layout: &self.bind_group_layout, @@ -407,11 +714,20 @@ impl Compositor { binding: 2, resource: wgpu::BindingResource::Sampler(&self.sampler), }, + wgpu::BindGroupEntry { + binding: 3, + resource: wgpu::BindingResource::TextureView(lut_view), + }, + wgpu::BindGroupEntry { + binding: 4, + resource: wgpu::BindingResource::Sampler(&self.sampler), + }, ], }); prepared.push(Prepared { bind_group, _tex: tex, + _lut: resolved_lut, }); } diff --git a/crates/opentake-render/src/gpu/mod.rs b/crates/opentake-render/src/gpu/mod.rs index 4b4b11b5..9115a0bd 100644 --- a/crates/opentake-render/src/gpu/mod.rs +++ b/crates/opentake-render/src/gpu/mod.rs @@ -17,7 +17,7 @@ pub use compositor::{Compositor, TextureResolver}; pub use device::RenderDevice; pub use text_engine::CosmicTextRasterizer; pub use text_raster::{NullTextRasterizer, TextRasterRequest, TextRasterizer}; -pub use texture::{upload_rgba, GpuTexture, TextureCache}; +pub use texture::{upload_lut_3d, upload_rgba, GpuLutTexture, GpuTexture, TextureCache}; /// Errors from GPU device acquisition and frame compositing. #[derive(Debug, thiserror::Error)] @@ -29,4 +29,14 @@ pub enum RenderError { DeviceRequest(String), #[error("frame read-back failed: {0}")] Readback(String), + #[error("invalid effect chain: {0}")] + InvalidEffect(#[from] opentake_domain::EffectValidationError), + #[error("invalid color grade: {0}")] + InvalidColorGrade(#[from] opentake_domain::ColorGradeValidationError), + #[error("invalid LUT reference: {0}")] + InvalidLutReference(#[from] opentake_domain::LutReferenceValidationError), + #[error("LUT asset could not be resolved: {0}")] + MissingLut(String), + #[error("invalid LUT asset: {0}")] + InvalidLut(String), } diff --git a/crates/opentake-render/src/gpu/shader.wgsl b/crates/opentake-render/src/gpu/shader.wgsl index 5a8d2157..6f2b0e89 100644 --- a/crates/opentake-render/src/gpu/shader.wgsl +++ b/crates/opentake-render/src/gpu/shader.wgsl @@ -26,28 +26,39 @@ // on the (sampled) color directly, matching the domain reference which is // space-agnostic for those stages. // -// MASK CAP: up to MASK_CAP masks are evaluated in-shader (linear + circle SDF). -// Polygon masks are carried in the domain/plan and fully unit-tested there, but -// their variable-length point list does not fit this fixed uniform; wiring -// polygon points through a storage buffer is a documented render-side TODO. +// MASK CAP: up to MASK_CAP masks and POLY_POINT_CAP pen points per mask are +// evaluated in-shader. The fixed point cap keeps the uniform layout portable. const MASK_CAP: u32 = 4u; +const POLY_POINT_CAP: u32 = 16u; +const EFFECT_CAP: u32 = 8u; // Flag bits packed into U.canvas_op_flags.w (bitcast to u32). const FLAG_PREMULTIPLY: u32 = 1u; // straight-alpha source needs premultiply const FLAG_GRADE: u32 = 2u; // color grade active const FLAG_CHROMA: u32 = 4u; // chroma key active -// Mask kind tags (mirror MaskShape; poly is not evaluated in-shader). +// Mask kind tags (mirror MaskShape). const MASK_LINEAR: u32 = 0u; const MASK_CIRCLE: u32 = 1u; +const MASK_POLY: u32 = 2u; struct MaskGpu { - // (kind-as-f32, feather, invert-as-f32, pad) + // (kind-as-f32, feather, invert-as-f32, polygon-point-count) head: vec4, // linear: (point.x, point.y, normal.x, normal.y) // circle: (center.x, center.y, radius.x, radius.y) geo: vec4, + // (offset.x, offset.y, scale.x, scale.y) + transform: vec4, + // (rotation radians, pad, pad, pad) + transform_meta: vec4, + points: array, POLY_POINT_CAP>, +}; + +struct EffectGpu { + // (kind, amount, pad, pad) + data: vec4, }; // Laid out as vec4s so every field is 16-byte aligned (no implicit WGSL padding) @@ -62,17 +73,43 @@ struct U { grade_lift: vec4, // lift_r, lift_g, lift_b, contrast grade_gamma: vec4, // gamma_r, gamma_g, gamma_b, saturation grade_gain: vec4, // gain_r, gain_g, gain_b, pad + hsl_secondary_meta: vec4, // enabled, hue center, full width, feather + hsl_secondary_adjust: vec4, // hue shift, saturation, lightness, pad + lut_meta: vec4, // enabled, intensity, table size, pad + lut_domain_min: vec4, // min r/g/b, pad + lut_domain_scale: vec4, // reciprocal domain span r/g/b, pad // Chroma key. chroma0: vec4, // key_r, key_g, key_b, similarity chroma1: vec4, // smoothness, spill, pad, pad // Mask count (x) + padding. mask_meta: vec4, // mask_count, pad, pad, pad masks: array, + effect_meta: vec4, // effect_count, pad, pad, pad + effects: array, }; @group(0) @binding(0) var u: U; @group(0) @binding(1) var t_color: texture_2d; @group(0) @binding(2) var s_color: sampler; +@group(0) @binding(3) var t_lut: texture_3d; +@group(0) @binding(4) var s_lut: sampler; + +fn apply_lut(rgb: vec3) -> vec3 { + if (u.lut_meta.x < 0.5 || u.lut_meta.y <= 0.0) { + return rgb; + } + let normalized = clamp( + (rgb - u.lut_domain_min.xyz) * u.lut_domain_scale.xyz, + vec3(0.0), + vec3(1.0), + ); + let table_size = u.lut_meta.z; + // Align authored grid points i/(N-1) with texel centers before hardware + // trilinear filtering. + let coordinate = (normalized * (table_size - 1.0) + vec3(0.5)) / table_size; + let transformed = textureSample(t_lut, s_lut, coordinate).rgb; + return mix(rgb, transformed, clamp(u.lut_meta.y, 0.0, 1.0)); +} struct VsOut { @builtin(position) pos: vec4, @@ -112,15 +149,84 @@ fn smoothstep01(edge0: f32, edge1: f32, x: f32) -> f32 { const CONTRAST_PIVOT: f32 = 0.18; fn apply_channel_lgg(x: f32, lift: f32, gamma: f32, gain: f32) -> f32 { - let v = gain * (x + lift); + let shaped = x + lift * (1.0 - x); if (abs(gamma - 1.0) > 1e-6 && gamma > 0.0) { - return pow(max(v, 0.0), 1.0 / gamma); + return gain * pow(max(shaped, 0.0), 1.0 / gamma); + } + return gain * shaped; +} + +fn rgb_to_hsl(c: vec3) -> vec3 { + let maximum = max(c.r, max(c.g, c.b)); + let minimum = min(c.r, min(c.g, c.b)); + let delta = maximum - minimum; + let lightness = (maximum + minimum) * 0.5; + if (delta <= 1e-7) { + return vec3(0.0, 0.0, lightness); + } + let saturation = delta / max(1.0 - abs(2.0 * lightness - 1.0), 1e-7); + var sector = 0.0; + if (maximum == c.r) { + sector = ((c.g - c.b) / delta) % 6.0; + if (sector < 0.0) { + sector = sector + 6.0; + } + } else if (maximum == c.g) { + sector = (c.b - c.r) / delta + 2.0; + } else { + sector = (c.r - c.g) / delta + 4.0; } - return v; + return vec3(sector / 6.0, saturation, lightness); +} + +fn hsl_to_rgb(hsl: vec3) -> vec3 { + let chroma = (1.0 - abs(2.0 * hsl.z - 1.0)) * hsl.y; + let sector = fract(hsl.x) * 6.0; + let x = chroma * (1.0 - abs((sector % 2.0) - 1.0)); + var rgb = vec3(0.0); + if (sector < 1.0) { + rgb = vec3(chroma, x, 0.0); + } else if (sector < 2.0) { + rgb = vec3(x, chroma, 0.0); + } else if (sector < 3.0) { + rgb = vec3(0.0, chroma, x); + } else if (sector < 4.0) { + rgb = vec3(0.0, x, chroma); + } else if (sector < 5.0) { + rgb = vec3(x, 0.0, chroma); + } else { + rgb = vec3(chroma, 0.0, x); + } + return rgb + vec3(hsl.z - chroma * 0.5); +} + +fn apply_hsl_secondary(c: vec3) -> vec3 { + if (u.hsl_secondary_meta.x < 0.5) { + return c; + } + var hsl = rgb_to_hsl(c); + if (hsl.y <= 1e-7) { + return c; + } + let delta = abs(fract(hsl.x - u.hsl_secondary_meta.y + 0.5) - 0.5); + let outer = u.hsl_secondary_meta.z * 0.5; + if (delta > outer) { + return c; + } + let feather = u.hsl_secondary_meta.w; + var weight = 1.0; + if (feather > 1e-7) { + weight = 1.0 - smoothstep01(max(outer - feather, 0.0), outer, delta); + } + hsl.x = fract(hsl.x + u.hsl_secondary_adjust.x * weight + 1.0); + hsl.y = clamp(hsl.y * (1.0 + u.hsl_secondary_adjust.y * weight), 0.0, 1.0); + hsl.z = clamp(hsl.z + u.hsl_secondary_adjust.z * weight, 0.0, 1.0); + return hsl_to_rgb(hsl); } // Applies the grade to a LINEAR-rgb triple, returning clamped linear rgb. Mirror -// of ColorGrade::apply_linear (exposure -> wb -> lgg -> contrast -> saturation). +// of ColorGrade::apply_linear +// (exposure -> wb -> lgg -> contrast -> saturation -> HSL secondary). fn apply_grade_linear(rgb_in: vec3) -> vec3 { var c = rgb_in; @@ -151,6 +257,9 @@ fn apply_grade_linear(rgb_in: vec3) -> vec3 { let l = luma709(c); c = vec3(l) + (c - vec3(l)) * saturation; + // 6. Feathered HSL secondary qualifier. + c = apply_hsl_secondary(c); + return clamp(c, vec3(0.0), vec3(1.0)); } @@ -193,10 +302,60 @@ fn suppress_spill(c: vec3) -> vec3 { return vec3(nr, c.g, c.b); } -// ---- Masks (mirror of Mask::coverage; linear + circle in-shader) ------------ +// ---- Masks (mirror of Mask::coverage) --------------------------------------- + +fn mask_local_point(m: MaskGpu, p: vec2) -> vec2 { + let scale = max(abs(m.transform.zw), vec2(1e-6)); + let radians = m.transform_meta.x; + let c = cos(radians); + let s = sin(radians); + let delta = p - vec2(0.5) - m.transform.xy; + let unrotated = vec2( + c * delta.x + s * delta.y, + -s * delta.x + c * delta.y, + ); + return unrotated / scale + vec2(0.5); +} + +fn point_segment_dist2(p: vec2, a: vec2, b: vec2) -> f32 { + let ab = b - a; + let denom = dot(ab, ab); + var t = 0.0; + if (denom > 1e-12) { + t = clamp(dot(p - a, ab) / denom, 0.0, 1.0); + } + let delta = p - (a + ab * t); + return dot(delta, delta); +} + +fn polygon_signed_distance(m: MaskGpu, p: vec2) -> f32 { + let count = min(u32(m.head.w + 0.5), POLY_POINT_CAP); + if (count < 3u) { + return 1e6; + } + var inside = false; + var min_d2 = 1e12; + var j = count - 1u; + for (var i: u32 = 0u; i < count; i = i + 1u) { + let a = m.points[i].xy; + let b = m.points[j].xy; + let crosses_y = (a.y > p.y) != (b.y > p.y); + if (crosses_y) { + let edge_x = (b.x - a.x) * (p.y - a.y) / (b.y - a.y) + a.x; + if (p.x < edge_x) { + inside = !inside; + } + } + min_d2 = min(min_d2, point_segment_dist2(p, a, b)); + j = i; + } + let distance = sqrt(min_d2); + return select(distance, -distance, inside); +} fn mask_signed_distance(m: MaskGpu, p: vec2) -> f32 { let kind = u32(m.head.x + 0.5); + let local = mask_local_point(m, p); if (kind == MASK_LINEAR) { let point = m.geo.xy; let normal = m.geo.zw; @@ -205,12 +364,15 @@ fn mask_signed_distance(m: MaskGpu, p: vec2) -> f32 { return 0.0; } let n = normal / nlen; - return -dot(p - point, n); + return -dot(local - point, n); + } + if (kind == MASK_POLY) { + return polygon_signed_distance(m, local); } // Circle (default for any other tag). let center = m.geo.xy; let radius = max(m.geo.zw, vec2(1e-6)); - let d = length((p - center) / radius); + let d = length((local - center) / radius); return (d - 1.0) * min(radius.x, radius.y); } @@ -237,6 +399,39 @@ fn masks_coverage(p: vec2) -> f32 { return cov; } +// ---- Closed generic effect chain ------------------------------------------ + +const EFFECT_GRAYSCALE: u32 = 0u; +const EFFECT_SEPIA: u32 = 1u; +const EFFECT_INVERT: u32 = 2u; + +fn apply_effect(effect: EffectGpu, input: vec3) -> vec3 { + let kind = u32(effect.data.x + 0.5); + let amount = clamp(effect.data.y, 0.0, 1.0); + var transformed = input; + if (kind == EFFECT_GRAYSCALE) { + transformed = vec3(luma709(input)); + } else if (kind == EFFECT_SEPIA) { + transformed = vec3( + dot(input, vec3(0.393, 0.769, 0.189)), + dot(input, vec3(0.349, 0.686, 0.168)), + dot(input, vec3(0.272, 0.534, 0.131)), + ); + } else if (kind == EFFECT_INVERT) { + transformed = vec3(1.0) - input; + } + return clamp(mix(input, transformed, amount), vec3(0.0), vec3(1.0)); +} + +fn apply_effect_chain(input: vec3) -> vec3 { + let count = min(u32(u.effect_meta.x + 0.5), EFFECT_CAP); + var result = input; + for (var i: u32 = 0u; i < count; i = i + 1u) { + result = apply_effect(u.effects[i], result); + } + return result; +} + @vertex fn vs(@builtin(vertex_index) vi: u32) -> VsOut { // Triangle-strip quad: (0,0) (1,0) (0,1) (1,1). @@ -333,7 +528,13 @@ fn fs(in: VsOut) -> @location(0) vec4 { rgb = linear_to_srgb(graded); } - // 3. Masks (intersected coverage) scale alpha. + // 3. Project-managed 3D LUT in display-encoded RGB. + rgb = apply_lut(rgb); + + // 4. Ordered, schema-validated generic effects. + rgb = apply_effect_chain(rgb); + + // 5. Masks (intersected coverage) scale alpha. alpha = alpha * masks_coverage(in.canvas_uv); // Premultiply once (the compositor blends premultiplied over), then apply the diff --git a/crates/opentake-render/src/gpu/text_engine.rs b/crates/opentake-render/src/gpu/text_engine.rs index d87668d8..d22e4353 100644 --- a/crates/opentake-render/src/gpu/text_engine.rs +++ b/crates/opentake-render/src/gpu/text_engine.rs @@ -16,16 +16,17 @@ //! - **Rich text** (multi-span styles): cosmic-text `Buffer::set_rich_text` takes //! an iterator of `(text, Attrs)` spans; the current path uses `set_text` //! (single style per clip) — switching needs a `TextRasterRequest` shape change. -//! - **Emoji / CJK fallback**: cosmic-text 0.12 `Attrs` has no `family_emoji` / -//! `family_asian` (added in 0.14+); fallback relies on fontdb's default -//! sans-serif. TODO when the crate is bumped. +//! - **Emoji / CJK selection hints**: cosmic-text 0.12 `Attrs` has no +//! `family_emoji` / `family_asian` (added in 0.14+), so fallback uses its +//! script-aware fontdb search rather than an explicit preferred family. //! - **Vertical text**: unsupported (upstream doesn't expose it via `TextStyle`); //! v1 leaves it horizontal. use std::cell::RefCell; use cosmic_text::{ - Align, Attrs, Buffer, Color, Family, FontSystem, Metrics, Shaping, Style, SwashCache, Weight, + fontdb, Align, Attrs, Buffer, Color, Family, FontSystem, Metrics, Shaping, Style, SwashCache, + Weight, }; use opentake_domain::{Rgba, TextAlignment}; @@ -57,9 +58,23 @@ impl CosmicTextRasterizer { /// directories once and is mildly expensive (~tens of ms); construct it once /// and reuse. pub fn new() -> Self { + Self::from_font_system(FontSystem::new()) + } + + /// Build the deterministic no-font fallback used by headless runtimes and + /// tests. Text requests still yield a correctly sized premultiplied frame; + /// glyph coverage is empty while background and border styles remain live. + pub fn without_system_fonts() -> Self { + Self::from_font_system(FontSystem::new_with_locale_and_db( + "en-US".to_string(), + fontdb::Database::new(), + )) + } + + fn from_font_system(font_system: FontSystem) -> Self { CosmicTextRasterizer { inner: RefCell::new(Inner { - font_system: FontSystem::new(), + font_system, swash_cache: SwashCache::new(), }), } @@ -180,6 +195,22 @@ fn rasterize_box(inner: &mut Inner, req: &TextRasterRequest<'_>) -> Option) -> Option, +) -> GpuLutTexture { + upload_lut_table_3d( + device, + queue, + lut.size(), + lut.domain_min(), + lut.domain_max(), + lut.table(), + label, + ) +} + +pub(crate) fn upload_lut_table_3d( + device: &wgpu::Device, + queue: &wgpu::Queue, + lut_size: u32, + domain_min: [f32; 3], + domain_max: [f32; 3], + table: &[[f32; 3]], + label: Option<&str>, +) -> GpuLutTexture { + let extent = wgpu::Extent3d { + width: lut_size, + height: lut_size, + depth_or_array_layers: lut_size, + }; + let texture = device.create_texture(&wgpu::TextureDescriptor { + label, + size: extent, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D3, + format: wgpu::TextureFormat::Rgba16Float, + usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, + view_formats: &[], + }); + let mut rgba = Vec::::with_capacity(table.len() * 4); + for value in table { + rgba.extend(value.map(|channel| half::f16::from_f32(channel).to_bits())); + rgba.push(half::f16::ONE.to_bits()); + } + queue.write_texture( + wgpu::ImageCopyTexture { + texture: &texture, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + bytemuck::cast_slice(&rgba), + wgpu::ImageDataLayout { + offset: 0, + bytes_per_row: Some(lut_size * 8), + rows_per_image: Some(lut_size), + }, + extent, + ); + let view = texture.create_view(&wgpu::TextureViewDescriptor { + dimension: Some(wgpu::TextureViewDimension::D3), + ..Default::default() + }); + GpuLutTexture { + texture, + view, + size: lut_size, + domain_min, + domain_max, + } +} + /// Upload a [`DecodedFrame`] as an RGBA8 texture. /// /// `srgb` selects the texture format: `Rgba8UnormSrgb` makes the sampler return diff --git a/crates/opentake-render/src/lib.rs b/crates/opentake-render/src/lib.rs index 893896a4..639a82f9 100644 --- a/crates/opentake-render/src/lib.rs +++ b/crates/opentake-render/src/lib.rs @@ -14,13 +14,14 @@ pub mod source; pub use wgpu; pub use plan::{ - affine_transform, build_render_plan, compose, crop_to_uv, source_frame_index, ClipPlan, - FramePlan, LayerDraw, RenderPlan, RenderSize, TextureSource, + affine_transform, build_render_plan, compose, crop_to_uv, source_frame_index, + try_build_render_plan, AudioClipPlan, ClipPlan, CompoundAncestor, FramePlan, LayerDraw, + RenderPlan, RenderSize, TextureSource, }; pub use size::{even, export_render_size, ExportResolution}; pub use source::{DecodedFrame, FrameProvider, SourceMetrics}; pub use gpu::{ - Compositor, CosmicTextRasterizer, GpuTexture, NullTextRasterizer, RenderDevice, RenderError, - TextRasterRequest, TextRasterizer, TextureCache, TextureResolver, + Compositor, CosmicTextRasterizer, GpuLutTexture, GpuTexture, NullTextRasterizer, RenderDevice, + RenderError, TextRasterRequest, TextRasterizer, TextureCache, TextureResolver, }; diff --git a/crates/opentake-render/src/plan/build.rs b/crates/opentake-render/src/plan/build.rs index 2e84da15..e64a7564 100644 --- a/crates/opentake-render/src/plan/build.rs +++ b/crates/opentake-render/src/plan/build.rs @@ -6,10 +6,15 @@ //! keyframe / fade / dB sample goes through the domain `*_at` methods (SPEC §0 //! iron rule); this module only adds geometry projection + frame scheduling. -use opentake_domain::{Clip, ClipType, Timeline}; +use std::collections::{HashMap, HashSet}; + +use opentake_domain::{Clip, ClipType, NestedSequence, Timeline, TransitionKind}; use super::affine::{affine_transform, compose, crop_to_uv}; -use super::types::{ClipPlan, FramePlan, LayerDraw, RenderPlan, RenderSize, TextureSource}; +use super::types::{ + AudioClipPlan, ClipPlan, CompoundAncestor, FramePlan, LayerDraw, RenderPlan, RenderSize, + TextureSource, +}; use crate::source::SourceMetrics; /// Half-away-from-zero round, matching the domain convention (`clip.rs` L7). @@ -65,11 +70,42 @@ fn texture_source_for(clip: &Clip) -> TextureSource { } } +/// Compose authored transform animation with the clip-relative stabilization +/// track. The same helper is used by ordinary frames, transitions, preview, and +/// export, preventing separate stabilization math from drifting by surface. +fn evaluated_transform( + clip: &Clip, + frame: i32, + render_size: RenderSize, +) -> opentake_domain::Transform { + let mut transform = if clip.has_transform_animation() { + clip.transform_at(frame) + } else { + clip.transform + }; + if let Some(stabilization) = &clip.stabilization { + let correction = stabilization.sample(frame - clip.start_frame); + let scale = stabilization.crop_scale(render_size.width_f() / render_size.height_f()); + transform.center_x += correction.translation_x; + transform.center_y += correction.translation_y; + transform.width *= scale; + transform.height *= scale; + transform.rotation += correction.rotation_degrees; + } + transform +} + /// Build a [`ClipPlan`] for one selected clip. +#[allow(clippy::too_many_arguments)] fn make_clip_plan( clip: &Clip, track_index: usize, clip_index: usize, + blend_path: Vec, + compound_ancestors: Vec, + visible_start: i32, + visible_end: i32, + effective_trim_start: i32, sources: &dyn SourceMetrics, render_size: RenderSize, ) -> ClipPlan { @@ -111,24 +147,28 @@ fn make_clip_plan( }; ClipPlan { + clip: clip.clone(), clip_id: clip.id.clone(), track_index, clip_index, + blend_path, + compound_ancestors, source: texture_source_for(clip), - start_frame: clip.start_frame, - end_frame: clip.end_frame(), + start_frame: visible_start, + end_frame: visible_end, nat_size, preferred_transform, needs_premultiply, speed: clip.speed, reversed: clip.reversed, - trim_start_frame: clip.trim_start_frame, + trim_start_frame: effective_trim_start, media_type: clip.media_type, lottie_frame_count, // Advanced pixel-effect inputs, copied verbatim from the clip (frame- // independent this round). Drop a color grade that is the identity so the // compositor can skip it cheaply. color_grade: clip.color_grade.filter(|g| !g.is_identity()), + lut: clip.lut.clone(), chroma_key: clip.chroma_key, masks: clip.masks.clone(), effects: clip.effects.clone(), @@ -151,10 +191,84 @@ pub fn build_render_plan( render_size: RenderSize, sources: &dyn SourceMetrics, ) -> RenderPlan { + try_build_render_plan(timeline, render_size, sources) + .expect("timeline graph must be valid before building a render plan") +} + +/// Fail-closed render-plan construction used by preview, export, and agents. +pub fn try_build_render_plan( + timeline: &Timeline, + render_size: RenderSize, + sources: &dyn SourceMetrics, +) -> Result { + timeline.validate_nested_sequences()?; + validate_nested_render_constraints(timeline)?; let total_frames = timeline.total_frames(); let mut clip_plans: Vec = Vec::new(); let mut text_plans: Vec = Vec::new(); + let mut audio_clips: Vec = Vec::new(); + + let registry: HashMap<&str, &NestedSequence> = timeline + .nested_sequences + .iter() + .map(|sequence| (sequence.id.as_str(), sequence)) + .collect(); + collect_timeline_plans( + timeline, + ®istry, + 0, + None, + None, + &[], + &[], + render_size, + sources, + &mut clip_plans, + &mut text_plans, + ); + collect_nested_audio( + timeline, + ®istry, + 0, + None, + None, + false, + &[], + &mut audio_clips, + ); + + // Final blend order: bottom-to-top. Upstream keeps visual track 0 topmost, + // so higher track indexes draw first and lower indexes draw last. + clip_plans.sort_by(|a, b| { + b.blend_path + .cmp(&a.blend_path) + .then(a.start_frame.cmp(&b.start_frame)) + }); + + Ok(RenderPlan { + fps: timeline.fps, + render_size, + total_frames, + clip_plans, + text_plans, + audio_clips, + }) +} +#[allow(clippy::too_many_arguments)] +fn collect_timeline_plans( + timeline: &Timeline, + registry: &HashMap<&str, &NestedSequence>, + frame_offset: i32, + parent_start: Option, + parent_end: Option, + parent_blend_path: &[usize], + compound_ancestors: &[CompoundAncestor], + render_size: RenderSize, + sources: &dyn SourceMetrics, + clip_plans: &mut Vec, + text_plans: &mut Vec, +) { for (track_index, track) in timeline.tracks.iter().enumerate() { if track.hidden { continue; @@ -167,17 +281,79 @@ pub fn build_render_plan( order.sort_by_key(|&i| track.clips[i].start_frame); let mut prev_end_frame = i32::MIN; + let mut blend_path = parent_blend_path.to_vec(); + blend_path.push(track_index); for &clip_index in &order { let clip = &track.clips[clip_index]; + let unclamped_start = frame_offset.saturating_add(clip.start_frame); + let absolute_start = unclamped_start.max(parent_start.unwrap_or(i32::MIN)); + let absolute_end = frame_offset + .saturating_add(clip.end_frame()) + .min(parent_end.unwrap_or(i32::MAX)); + if absolute_end <= absolute_start { + continue; + } + + let clipped_left = absolute_start - unclamped_start; + let effective_trim_start = clip + .trim_start_frame + .saturating_add((clipped_left as f64 * clip.speed).round() as i32); + let mut mapped_clip = clip.clone(); + mapped_clip.start_frame = unclamped_start; + + // Apply the same overlap policy to compound and ordinary visual + // clips before recursively expanding the selected compound. + if clip.media_type != ClipType::Text { + if clip.duration_frames <= 0 || clip.start_frame < prev_end_frame { + continue; + } + prev_end_frame = clip.end_frame(); + } + + if let Some(sequence_id) = clip.nested_sequence_id.as_deref() { + let sequence = registry + .get(sequence_id) + .expect("validated nested reference must exist"); + let mut child_ancestors = compound_ancestors.to_vec(); + child_ancestors.push(CompoundAncestor { + clip: mapped_clip, + // Flattened leaves are already projected into the output + // canvas' normalized coordinate space. Compound transforms + // therefore operate on that output canvas as their source; + // using the authored child pixel size here would scale the + // same normalization a second time. + canvas_size: (render_size.width_f(), render_size.height_f()), + }); + collect_timeline_plans( + &sequence.timeline, + registry, + absolute_start.saturating_sub(effective_trim_start), + Some(absolute_start), + Some(absolute_end), + &blend_path, + &child_ancestors, + render_size, + sources, + clip_plans, + text_plans, + ); + continue; + } + if clip.media_type == ClipType::Text { // Text: no overlap skip, no audio gate; each text clip stands // alone (SPEC §4.2). Defensive: require a positive span. if clip.duration_frames > 0 { text_plans.push(make_clip_plan( - clip, + &mapped_clip, track_index, clip_index, + blend_path.clone(), + compound_ancestors.to_vec(), + absolute_start, + absolute_end, + effective_trim_start, sources, render_size, )); @@ -191,34 +367,143 @@ pub fn build_render_plan( } // Video-track de-dup (upstream L152 / L424). - if clip.duration_frames <= 0 || clip.start_frame < prev_end_frame { - continue; - } clip_plans.push(make_clip_plan( - clip, + &mapped_clip, track_index, clip_index, + blend_path.clone(), + compound_ancestors.to_vec(), + absolute_start, + absolute_end, + effective_trim_start, sources, render_size, )); - prev_end_frame = clip.end_frame(); } } +} - // Final blend order: bottom-to-top. Upstream keeps visual track 0 topmost, - // so higher track indexes draw first and lower indexes draw last. - clip_plans.sort_by(|a, b| { - b.track_index - .cmp(&a.track_index) - .then(a.start_frame.cmp(&b.start_frame)) - }); +fn validate_nested_render_constraints(timeline: &Timeline) -> Result<(), String> { + let mut timelines = Vec::with_capacity(timeline.nested_sequences.len() + 1); + timelines.push(timeline); + timelines.extend( + timeline + .nested_sequences + .iter() + .map(|sequence| &sequence.timeline), + ); + let compound_ids = timelines + .iter() + .flat_map(|candidate| candidate.tracks.iter()) + .flat_map(|track| &track.clips) + .filter(|clip| clip.nested_sequence_id.is_some()) + .map(|clip| clip.id.as_str()) + .collect::>(); + for candidate in timelines { + for clip in candidate.tracks.iter().flat_map(|track| &track.clips) { + if clip + .transition_out + .as_ref() + .is_some_and(|transition| compound_ids.contains(transition.to_clip_id.as_str())) + { + return Err(format!( + "transition into compound clip {} requires offscreen nesting", + clip.transition_out + .as_ref() + .expect("transition was matched") + .to_clip_id + )); + } + } + for clip in candidate + .tracks + .iter() + .flat_map(|track| track.clips.iter()) + .filter(|clip| clip.nested_sequence_id.is_some()) + { + if (clip.speed - 1.0).abs() > f64::EPSILON || clip.reversed { + return Err(format!( + "compound clip {} must use forward 1x playback", + clip.id + )); + } + if clip.crop != Default::default() + || clip.crop_track.is_some() + || clip.color_grade.is_some() + || clip.chroma_key.is_some() + || !clip.masks.is_empty() + || !clip.effects.is_empty() + || clip.transition_out.is_some() + { + return Err(format!( + "compound clip {} uses effects that require offscreen nesting", + clip.id + )); + } + } + } + Ok(()) +} - RenderPlan { - fps: timeline.fps, - render_size, - total_frames, - clip_plans, - text_plans, +#[allow(clippy::too_many_arguments)] +fn collect_nested_audio( + timeline: &Timeline, + registry: &HashMap<&str, &NestedSequence>, + frame_offset: i32, + parent_start: Option, + parent_end: Option, + parent_muted: bool, + compound_ancestors: &[Clip], + audio_clips: &mut Vec, +) { + for track in &timeline.tracks { + let muted = parent_muted || track.muted; + for clip in &track.clips { + let unclamped_start = frame_offset.saturating_add(clip.start_frame); + let absolute_start = unclamped_start.max(parent_start.unwrap_or(i32::MIN)); + let absolute_end = frame_offset + .saturating_add(clip.end_frame()) + .min(parent_end.unwrap_or(i32::MAX)); + if absolute_end <= absolute_start { + continue; + } + let clipped_left = absolute_start - unclamped_start; + let effective_trim_start = clip + .trim_start_frame + .saturating_add((clipped_left as f64 * clip.speed).round() as i32); + let mut mapped_gain_clip = clip.clone(); + mapped_gain_clip.start_frame = unclamped_start; + if let Some(sequence_id) = clip.nested_sequence_id.as_deref() { + let sequence = registry + .get(sequence_id) + .expect("validated nested reference must exist"); + let mut child_ancestors = compound_ancestors.to_vec(); + child_ancestors.push(mapped_gain_clip); + collect_nested_audio( + &sequence.timeline, + registry, + absolute_start.saturating_sub(effective_trim_start), + Some(absolute_start), + Some(absolute_end), + muted, + &child_ancestors, + audio_clips, + ); + continue; + } + if muted || !matches!(clip.media_type, ClipType::Audio | ClipType::Video) { + continue; + } + let mut flattened = clip.clone(); + flattened.start_frame = absolute_start; + flattened.duration_frames = absolute_end - absolute_start; + flattened.trim_start_frame = effective_trim_start; + audio_clips.push(AudioClipPlan { + clip: flattened, + gain_clip: mapped_gain_clip, + compound_ancestors: compound_ancestors.to_vec(), + }); + } } } @@ -276,7 +561,10 @@ fn eval_layer<'a>( if f < plan.start_frame || f >= plan.end_frame { return None; } - let opacity = clip.opacity_at(f); + let mut opacity = clip.opacity_at(f); + for ancestor in plan.compound_ancestors.iter().rev() { + opacity *= ancestor.clip.opacity_at(f); + } if opacity <= 0.0 { return None; // behavior-equivalent skip (SPEC §2.4 step 3). } @@ -285,15 +573,18 @@ fn eval_layer<'a>( // path uses `clip.transformAt(frame)` (which rebuilds top-left/size/rotation // and intentionally drops flip — matching domain `transform_at`). Replicate // that split so flip behaves exactly as upstream. - let transform = if clip.has_transform_animation() { - clip.transform_at(f) - } else { - clip.transform - }; - let affine = compose( + let transform = evaluated_transform(clip, f, render_size); + let mut affine = compose( plan.preferred_transform, affine_transform(&transform, plan.nat_size, render_size), ); + for ancestor in plan.compound_ancestors.iter().rev() { + let transform = evaluated_transform(&ancestor.clip, f, render_size); + affine = compose( + affine, + affine_transform(&transform, ancestor.canvas_size, render_size), + ); + } let crop_uv = crop_to_uv(clip.crop_at(f)); let source_frame = source_frame_index(plan, f); @@ -310,6 +601,54 @@ fn eval_layer<'a>( needs_premultiply: plan.needs_premultiply, clip_id: &plan.clip_id, color_grade: plan.color_grade.as_ref(), + lut: plan.lut.as_ref(), + chroma_key: plan.chroma_key.as_ref(), + masks: &plan.masks, + effects: &plan.effects, + }) +} + +/// Evaluate the incoming side of a cross dissolve before its nominal timeline +/// start. The first source frame is held during the dissolve, then regular +/// playback begins at the cut; this avoids reading outside the clip's source +/// window while preserving timeline duration and adjacency. +fn eval_transition_incoming<'a>( + plan: &'a ClipPlan, + clip: &Clip, + progress: f64, + render_size: RenderSize, +) -> Option> { + let sample_frame = plan.start_frame; + let mut opacity = clip.raw_opacity_at(sample_frame) * progress.clamp(0.0, 1.0); + for ancestor in plan.compound_ancestors.iter().rev() { + opacity *= ancestor.clip.opacity_at(sample_frame); + } + if opacity <= 0.0 { + return None; + } + let transform = evaluated_transform(clip, sample_frame, render_size); + let mut affine = compose( + plan.preferred_transform, + affine_transform(&transform, plan.nat_size, render_size), + ); + for ancestor in plan.compound_ancestors.iter().rev() { + let transform = evaluated_transform(&ancestor.clip, sample_frame, render_size); + affine = compose( + affine, + affine_transform(&transform, ancestor.canvas_size, render_size), + ); + } + Some(LayerDraw { + source: &plan.source, + source_frame: source_frame_index(plan, sample_frame), + affine, + nat_size: plan.nat_size, + crop_uv: crop_to_uv(clip.crop_at(sample_frame)), + opacity, + needs_premultiply: plan.needs_premultiply, + clip_id: &plan.clip_id, + color_grade: plan.color_grade.as_ref(), + lut: plan.lut.as_ref(), chroma_key: plan.chroma_key.as_ref(), masks: &plan.masks, effects: &plan.effects, @@ -322,21 +661,51 @@ impl RenderPlan { /// `timeline` must be the same one the plan was built from (they share clip /// indices). Video clips composite first; text clips composite last (on /// top), matching upstream's text-over-video layering (SPEC §4.2). - pub fn frame<'a>(&'a self, timeline: &'a Timeline, f: i32) -> FramePlan<'a> { + pub fn frame<'a>(&'a self, _timeline: &'a Timeline, f: i32) -> FramePlan<'a> { let mut draws: Vec> = Vec::new(); - for plan in &self.clip_plans { - let Some(clip) = clip_for(timeline, plan) else { - continue; - }; + for (index, plan) in self.clip_plans.iter().enumerate() { + let clip = &plan.clip; + let transition = clip.transition_out.as_ref().and_then(|transition| { + let incoming_plan = self.clip_plans.get(index + 1)?; + if transition.kind != TransitionKind::CrossDissolve + || (!transition.from_clip_id.is_empty() + && transition.from_clip_id != plan.clip_id) + || incoming_plan.track_index != plan.track_index + || incoming_plan.clip_id != transition.to_clip_id + || incoming_plan.start_frame != plan.end_frame + { + return None; + } + let incoming = &incoming_plan.clip; + let duration = transition + .duration_frames + .max(1) + .min(clip.duration_frames.max(1)) + .min(incoming.duration_frames.max(1)); + let start = plan.end_frame - duration; + if f < start || f >= plan.end_frame { + return None; + } + let progress = (f - start) as f64 / duration as f64; + Some((incoming_plan, incoming, progress)) + }); + if let Some(d) = eval_layer(plan, clip, f, self.render_size) { - draws.push(d); + if d.opacity > 0.0 { + draws.push(d); + } + } + if let Some((incoming_plan, incoming, progress)) = transition { + if let Some(d) = + eval_transition_incoming(incoming_plan, incoming, progress, self.render_size) + { + draws.push(d); + } } } for plan in &self.text_plans { - let Some(clip) = clip_for(timeline, plan) else { - continue; - }; + let clip = &plan.clip; if let Some(d) = eval_layer(plan, clip, f, self.render_size) { draws.push(d); } @@ -348,19 +717,3 @@ impl RenderPlan { } } } - -/// Resolve the `&Clip` for a plan via its stored indices, falling back to an id -/// search if the indices no longer line up (defensive; the indexed path is the -/// fast one per SPEC §2.4). -fn clip_for<'a>(timeline: &'a Timeline, plan: &ClipPlan) -> Option<&'a Clip> { - if let Some(track) = timeline.tracks.get(plan.track_index) { - if let Some(clip) = track.clips.get(plan.clip_index) { - if clip.id == plan.clip_id { - return Some(clip); - } - } - // Indices drifted: fall back to id lookup within the track. - return track.clips.iter().find(|c| c.id == plan.clip_id); - } - None -} diff --git a/crates/opentake-render/src/plan/mod.rs b/crates/opentake-render/src/plan/mod.rs index e5a2cb68..f0c7aedf 100644 --- a/crates/opentake-render/src/plan/mod.rs +++ b/crates/opentake-render/src/plan/mod.rs @@ -10,5 +10,8 @@ pub mod types; mod tests; pub use affine::{affine_transform, compose, crop_to_uv}; -pub use build::{build_render_plan, source_frame_index}; -pub use types::{ClipPlan, FramePlan, LayerDraw, RenderPlan, RenderSize, TextureSource}; +pub use build::{build_render_plan, source_frame_index, try_build_render_plan}; +pub use types::{ + AudioClipPlan, ClipPlan, CompoundAncestor, FramePlan, LayerDraw, RenderPlan, RenderSize, + TextureSource, +}; diff --git a/crates/opentake-render/src/plan/tests.rs b/crates/opentake-render/src/plan/tests.rs index d37984a1..d17d079b 100644 --- a/crates/opentake-render/src/plan/tests.rs +++ b/crates/opentake-render/src/plan/tests.rs @@ -3,7 +3,7 @@ use opentake_domain::{ AnimPair, Clip, ClipType, Crop, Interpolation, Keyframe, KeyframeTrack, Point, Timeline, Track, - Transform, + Transform, Transition, TransitionKind, }; use super::affine::affine_transform; @@ -76,6 +76,36 @@ const RS: RenderSize = RenderSize { height: 1080, }; +#[test] +fn cross_dissolve_emits_two_weighted_layers_before_the_cut() { + let mut a = video_clip("a", 0, 30); + a.transition_out = Some(Transition { + from_clip_id: "a".into(), + to_clip_id: "b".into(), + kind: TransitionKind::CrossDissolve, + duration_frames: 10, + }); + let b = video_clip("b", 30, 30); + let mut tl = Timeline::new(); + let mut track = Track::new("v", ClipType::Video); + track.clips = vec![a, b]; + tl.tracks.push(track); + let plan = build_render_plan(&tl, RS, &TestMetrics::default()); + + let midpoint = plan.frame(&tl, 25); + assert_eq!(midpoint.draws.len(), 2); + assert_eq!(midpoint.draws[0].clip_id, "a"); + assert_eq!(midpoint.draws[1].clip_id, "b"); + approx(midpoint.draws[0].opacity, 1.0); + approx(midpoint.draws[1].opacity, 0.5); + assert_eq!(midpoint.draws[1].source_frame, 0); + + let after_cut = plan.frame(&tl, 30); + assert_eq!(after_cut.draws.len(), 1); + assert_eq!(after_cut.draws[0].clip_id, "b"); + approx(after_cut.draws[0].opacity, 1.0); +} + // --- Single clip, no transform: full-canvas identity-ish affine --- #[test] diff --git a/crates/opentake-render/src/plan/types.rs b/crates/opentake-render/src/plan/types.rs index a4635e87..3d78a36a 100644 --- a/crates/opentake-render/src/plan/types.rs +++ b/crates/opentake-render/src/plan/types.rs @@ -13,7 +13,7 @@ //! The black background is NOT a clip here — it is the compositor clear color //! `(0,0,0,1)` (SPEC §3.5). -use opentake_domain::{ChromaKey, ClipType, ColorGrade, Effect, Mask}; +use opentake_domain::{ChromaKey, Clip, ClipType, ColorGrade, Effect, LutReference, Mask}; /// Canvas pixel size (already even-ized; see [`crate::size`]). #[derive(Clone, Copy, PartialEq, Eq, Debug)] @@ -51,21 +51,57 @@ pub enum TextureSource { Image { media_ref: String }, /// Lottie: a texture per "Lottie internal frame" (content-hash cached). Lottie { media_ref: String }, - /// Text: rasterized for this clip at the canvas size (content-hash cached, - /// key = style + content + canvas). + /// Text clip identity: rasterized at the canvas size. The resolver reads + /// content/style from the same timeline snapshot and includes style, + /// content, and canvas in its cache key, so an edited clip cannot reuse + /// stale pixels based on id alone. Text { clip_id: String }, } +/// One compound clip surrounding a flattened leaf, plus the child canvas size +/// that clip transforms as its source. +#[derive(Clone, PartialEq, Debug)] +pub struct CompoundAncestor { + pub clip: Clip, + pub canvas_size: (f64, f64), +} + +/// One audio-bearing leaf projected into root timeline coordinates. `clip` +/// owns the visible source window used for decode/placement, while `gain_clip` +/// and `compound_ancestors` retain unclipped timing for frame-accurate volume +/// keyframe and fade sampling. +#[derive(Clone, PartialEq, Debug)] +pub struct AudioClipPlan { + pub clip: Clip, + pub gain_clip: Clip, + pub compound_ancestors: Vec, +} + +impl AudioClipPlan { + pub fn volume_at(&self, frame: i32) -> f64 { + self.compound_ancestors + .iter() + .fold(self.gain_clip.volume_at(frame), |gain, ancestor| { + gain * ancestor.volume_at(frame) + }) + } +} + /// Static (frame-independent) render description for one clip. #[derive(Clone, PartialEq, Debug)] pub struct ClipPlan { + /// Immutable clip snapshot owned by this plan. This lets recursively + /// flattened nested clips use the exact same plan in preview and export + /// without indexing back into a different timeline tree. + pub clip: Clip, pub clip_id: String, - /// Index of the timeline track this clip belongs to (blend order; SPEC §1.5). + /// Flattened track index retained for diagnostics and compatibility. pub track_index: usize, - /// Index of the clip inside `timeline.tracks[track_index].clips`, so - /// [`RenderPlan::frame`](crate::plan::RenderPlan::frame) can fetch the `&Clip` - /// without a string search (SPEC §2.4 note). + /// Index of the source clip inside its immediate timeline track. pub clip_index: usize, + /// Root-to-leaf track path used for deterministic nested blend order. + pub blend_path: Vec, + pub compound_ancestors: Vec, pub source: TextureSource, pub start_frame: i32, /// Half-open end (`start_frame + duration_frames`). @@ -96,6 +132,8 @@ pub struct ClipPlan { // shader; the pure pixel math lives in `opentake_domain::grade`. /// Linear-light color grade, or `None` when the clip has no grade. pub color_grade: Option, + /// Project-managed 3D LUT applied after the primary grade. + pub lut: Option, /// Chroma key, or `None` when the clip has no keying. pub chroma_key: Option, /// Vector masks (intersected coverage). Empty = no masking. @@ -121,6 +159,10 @@ pub struct RenderPlan { /// ExportService L237-248; SPEC §4.2). Ordered by appearance, NOT deduped /// per track (upstream collects text clips without the per-track skip rule). pub text_plans: Vec, + /// Audio-bearing leaf clips flattened through the same nested timing map. + /// Export consumes this list so compound preview/video and audio share + /// identical in/out boundaries. + pub audio_clips: Vec, } /// One draw after evaluating a single frame (instantaneous). @@ -153,6 +195,8 @@ pub struct LayerDraw<'a> { /// Color grade applied in-shader (linear-light chain), borrowed from the /// [`ClipPlan`]. `None` = no grade. pub color_grade: Option<&'a ColorGrade>, + /// Project-managed 3D LUT applied after the primary grade. + pub lut: Option<&'a LutReference>, /// Chroma key applied in-shader, borrowed from the [`ClipPlan`]. `None` = none. pub chroma_key: Option<&'a ChromaKey>, /// Masks applied in-shader (intersected coverage), borrowed from the diff --git a/crates/opentake-render/tests/composite_acceptance.rs b/crates/opentake-render/tests/composite_acceptance.rs new file mode 100644 index 00000000..a9361659 --- /dev/null +++ b/crates/opentake-render/tests/composite_acceptance.rs @@ -0,0 +1,163 @@ +const EVIDENCE: &str = include_str!( + "../../../docs/audit/2026-07-14/runtime-artifacts/automated/hdr-proxy-account-real-device-2026-08-01.md" +); +const GPU_CHILDREN: &str = include_str!("gpu_effects.rs"); +const COMPOSITOR: &str = include_str!("../src/gpu/compositor.rs"); +const RENDER_OVERVIEW: &str = include_str!("../../../docs/modules/opentake-render/OVERVIEW.md"); +const MEDIA_PRINCIPLES: &str = include_str!("../../../docs/specs/media/0-principles.md"); +const MEDIA_DOMAIN_CONTRACT: &str = include_str!("../../../docs/specs/media/9-domain-contract.md"); +const MEDIA_FFMPEG_CHILDREN: &str = + include_str!("../../opentake-media/tests/ffmpeg_integration.rs"); +const MEDIA_ENGINE_SOURCE: &str = include_str!("../../opentake-media/src/lib.rs"); +const MEDIA_INDEX_SOURCE: &str = include_str!("../../opentake-media/src/index_coordinator.rs"); + +use opentake_domain::{ + Clip, ClipType, Effect, Mask, MaskShape, Point2, Timeline, Track, MAX_MASKS_PER_CLIP, + MAX_POLYGON_MASK_POINTS, +}; +use opentake_ops::{apply, EditCommand, EditError, EditorState, SeqIdGen}; + +#[test] +fn hdr_proxy_account_children_close_one_composite_acceptance() { + let evidence = EVIDENCE.replace("\r\n", "\n"); + for child in [ + "HDR child result: **PASS**", + "Proxy child result: **PASS**", + "Account child result: **PASS**", + ] { + assert!(evidence.contains(child), "missing child evidence: {child}"); + } + assert!(evidence.contains("`HDR child PASS + proxy child PASS + account child PASS`")); + assert!(evidence.contains("closes one composite\nacceptance")); + assert!(evidence.contains("codesign --verify --deep --strict")); + assert!(evidence.contains("This is not\nan HDR-passthrough claim.")); + assert!(evidence.contains("Export therefore used the original source, not the enabled proxy.")); + assert!(evidence.contains("Local editing remains the default")); +} + +fn state_with_visual_clip() -> EditorState { + let mut timeline = Timeline::new(); + let mut track = Track::new("video", ClipType::Video); + track.clips.push(Clip::new("clip", "asset", 0, 30)); + timeline.tracks.push(track); + EditorState::from_timeline(timeline) +} + +#[test] +fn mask_and_effect_records_have_separate_child_owners() { + // The mixed audit record is closed by two executable pixel owners, rather + // than by duplicating either renderer in this aggregation test. + for owner in [ + "linear_circle_and_polygon_masks_match_cpu_reference_in_preview_and_export", + "advertised_effect_registry_has_preview_export_golden_fixtures", + ] { + assert!( + GPU_CHILDREN.contains(owner), + "missing executable child owner: {owner}" + ); + } + for boundary in [ + "fn pack_masks", + "MAX_POLYGON_MASK_POINTS", + "fn pack_effects", + "validate_effect_chain(draw.effects)", + ] { + assert!( + COMPOSITOR.contains(boundary), + "missing compositor boundary: {boundary}" + ); + } + + // Authored overflow and unknown registry entries fail before mutation. + let ids = SeqIdGen::default(); + let mut state = state_with_visual_clip(); + let original = state.timeline.clone(); + let too_many = vec![Mask::default(); MAX_MASKS_PER_CLIP + 1]; + let error = apply( + &mut state, + EditCommand::SetMasks { + clip_ids: vec!["clip".into()], + masks: too_many, + }, + &ids, + ) + .unwrap_err(); + assert!(matches!(error, EditError::Invalid(message) if message.contains("at most"))); + assert_eq!(state.timeline, original); + assert_eq!(state.undo_depth(), 0); + + let polygon = Mask { + shape: MaskShape::Poly { + points: vec![Point2::new(0.5, 0.5); MAX_POLYGON_MASK_POINTS + 1], + }, + ..Mask::default() + }; + let error = apply( + &mut state, + EditCommand::SetMasks { + clip_ids: vec!["clip".into()], + masks: vec![polygon], + }, + &ids, + ) + .unwrap_err(); + assert!(matches!(error, EditError::Invalid(message) if message.contains("polygon"))); + assert_eq!(state.timeline, original); + + let error = apply( + &mut state, + EditCommand::SetEffects { + clip_ids: vec!["clip".into()], + effects: vec![Effect::new("unadvertised")], + }, + &ids, + ) + .unwrap_err(); + assert!(matches!(error, EditError::Invalid(message) if message.contains("unknown effect"))); + assert_eq!(state.timeline, original); + + assert!(RENDER_OVERVIEW.contains("多边形(钢笔)蒙版与通用 Effect 链已落地")); + assert!(!RENDER_OVERVIEW.contains("编码为全画布 no-op")); +} + +#[test] +fn media_principles_headings_reference_exact_child_capabilities() { + assert!(MEDIA_PRINCIPLES.starts_with("# 设计原则与移植铁律(本 crate 必须遵守)")); + assert!(MEDIA_DOMAIN_CONTRACT.starts_with("# 跨平台与合规要点")); + for document in [MEDIA_PRINCIPLES, MEDIA_DOMAIN_CONTRACT] { + assert!(document.contains("可执行子能力集合")); + for child in [ + "probe_reports_dimensions_fps_and_audio", + "decode_frame_returns_rgba_of_expected_size", + "extract_pcm_yields_16k_mono", + "waveform_has_expected_bucket_count", + "encode_roundtrip_produces_playable_video", + "export_pause_ref_counts", + ] { + assert!(document.contains(child), "missing child reference: {child}"); + } + } + + for child in [ + "fn probe_reports_dimensions_fps_and_audio", + "fn decode_frame_returns_rgba_of_expected_size", + "fn extract_pcm_yields_16k_mono", + "fn waveform_has_expected_bucket_count", + "fn encode_roundtrip_produces_playable_video", + ] { + assert!( + MEDIA_FFMPEG_CHILDREN.contains(child), + "missing executable media child: {child}" + ); + } + assert!(MEDIA_ENGINE_SOURCE.contains("seconds (f64) at every IO")); + assert!(MEDIA_ENGINE_SOURCE.contains("pub struct MediaEngine")); + assert!(MEDIA_INDEX_SOURCE.contains("fn export_pause_ref_counts")); + + // The compliance collection must describe the actual subprocess-sidecar + // architecture and preserve the known release blocker instead of claiming + // dynamic linking or a completed public distribution review. + assert!(MEDIA_DOMAIN_CONTRACT.contains("FFmpeg 子进程 sidecar")); + assert!(MEDIA_DOMAIN_CONTRACT.contains("Beta 发布阻塞")); + assert!(!MEDIA_DOMAIN_CONTRACT.contains("动态链接 + NOTICE")); +} diff --git a/crates/opentake-render/tests/compound_render.rs b/crates/opentake-render/tests/compound_render.rs new file mode 100644 index 00000000..440ea7a0 --- /dev/null +++ b/crates/opentake-render/tests/compound_render.rs @@ -0,0 +1,128 @@ +use opentake_domain::{Clip, ClipType, NestedSequence, Timeline, Track}; +use opentake_render::{try_build_render_plan, RenderSize, SourceMetrics}; + +struct Metrics; + +impl SourceMetrics for Metrics { + fn natural_size(&self, _media_ref: &str) -> Option<(u32, u32)> { + Some((64, 64)) + } +} + +fn video_track(id: &str, clips: Vec) -> Track { + let mut track = Track::new(id, ClipType::Video); + track.clips = clips; + track +} + +#[test] +fn compound_clip_preview_export_frames_match() { + let mut leaf = Clip::new("leaf", "asset-a", 3, 10); + leaf.trim_start_frame = 2; + let mut sequence_b = Timeline::new(); + sequence_b.tracks = vec![video_track("b-track", vec![leaf])]; + + let mut nested_b = Clip::new_nested("nested-b", "sequence-b", 5, 8); + nested_b.trim_start_frame = 3; + let mut sequence_a = Timeline::new(); + sequence_a.tracks = vec![video_track("a-track", vec![nested_b])]; + + let mut root = Timeline::new(); + root.nested_sequences = vec![ + NestedSequence::new("sequence-a", "A", sequence_a), + NestedSequence::new("sequence-b", "B", sequence_b), + ]; + let mut compound = Clip::new_nested("compound", "sequence-a", 20, 6); + compound.trim_start_frame = 5; + compound.opacity = 0.5; + compound.transform.width = 0.5; + compound.transform.height = 0.5; + root.tracks = vec![video_track("root-track", vec![compound])]; + + let preview = try_build_render_plan(&root, RenderSize::new(64, 64), &Metrics).unwrap(); + let export = try_build_render_plan(&root, RenderSize::new(64, 64), &Metrics).unwrap(); + for frame in 19..=27 { + assert_eq!(preview.frame(&root, frame), export.frame(&root, frame)); + } + assert!(preview.frame(&root, 19).draws.is_empty()); + assert_eq!(preview.frame(&root, 20).draws[0].source_frame, 2); + assert_eq!(preview.frame(&root, 20).draws[0].opacity, 0.5); + assert_eq!( + preview.frame(&root, 20).draws[0].affine, + [0.5, 0.0, 0.0, 0.5, 16.0, 16.0] + ); + assert_eq!(preview.frame(&root, 25).draws[0].source_frame, 7); + assert!(preview.frame(&root, 26).draws.is_empty()); +} + +#[test] +fn compound_trim_preserves_inner_fade_sampling_offset() { + let mut leaf = Clip::new("leaf", "asset-a", 0, 20); + leaf.fade_in_frames = 20; + let mut child = Timeline::new(); + child.tracks = vec![video_track("child-track", vec![leaf])]; + + let mut root = Timeline::new(); + root.nested_sequences + .push(NestedSequence::new("sequence", "Sequence", child)); + let mut compound = Clip::new_nested("compound", "sequence", 100, 5); + compound.trim_start_frame = 10; + root.tracks = vec![video_track("root-track", vec![compound])]; + + let plan = try_build_render_plan(&root, RenderSize::new(64, 64), &Metrics).unwrap(); + let first = &plan.frame(&root, 100).draws[0]; + assert_eq!(first.source_frame, 10); + assert_eq!(first.opacity, 0.5); +} + +#[test] +fn unsupported_compound_retime_fails_before_preview_or_export() { + let mut child = Timeline::new(); + child.tracks = vec![video_track( + "child-track", + vec![Clip::new("leaf", "asset-a", 0, 10)], + )]; + let mut root = Timeline::new(); + root.nested_sequences + .push(NestedSequence::new("sequence", "Sequence", child)); + let mut compound = Clip::new_nested("compound", "sequence", 0, 10); + compound.speed = 2.0; + root.tracks = vec![video_track("root-track", vec![compound])]; + + assert_eq!( + try_build_render_plan(&root, RenderSize::new(64, 64), &Metrics).unwrap_err(), + "compound clip compound must use forward 1x playback" + ); +} + +#[test] +fn compound_audio_uses_the_same_trimmed_root_span() { + let mut audio = Clip::new("audio", "asset-a", 4, 10); + audio.media_type = ClipType::Audio; + audio.source_clip_type = ClipType::Audio; + audio.trim_start_frame = 2; + audio.fade_in_frames = 10; + let mut child = Timeline::new(); + child.tracks = vec![video_track("unused", vec![])]; + let mut audio_track = Track::new("audio-track", ClipType::Audio); + audio_track.clips.push(audio); + child.tracks.push(audio_track); + + let mut root = Timeline::new(); + root.nested_sequences + .push(NestedSequence::new("sequence", "Sequence", child)); + let mut compound = Clip::new_nested("compound", "sequence", 20, 5); + compound.trim_start_frame = 6; + compound.volume = 0.5; + compound.fade_in_frames = 5; + root.tracks = vec![video_track("root-track", vec![compound])]; + + let plan = try_build_render_plan(&root, RenderSize::new(64, 64), &Metrics).unwrap(); + assert_eq!(plan.audio_clips.len(), 1); + let flattened = &plan.audio_clips[0]; + assert_eq!(flattened.clip.start_frame, 20); + assert_eq!(flattened.clip.duration_frames, 5); + assert_eq!(flattened.clip.trim_start_frame, 4); + assert_eq!(flattened.volume_at(20), 0.0); + assert!((flattened.volume_at(22) - 0.08).abs() < 1e-12); +} diff --git a/crates/opentake-render/tests/gpu_effects.rs b/crates/opentake-render/tests/gpu_effects.rs index 5ec32236..91d72380 100644 --- a/crates/opentake-render/tests/gpu_effects.rs +++ b/crates/opentake-render/tests/gpu_effects.rs @@ -10,15 +10,16 @@ use std::rc::Rc; use opentake_domain::{ - ChromaKey, Clip, ClipType, ColorGrade, Mask, MaskShape, Point, Point2, Rgb, Timeline, Track, - Transform, + effect_registry, ChromaKey, Clip, ClipType, ColorGrade, Effect, EffectValidationError, + HslSecondary, LiftGammaGain, Mask, MaskShape, MaskTransform, Point, Point2, Rgb, Timeline, + Track, Transform, }; use opentake_render::gpu::texture::upload_rgba; use opentake_render::source::DecodedFrame; use opentake_render::wgpu; use opentake_render::{ - build_render_plan, Compositor, GpuTexture, RenderDevice, RenderSize, SourceMetrics, - TextureResolver, TextureSource, + build_render_plan, Compositor, GpuTexture, RenderDevice, RenderError, RenderSize, + SourceMetrics, TextureResolver, TextureSource, }; const RS: RenderSize = RenderSize { @@ -43,6 +44,39 @@ struct SolidResolver<'d> { cached: Option>, } +/// Four equal-width chart bars: red, orange, green and blue. This lets the HSL +/// qualifier test an in-range hue, its feather boundary and two isolated hues +/// in one real compositor submission. +struct ColorChartResolver<'d> { + device: &'d wgpu::Device, + queue: &'d wgpu::Queue, + cached: Option>, +} + +impl TextureResolver for ColorChartResolver<'_> { + fn resolve(&mut self, _source: &TextureSource, _frame: i64) -> Option> { + if self.cached.is_none() { + let colors = [ + [255, 0, 0, 255], + [255, 200, 0, 255], + [0, 255, 0, 255], + [0, 0, 255, 255], + ]; + let mut buf = vec![0u8; 16 * 16 * 4]; + for y in 0..16 { + for x in 0..16 { + let i = (y * 16 + x) * 4; + buf[i..i + 4].copy_from_slice(&colors[x / 4]); + } + } + let frame = DecodedFrame::new(16, 16, buf, true); + let tex = upload_rgba(self.device, self.queue, &frame, false, Some("hsl-chart")); + self.cached = Some(Rc::new(tex)); + } + self.cached.clone() + } +} + impl TextureResolver for SolidResolver<'_> { fn resolve(&mut self, _source: &TextureSource, _frame: i64) -> Option> { if self.cached.is_none() { @@ -118,6 +152,115 @@ fn render(dev: &RenderDevice, tl: &Timeline, rgba: [u8; 4]) -> DecodedFrame { .expect("render") } +fn render_color_chart(dev: &RenderDevice, tl: &Timeline) -> DecodedFrame { + let plan = build_render_plan(tl, RS, &Metrics); + let fp = plan.frame(tl, 0); + let compositor = Compositor::new(&dev.device); + let mut resolver = ColorChartResolver { + device: &dev.device, + queue: &dev.queue, + cached: None, + }; + compositor + .render_to_rgba(&dev.device, &dev.queue, RS, &fp, &mut resolver) + .expect("render color chart") +} + +#[test] +fn advertised_effect_registry_has_preview_export_golden_fixtures() { + let Some(dev) = device_or_skip("advertised_effect_registry_has_preview_export_golden_fixtures") + else { + return; + }; + + let registry = effect_registry(); + assert_eq!( + registry + .iter() + .map(|effect| effect.name) + .collect::>(), + ["grayscale", "sepia", "invert"] + ); + + // Golden center pixels for a fixed opaque source. Each advertised effect is + // exercised at its persisted default and a non-default amount. Preview and + // export deliberately render through fresh resolver state and must agree + // byte-for-byte. + let fixtures = [ + ("grayscale", None, [81, 81, 81, 255]), + ("grayscale", Some(0.4), [164, 50, 140, 255]), + ("sepia", None, [144, 128, 99, 255]), + ("sepia", Some(0.4), [189, 69, 148, 255]), + ("invert", None, [35, 225, 75, 255]), + ("invert", Some(0.4), [146, 108, 138, 255]), + ]; + for (name, amount, golden) in fixtures { + let mut timeline = full_canvas_timeline(); + let effect = amount.map_or_else( + || Effect::new(name), + |value| Effect::new(name).with_param("amount", value), + ); + effect.validate().expect("advertised effect validates"); + timeline.tracks[0].clips[0].effects = vec![effect]; + + let preview = render(&dev, &timeline, [220, 30, 180, 255]); + let export = render(&dev, &timeline, [220, 30, 180, 255]); + assert_eq!(preview.rgba, export.rgba, "preview/export drift for {name}"); + let actual = center_pixel(&preview); + for channel in 0..4 { + assert!( + (actual[channel] as i32 - golden[channel]).abs() <= 3, + "{name} amount={amount:?}: expected {golden:?}, got {actual:?}" + ); + } + } + + // The sequence itself is authored state: changing order must change pixels. + let mut first = full_canvas_timeline(); + first.tracks[0].clips[0].effects = vec![Effect::new("sepia"), Effect::new("invert")]; + let mut second = full_canvas_timeline(); + second.tracks[0].clips[0].effects = vec![Effect::new("invert"), Effect::new("sepia")]; + assert_ne!( + center_pixel(&render(&dev, &first, [220, 30, 180, 255])), + center_pixel(&render(&dev, &second, [220, 30, 180, 255])), + "effect order must be rendered, not stored as inert metadata" + ); + + // Disabled registered effects remain persisted but are skipped by the + // render chain in both preview and export. + let source = [220, 30, 180, 255]; + let mut disabled = full_canvas_timeline(); + disabled.tracks[0].clips[0].effects = vec![Effect { + enabled: false, + ..Effect::new("invert") + }]; + let baseline = render(&dev, &full_canvas_timeline(), source); + let disabled_preview = render(&dev, &disabled, source); + let disabled_export = render(&dev, &disabled, source); + assert_eq!(disabled_preview.rgba, baseline.rgba); + assert_eq!(disabled_export.rgba, baseline.rgba); + + let mut invalid = full_canvas_timeline(); + invalid.tracks[0].clips[0].effects = vec![Effect::new("unadvertised")]; + let plan = build_render_plan(&invalid, RS, &Metrics); + let frame_plan = plan.frame(&invalid, 0); + let compositor = Compositor::new(&dev.device); + let mut resolver = SolidResolver { + device: &dev.device, + queue: &dev.queue, + rgba: [220, 30, 180, 255], + cached: None, + }; + let error = compositor + .render_to_rgba(&dev.device, &dev.queue, RS, &frame_plan, &mut resolver) + .expect_err("unknown effects must fail instead of rendering unchanged"); + assert!(matches!( + error, + RenderError::InvalidEffect(EffectValidationError::UnknownEffect { ref name }) + if name == "unadvertised" + )); +} + #[test] fn color_grade_zero_saturation_greyscales() { let Some(dev) = device_or_skip("color_grade_zero_saturation_greyscales") else { @@ -188,6 +331,150 @@ fn color_grade_identity_is_passthrough() { } } +#[test] +fn lift_gamma_gain_matches_cpu_reference() { + let grade = ColorGrade { + lift_gamma_gain: LiftGammaGain { + lift: Rgb::new(0.08, -0.03, 0.12), + gamma: Rgb::new(1.8, 0.75, 1.25), + gain: Rgb::new(0.82, 1.15, 0.93), + }, + ..Default::default() + }; + let source = [96_u8, 128, 192, 255]; + let linear = source[..3] + .iter() + .map(|channel| opentake_render::gpu::srgb_to_linear(f64::from(*channel) / 255.0)) + .collect::>(); + + let source_formula = |x: f64, lift: f64, gamma: f64, gain: f64| { + gain * (x + lift * (1.0 - x)).max(0.0).powf(1.0 / gamma) + }; + let expected_linear = [ + source_formula(linear[0], 0.08, 1.8, 0.82), + source_formula(linear[1], -0.03, 0.75, 1.15), + source_formula(linear[2], 0.12, 1.25, 0.93), + ]; + let cpu = grade.apply_linear(linear[0], linear[1], linear[2]); + for (actual, expected) in [cpu.0, cpu.1, cpu.2].into_iter().zip(expected_linear) { + assert!( + (actual - expected).abs() < 1e-9, + "CPU color-wheel reference drift: expected {expected}, got {actual}" + ); + } + + let Some(dev) = device_or_skip("lift_gamma_gain_matches_cpu_reference") else { + return; + }; + let mut timeline = full_canvas_timeline(); + timeline.tracks[0].clips[0].color_grade = Some(grade); + let preview = render(&dev, &timeline, source); + let export = render(&dev, &timeline, source); + assert_eq!(preview.rgba, export.rgba, "preview/export LGG drift"); + + let expected = expected_linear.map(|channel| { + (opentake_render::gpu::linear_to_srgb(channel.clamp(0.0, 1.0)) * 255.0).round() as u8 + }); + let actual = center_pixel(&preview); + for channel in 0..3 { + assert!( + (i16::from(actual[channel]) - i16::from(expected[channel])).abs() <= 2, + "GPU LGG channel {channel}: expected {expected:?}, got {actual:?}" + ); + } + assert_eq!(actual[3], 255); + + // A malformed persisted grade is rejected before source resolution or any + // GPU submission, so preview/export cannot silently diverge or render an + // unchanged frame. + let mut invalid = full_canvas_timeline(); + invalid.tracks[0].clips[0].color_grade = Some(ColorGrade { + lift_gamma_gain: LiftGammaGain { + gamma: Rgb::new(0.0, 1.0, 1.0), + ..Default::default() + }, + ..Default::default() + }); + let invalid_plan = build_render_plan(&invalid, RS, &Metrics); + let invalid_frame = invalid_plan.frame(&invalid, 0); + let compositor = Compositor::new(&dev.device); + let mut resolver = SolidResolver { + device: &dev.device, + queue: &dev.queue, + rgba: source, + cached: None, + }; + let error = compositor + .render_to_rgba(&dev.device, &dev.queue, RS, &invalid_frame, &mut resolver) + .expect_err("zero gamma must be rejected before source resolution"); + assert!(matches!( + error, + RenderError::InvalidColorGrade(ref invalid) + if invalid.to_string() + == "liftGammaGain.gamma.r must be finite and within (0, 4]" + )); + assert!( + resolver.cached.is_none(), + "invalid grade resolved source data" + ); +} + +#[test] +fn hsl_secondary_hue_boundary_feather_and_isolation() { + let grade = ColorGrade { + hsl_secondary: Some(HslSecondary { + hue_center: 0.0, + hue_width: 0.24, + feather: 0.08, + hue_shift: 0.20, + saturation: -0.25, + lightness: 0.10, + }), + ..Default::default() + }; + grade.validate().expect("bounded HSL secondary validates"); + + // Persisted authored state must survive a save/reopen boundary exactly. + let json = serde_json::to_string(&grade).expect("serialize HSL secondary"); + let reopened: ColorGrade = serde_json::from_str(&json).expect("reopen HSL secondary"); + assert_eq!(reopened, grade); + + let Some(dev) = device_or_skip("hsl_secondary_hue_boundary_feather_and_isolation") else { + return; + }; + let plain = render_color_chart(&dev, &full_canvas_timeline()); + let mut timeline = full_canvas_timeline(); + timeline.tracks[0].clips[0].color_grade = Some(grade); + let preview = render_color_chart(&dev, &timeline); + let export = render_color_chart(&dev, &timeline); + assert_eq!(preview.rgba, export.rgba, "preview/export HSL drift"); + + let delta = |x: u32| { + let before = pixel_at(&plain, x, 8); + let after = pixel_at(&preview, x, 8); + before[..3] + .iter() + .zip(&after[..3]) + .map(|(a, b)| (i16::from(*a) - i16::from(*b)).unsigned_abs()) + .max() + .unwrap() + }; + let selected_red = delta(2); + let feathered_orange = delta(6); + let isolated_green = delta(10); + let isolated_blue = delta(14); + assert!( + selected_red > 20, + "selected red did not change: {selected_red}" + ); + assert!( + feathered_orange > 2 && feathered_orange < selected_red, + "orange must receive only the feathered adjustment: red={selected_red}, orange={feathered_orange}" + ); + assert!(isolated_green <= 2, "green leaked by {isolated_green}"); + assert!(isolated_blue <= 2, "blue leaked by {isolated_blue}"); +} + #[test] fn chroma_key_removes_green() { let Some(dev) = device_or_skip("chroma_key_removes_green") else { @@ -238,6 +525,7 @@ fn circle_mask_clips_to_center() { }, feather: 0.0, invert: false, + ..Mask::default() }]; // White source, masked to a small centered circle over black. let frame = render(&dev, &tl, [255, 255, 255, 255]); @@ -266,6 +554,7 @@ fn inverted_mask_clips_out_center() { }, feather: 0.0, invert: true, + ..Mask::default() }]; let frame = render(&dev, &tl, [255, 255, 255, 255]); // Inverted: center is now masked OUT -> black. @@ -281,3 +570,116 @@ fn inverted_mask_clips_out_center() { "corner kept by inverted mask, got {corner:?}" ); } + +#[test] +fn linear_circle_and_polygon_masks_match_cpu_reference_in_preview_and_export() { + let Some(dev) = + device_or_skip("linear_circle_and_polygon_masks_match_cpu_reference_in_preview_and_export") + else { + return; + }; + let shapes = [ + MaskShape::Linear { + point: Point2::new(0.45, 0.55), + normal: Point2::new(0.8, -0.3), + }, + MaskShape::Circle { + center: Point2::new(0.55, 0.45), + radius: Point2::new(0.31, 0.22), + }, + MaskShape::Poly { + points: vec![ + Point2::new(0.2, 0.2), + Point2::new(0.82, 0.28), + Point2::new(0.68, 0.82), + Point2::new(0.28, 0.72), + ], + }, + ]; + + for shape in shapes { + for feather in [0.0, 0.18] { + let mask = Mask { + shape: shape.clone(), + feather, + invert: feather > 0.0, + transform: if matches!(&shape, MaskShape::Poly { .. }) { + MaskTransform { + offset: Point2::new(0.07, -0.04), + scale: Point2::new(0.82, 1.13), + rotation_degrees: 17.0, + } + } else { + MaskTransform::default() + }, + }; + let mut timeline = full_canvas_timeline(); + timeline.tracks[0].clips[0].masks = vec![mask.clone()]; + + // Paused preview and export both consume the same FramePlan and + // compositor boundary. Render twice with fresh resolvers to guard + // against path-local state and compare both against the CPU mirror. + let preview = render(&dev, &timeline, [255, 255, 255, 255]); + let export = render(&dev, &timeline, [255, 255, 255, 255]); + assert_eq!(preview.rgba, export.rgba); + + for y in 0..RS.height { + for x in 0..RS.width { + let expected = (mask.coverage( + (x as f64 + 0.5) / RS.width as f64, + (y as f64 + 0.5) / RS.height as f64, + ) * 255.0) + .round() as i32; + let actual = pixel_at(&preview, x, y)[0] as i32; + assert!( + (actual - expected).abs() <= 3, + "shape={shape:?} feather={feather} pixel=({x},{y}) expected={expected} actual={actual}" + ); + } + } + } + } + + // Multiple masks intersect in authored order. Compare the real GPU result + // against the product of both CPU coverage functions at every pixel. + let masks = vec![ + Mask { + shape: MaskShape::Circle { + center: Point2::new(0.38, 0.5), + radius: Point2::new(0.34, 0.3), + }, + feather: 0.08, + ..Mask::default() + }, + Mask { + shape: MaskShape::Circle { + center: Point2::new(0.62, 0.5), + radius: Point2::new(0.34, 0.3), + }, + feather: 0.08, + ..Mask::default() + }, + ]; + let mut timeline = full_canvas_timeline(); + timeline.tracks[0].clips[0].masks = masks.clone(); + let preview = render(&dev, &timeline, [255, 255, 255, 255]); + let export = render(&dev, &timeline, [255, 255, 255, 255]); + assert_eq!(preview.rgba, export.rgba); + for y in 0..RS.height { + for x in 0..RS.width { + let px = (x as f64 + 0.5) / RS.width as f64; + let py = (y as f64 + 0.5) / RS.height as f64; + let expected = (masks + .iter() + .map(|mask| mask.coverage(px, py)) + .product::() + * 255.0) + .round() as i32; + let actual = pixel_at(&preview, x, y)[0] as i32; + assert!( + (actual - expected).abs() <= 3, + "multiple masks pixel=({x},{y}) expected={expected} actual={actual}" + ); + } + } +} diff --git a/crates/opentake-render/tests/gpu_text.rs b/crates/opentake-render/tests/gpu_text.rs index ce367318..3b0b811e 100644 --- a/crates/opentake-render/tests/gpu_text.rs +++ b/crates/opentake-render/tests/gpu_text.rs @@ -192,6 +192,33 @@ fn y_span(frame: &DecodedFrame) -> u32 { } } +fn alpha_bounds(frame: &DecodedFrame) -> Option<(u32, u32, u32, u32)> { + let mut x0 = frame.width; + let mut y0 = frame.height; + let mut x1 = 0; + let mut y1 = 0; + let mut found = false; + for y in 0..frame.height { + for x in 0..frame.width { + if frame.rgba[((y * frame.width + x) * 4 + 3) as usize] > 0 { + found = true; + x0 = x0.min(x); + y0 = y0.min(y); + x1 = x1.max(x); + y1 = y1.max(y); + } + } + } + found.then_some((x0, y0, x1, y1)) +} + +fn left_alpha_run(frame: &DecodedFrame) -> u32 { + let y = frame.height / 2; + (0..frame.width) + .take_while(|&x| frame.rgba[((y * frame.width + x) * 4 + 3) as usize] > 0) + .count() as u32 +} + #[test] fn font_size_scales_with_canvas_height() { let r = CosmicTextRasterizer::new(); @@ -377,3 +404,123 @@ fn natural_size_shadow_padding_matches_upstream() { assert_eq!(TextLayout::SHADOW_PADDING, 12.0); assert_eq!(TextLayout::REFERENCE_CANVAS_HEIGHT, 1080.0); } + +#[test] +fn fallback_font_no_font_scaled_stroke_and_structural_golden_matrix() { + // Pinned structural values from the upstream CATextLayer/TextLayout path. + // Glyph edge antialiasing is renderer-specific, so the golden compares + // geometry and ordering rather than exact CoreText coverage bytes. + const UPSTREAM_REFERENCE_HEIGHT: f64 = 1080.0; + const UPSTREAM_BORDER_AT_REFERENCE: u32 = 2; + const UPSTREAM_SHADOW_PADDING_EACH_SIDE: f64 = 12.0; + + assert_eq!( + TextLayout::REFERENCE_CANVAS_HEIGHT, + UPSTREAM_REFERENCE_HEIGHT + ); + assert_eq!( + TextLayout::SHADOW_PADDING, + UPSTREAM_SHADOW_PADDING_EACH_SIDE + ); + + // A truly empty font database models headless CI. The request still + // returns a correctly sized transparent premultiplied frame and never + // panics or invents replacement glyphs. + let headless = CosmicTextRasterizer::without_system_fonts(); + assert!(!headless.has_fonts()); + let mut plain = TextStyle { + font_size: 96.0, + ..TextStyle::default() + }; + plain.shadow.enabled = false; + let no_font = headless + .rasterize(&TextRasterRequest { + clip_id: "headless", + content: "终验 Headless", + style: &plain, + box_norm: (0.0, 0.0, 1.0, 1.0), + canvas: (640, 360), + }) + .expect("headless frame"); + assert_eq!((no_font.width, no_font.height), (640, 360)); + assert!(no_font.premultiplied); + assert!(alpha_bounds(&no_font).is_none()); + + let rasterizer = CosmicTextRasterizer::new(); + if !rasterizer.has_fonts() { + eprintln!("[skip] no system fonts for structural golden matrix"); + return; + } + + // A missing requested family must fall back through cosmic-text/fontdb. + let mut fallback = plain.clone(); + fallback.font_name = "OpenTake-Definitely-Missing-Bold".into(); + fallback.alignment = TextAlignment::Center; + let mixed = rasterizer + .rasterize(&TextRasterRequest { + clip_id: "fallback", + content: "终验 FINAL CHECK", + style: &fallback, + box_norm: (0.0, 0.0, 1.0, 1.0), + canvas: (960, 270), + }) + .expect("fallback frame"); + let mixed_bounds = alpha_bounds(&mixed).expect("fallback must paint glyphs"); + assert!(mixed_bounds.0 < mixed.width / 2); + assert!(mixed_bounds.2 > mixed.width / 2); + + // Pinned left/center/right structural matrix for the same Latin/CJK run. + let centroid = |alignment| { + let mut style = fallback.clone(); + style.alignment = alignment; + let frame = rasterizer + .rasterize(&TextRasterRequest { + clip_id: "alignment", + content: "OpenTake 终验", + style: &style, + box_norm: (0.0, 0.0, 1.0, 1.0), + canvas: (960, 270), + }) + .expect("alignment frame"); + x_centroid(&frame) / frame.width as f64 + }; + let left = centroid(TextAlignment::Left); + let center = centroid(TextAlignment::Center); + let right = centroid(TextAlignment::Right); + assert!(left < 0.35, "left centroid {left}"); + assert!((0.4..0.6).contains(¢er), "center centroid {center}"); + assert!(right > 0.65, "right centroid {right}"); + assert!(left < center && center < right); + + // A narrow box must wrap the mixed-language fixture into more vertical + // structure than a short line. + let render = |content: &str| { + rasterizer + .rasterize(&TextRasterRequest { + clip_id: "wrap", + content, + style: &fallback, + box_norm: (0.0, 0.0, 0.34, 1.0), + canvas: (960, 540), + }) + .expect("wrap frame") + }; + assert!(y_span(&render("OpenTake 终验 wraps across multiple lines")) > y_span(&render("终验"))); + + // The upstream box-border stroke is 2 px at 1080p and scales with canvas + // height, with a one-pixel floor for small canvases. + let mut border = plain; + border.border.enabled = true; + for (height, expected) in [(540, 1), (1080, UPSTREAM_BORDER_AT_REFERENCE), (2160, 4)] { + let frame = rasterizer + .rasterize(&TextRasterRequest { + clip_id: "border", + content: "I", + style: &border, + box_norm: (0.0, 0.0, 1.0, 1.0), + canvas: (height, height), + }) + .expect("border frame"); + assert_eq!(left_alpha_run(&frame), expected, "canvas height {height}"); + } +} diff --git a/crates/opentake-render/tests/gpu_y_orientation.rs b/crates/opentake-render/tests/gpu_y_orientation.rs index f45e2933..cb334686 100644 --- a/crates/opentake-render/tests/gpu_y_orientation.rs +++ b/crates/opentake-render/tests/gpu_y_orientation.rs @@ -243,6 +243,7 @@ fn off_center_mask_clips_to_authored_screen_region_not_mirrored() { }, feather: 0.0, invert: false, + ..Mask::default() }]; let mut track = Track::new("t0", ClipType::Video); track.clips.push(clip); diff --git a/crates/opentake-render/tests/lut.rs b/crates/opentake-render/tests/lut.rs new file mode 100644 index 00000000..6190a3bf --- /dev/null +++ b/crates/opentake-render/tests/lut.rs @@ -0,0 +1,165 @@ +//! Real GPU acceptance for project-managed 3D `.cube` LUTs. + +use std::rc::Rc; + +use opentake_domain::{Clip, ClipType, CubeLut, LutReference, Point, Timeline, Track, Transform}; +use opentake_render::gpu::texture::{upload_lut_3d, upload_rgba}; +use opentake_render::source::DecodedFrame; +use opentake_render::wgpu; +use opentake_render::{ + build_render_plan, Compositor, GpuLutTexture, GpuTexture, RenderDevice, RenderError, + RenderSize, SourceMetrics, TextureResolver, TextureSource, +}; + +const RS: RenderSize = RenderSize { + width: 16, + height: 16, +}; + +struct Metrics; +impl SourceMetrics for Metrics { + fn natural_size(&self, _media_ref: &str) -> Option<(u32, u32)> { + Some((16, 16)) + } +} + +struct LutResolver<'d> { + device: &'d wgpu::Device, + queue: &'d wgpu::Queue, + source: Option>, + lut: CubeLut, + uploaded_lut: Option>, +} + +impl TextureResolver for LutResolver<'_> { + fn resolve(&mut self, _source: &TextureSource, _frame: i64) -> Option> { + if self.source.is_none() { + let frame = DecodedFrame::new(16, 16, [64, 128, 192, 255].repeat(16 * 16), true); + self.source = Some(Rc::new(upload_rgba( + self.device, + self.queue, + &frame, + false, + Some("lut-source"), + ))); + } + self.source.clone() + } + + fn resolve_lut( + &mut self, + _reference: &LutReference, + ) -> Result>, RenderError> { + if self.uploaded_lut.is_none() { + self.uploaded_lut = Some(Rc::new(upload_lut_3d( + self.device, + self.queue, + &self.lut, + Some("known-transform-lut"), + ))); + } + Ok(self.uploaded_lut.clone()) + } +} + +fn cube(size: usize, transform: impl Fn(f32, f32, f32) -> [f32; 3]) -> Vec { + let mut text = + format!("TITLE \"acceptance\"\nLUT_3D_SIZE {size}\nDOMAIN_MIN 0 0 0\nDOMAIN_MAX 1 1 1\n"); + let last = (size - 1) as f32; + for b in 0..size { + for g in 0..size { + for r in 0..size { + let [r, g, b] = transform(r as f32 / last, g as f32 / last, b as f32 / last); + text.push_str(&format!("{r:.7} {g:.7} {b:.7}\n")); + } + } + } + text.into_bytes() +} + +fn timeline(reference: LutReference) -> Timeline { + let mut timeline = Timeline::new(); + timeline.width = 16; + timeline.height = 16; + timeline.fps = 30; + let mut clip = Clip::new("clip", "asset", 0, 30); + clip.transform = Transform::from_top_left(Point { x: 0.0, y: 0.0 }, 1.0, 1.0); + clip.lut = Some(reference); + let mut track = Track::new("track", ClipType::Video); + track.clips.push(clip); + timeline.tracks.push(track); + timeline +} + +fn render(dev: &RenderDevice, timeline: &Timeline, lut: CubeLut) -> DecodedFrame { + let plan = build_render_plan(timeline, RS, &Metrics); + let frame = plan.frame(timeline, 0); + let compositor = Compositor::new(&dev.device); + let mut resolver = LutResolver { + device: &dev.device, + queue: &dev.queue, + source: None, + lut, + uploaded_lut: None, + }; + compositor + .render_to_rgba(&dev.device, &dev.queue, RS, &frame, &mut resolver) + .expect("render with a valid LUT") +} + +#[test] +fn malformed_and_oversized_luts_fail_closed_and_valid_lut_matches_preview_export() { + let malformed = b"LUT_3D_SIZE 17\n0 0 0\n"; + assert!(CubeLut::parse(malformed).is_err(), "short table must fail"); + assert!( + CubeLut::parse(&vec![b' '; CubeLut::MAX_BYTES + 1]).is_err(), + "oversized input must fail before parsing" + ); + assert!( + CubeLut::parse(&cube(16, |r, g, b| [r, g, b])).is_err(), + "only planned 17- and 33-point tables are accepted" + ); + + let identity = CubeLut::parse(&cube(17, |r, g, b| [r, g, b])).expect("17-point identity"); + let transform = + CubeLut::parse(&cube(33, |r, g, b| [b, g * 0.5, r])).expect("33-point transform"); + assert_eq!(identity.size(), 17); + assert_eq!(transform.size(), 33); + + let reference = LutReference::new( + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + "Known Transform", + 0.75, + ) + .expect("managed reference"); + assert_eq!( + reference.relative_path(), + "media/luts/0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef.cube" + ); + + let authored = timeline(reference.clone()); + let json = serde_json::to_vec(&authored).expect("save project timeline"); + let reopened: Timeline = serde_json::from_slice(&json).expect("reopen project timeline"); + assert_eq!(reopened.tracks[0].clips[0].lut.as_ref(), Some(&reference)); + + let Ok(dev) = RenderDevice::try_new() else { + eprintln!("[skip] LUT GPU acceptance: no GPU device"); + return; + }; + let preview = render(&dev, &reopened, transform.clone()); + let export = render(&dev, &reopened, transform); + assert_eq!(preview.rgba, export.rgba, "preview/export LUT drift"); + + let center = (8 * 16 + 8) * 4; + let pixel = &preview.rgba[center..center + 4]; + assert!(pixel[0] > 140, "blue-to-red transform missing: {pixel:?}"); + assert!(pixel[1] < 100, "green attenuation missing: {pixel:?}"); + assert!(pixel[2] < 100, "red-to-blue transform missing: {pixel:?}"); + + let identity_timeline = timeline(LutReference::new(reference.id, "Identity", 1.0).unwrap()); + let identity_frame = render(&dev, &identity_timeline, identity); + let identity_pixel = &identity_frame.rgba[center..center + 4]; + for (actual, expected) in identity_pixel.iter().zip([64_u8, 128, 192, 255]) { + assert!((i16::from(*actual) - i16::from(expected)).abs() <= 2); + } +} diff --git a/crates/opentake-render/tests/nested_timeline.rs b/crates/opentake-render/tests/nested_timeline.rs new file mode 100644 index 00000000..6306c35c --- /dev/null +++ b/crates/opentake-render/tests/nested_timeline.rs @@ -0,0 +1,92 @@ +use opentake_domain::{Clip, ClipType, NestedSequence, Timeline, Track}; +use opentake_render::{build_render_plan, RenderSize, SourceMetrics}; + +struct Metrics; + +impl SourceMetrics for Metrics { + fn natural_size(&self, _media_ref: &str) -> Option<(u32, u32)> { + Some((64, 64)) + } +} + +fn video_clip(id: &str, media_ref: &str, start: i32, duration: i32) -> Clip { + let mut clip = Clip::new(id, media_ref, start, duration); + clip.media_type = ClipType::Video; + clip.source_clip_type = ClipType::Video; + clip +} + +fn sequence_clip(id: &str, sequence_id: &str, start: i32, duration: i32) -> Clip { + Clip::new_nested(id, sequence_id, start, duration) +} + +#[test] +fn nested_edits_preview_and_export_same_frames() { + let mut child_timeline = Timeline::new(); + child_timeline.settings_configured = true; + let mut child_track = Track::new("child-v1", ClipType::Video); + child_track + .clips + .push(video_clip("child-clip", "child-source-a", 2, 8)); + child_timeline.tracks.push(child_track); + + let mut root = Timeline::new(); + root.settings_configured = true; + root.nested_sequences + .push(NestedSequence::new("sequence-a", "Scene A", child_timeline)); + let mut root_track = Track::new("root-v1", ClipType::Video); + root_track + .clips + .push(sequence_clip("compound", "sequence-a", 10, 20)); + root.tracks.push(root_track); + + root.validate_nested_sequences() + .expect("valid nested graph"); + let encoded = serde_json::to_vec(&root).expect("serialize nested project"); + let mut reopened: Timeline = serde_json::from_slice(&encoded).expect("reopen nested project"); + assert_eq!(reopened.nested_sequences[0].name, "Scene A"); + + let preview_plan = build_render_plan(&reopened, RenderSize::new(64, 64), &Metrics); + let preview = preview_plan.frame(&reopened, 12); + assert_eq!(preview.draws.len(), 1); + assert_eq!(preview.draws[0].clip_id, "child-clip"); + assert_eq!(preview.draws[0].source_frame, 0); + + reopened.nested_sequences[0].timeline.tracks[0].clips[0].media_ref = + "child-source-b".to_string(); + reopened.nested_sequences[0].timeline.tracks[0].clips[0].trim_start_frame = 3; + + let preview_after_edit = build_render_plan(&reopened, RenderSize::new(64, 64), &Metrics); + let export_after_edit = build_render_plan(&reopened, RenderSize::new(64, 64), &Metrics); + for frame in 12..20 { + assert_eq!( + preview_after_edit.frame(&reopened, frame), + export_after_edit.frame(&reopened, frame), + "preview/export diverged at frame {frame}", + ); + } + assert_eq!( + preview_after_edit.frame(&reopened, 12).draws[0].source_frame, + 3 + ); + + let mut cycle = Timeline::new(); + let mut a = Timeline::new(); + let mut a_track = Track::new("a-track", ClipType::Video); + a_track.clips.push(sequence_clip("a-to-b", "b", 0, 10)); + a.tracks.push(a_track); + let mut b = Timeline::new(); + let mut b_track = Track::new("b-track", ClipType::Video); + b_track.clips.push(sequence_clip("b-to-a", "a", 0, 10)); + b.tracks.push(b_track); + cycle + .nested_sequences + .push(NestedSequence::new("a", "A", a)); + cycle + .nested_sequences + .push(NestedSequence::new("b", "B", b)); + let error = cycle + .validate_nested_sequences() + .expect_err("cycle must be rejected"); + assert!(error.contains("a -> b -> a"), "unexpected error: {error}"); +} diff --git a/crates/opentake-render/tests/optical_flow.rs b/crates/opentake-render/tests/optical_flow.rs new file mode 100644 index 00000000..63a1d0da --- /dev/null +++ b/crates/opentake-render/tests/optical_flow.rs @@ -0,0 +1,189 @@ +use std::rc::Rc; + +use opentake_media::{ + convert_frame_rate, interpolate_frame_pair, FrameInterpolationFallback, FrameInterpolationMode, + RgbaFrame, +}; +use opentake_render::gpu::compositor::{ + TextureInterpolationConfig, TextureInterpolationFallback, TextureInterpolationMode, + TextureResolveRequest, +}; +use opentake_render::{GpuTexture, TextureResolver, TextureSource}; + +fn moving_square(x: u32) -> RgbaFrame { + let mut frame = RgbaFrame::black(8, 4); + for y in 1..=2 { + for px_x in x..x + 2 { + let offset = ((y * frame.width + px_x) * 4) as usize; + frame.rgba[offset..offset + 4].copy_from_slice(&[255, 255, 255, 255]); + } + } + frame +} + +fn opposing_squares(left_x: u32, right_x: u32) -> RgbaFrame { + let mut frame = RgbaFrame::black(64, 32); + for y in 12..20 { + for x in left_x..left_x + 8 { + let offset = ((y * frame.width + x) * 4) as usize; + frame.rgba[offset..offset + 4].copy_from_slice(&[255, 255, 255, 255]); + } + for x in right_x..right_x + 8 { + let offset = ((y * frame.width + x) * 4) as usize; + frame.rgba[offset..offset + 4].copy_from_slice(&[255, 255, 255, 255]); + } + } + frame +} + +fn is_lit(frame: &RgbaFrame, x: u32, y: u32) -> bool { + frame.rgba[((y * frame.width + x) * 4) as usize] > 200 +} + +fn light_centroid_x(frame: &RgbaFrame) -> f64 { + let mut weighted_x = 0.0; + let mut weight = 0.0; + for y in 0..frame.height { + for x in 0..frame.width { + let offset = ((y * frame.width + x) * 4) as usize; + let value = frame.rgba[offset] as f64; + weighted_x += x as f64 * value; + weight += value; + } + } + weighted_x / weight +} + +#[derive(Default)] +struct RecordingResolver { + requests: Vec, +} + +impl TextureResolver for RecordingResolver { + fn resolve(&mut self, _source: &TextureSource, _source_frame: i64) -> Option> { + None + } + + fn resolve_with_interpolation( + &mut self, + request: TextureResolveRequest<'_>, + ) -> Option> { + self.requests.push(request.interpolation); + None + } +} + +#[test] +fn two_frame_fixture_is_deterministic_and_matches_preview_export() { + let first = moving_square(1); + let last = moving_square(5); + let conversion = convert_frame_rate(2, 24.0, 60.0).expect("valid 24 to 60 conversion"); + + assert_eq!(conversion.len(), 4); + assert_eq!(conversion.first().unwrap().timestamp_secs, 0.0); + assert_eq!(conversion.last().unwrap().timestamp_secs, 1.0 / 24.0); + assert_eq!(conversion[1].source_frame, 0); + assert_eq!(conversion[1].next_source_frame, 1); + assert!((conversion[1].source_alpha - 0.4).abs() < 1e-12); + assert!((conversion[2].source_alpha - 0.8).abs() < 1e-12); + + let render = |optical_flow_available| { + let source = [&first, &last]; + conversion + .iter() + .map(|sample| { + interpolate_frame_pair( + source[sample.source_frame as usize], + source[sample.next_source_frame as usize], + sample.source_alpha, + FrameInterpolationMode::OpticalFlow, + FrameInterpolationFallback::Blend, + optical_flow_available, + ) + .expect("configured blend fallback is infallible") + }) + .collect::>() + }; + let preview = render(true); + let export = render(true); + + assert_eq!(preview, export); + assert_eq!(preview.first().unwrap().frame, first); + assert_eq!(preview.last().unwrap().frame, last); + let centroids = preview + .iter() + .map(|result| light_centroid_x(&result.frame)) + .collect::>(); + assert!(centroids.windows(2).all(|pair| pair[0] < pair[1])); + assert!(preview + .iter() + .all(|result| result.mode_used == FrameInterpolationMode::OpticalFlow)); + + let fallback = render(false); + assert!(fallback + .iter() + .all(|result| result.mode_used == FrameInterpolationMode::Blend)); + assert_ne!(fallback[1].frame, preview[1].frame); + assert!(interpolate_frame_pair( + &first, + &last, + 0.5, + FrameInterpolationMode::OpticalFlow, + FrameInterpolationFallback::Error, + false, + ) + .is_err()); + + let interpolation = TextureInterpolationConfig::new( + 24.0, + 60.0, + TextureInterpolationMode::OpticalFlow, + TextureInterpolationFallback::Blend, + ) + .expect("valid render interpolation config"); + assert!(TextureInterpolationConfig::new( + 0.0, + 60.0, + TextureInterpolationMode::OpticalFlow, + TextureInterpolationFallback::Blend, + ) + .is_err()); + let source = TextureSource::Decoded { + media_ref: "motion-24fps".to_string(), + }; + let mut preview_resolver = RecordingResolver::default(); + let mut export_resolver = RecordingResolver::default(); + let request = TextureResolveRequest { + source: &source, + source_frame: 1, + interpolation, + }; + preview_resolver.resolve_with_interpolation(request); + export_resolver.resolve_with_interpolation(request); + + assert_eq!(preview_resolver.requests, export_resolver.requests); + assert_eq!(preview_resolver.requests, vec![interpolation]); +} + +#[test] +fn optical_flow_tracks_opposing_local_motion_without_global_frame_shift() { + let first = opposing_squares(4, 52); + let last = opposing_squares(12, 44); + let result = interpolate_frame_pair( + &first, + &last, + 0.5, + FrameInterpolationMode::OpticalFlow, + FrameInterpolationFallback::Error, + true, + ) + .expect("local optical flow should be available"); + + // Both objects move toward the center. A single global translation cannot + // satisfy these two regions simultaneously; the local field places both at + // their respective half-way locations. + assert!(is_lit(&result.frame, 10, 15)); + assert!(is_lit(&result.frame, 50, 15)); + assert!(!is_lit(&result.frame, 4, 15)); + assert!(!is_lit(&result.frame, 59, 15)); +} diff --git a/crates/opentake-render/tests/stabilization.rs b/crates/opentake-render/tests/stabilization.rs new file mode 100644 index 00000000..60171f68 --- /dev/null +++ b/crates/opentake-render/tests/stabilization.rs @@ -0,0 +1,149 @@ +use opentake_domain::{Clip, ClipType, Timeline, Track}; +use opentake_media::analysis::{ + analyze_stabilization, StabilizationConfig, StabilizationMotionSample, +}; +use opentake_media::MediaCancelToken; +use opentake_ops::{apply, EditCommand, EditorState, SeqIdGen}; +use opentake_render::{build_render_plan, RenderSize, SourceMetrics}; + +struct FullHdSource; + +impl SourceMetrics for FullHdSource { + fn natural_size(&self, _media_ref: &str) -> Option<(u32, u32)> { + Some((1920, 1080)) + } +} + +fn displacement(path: &[(f64, f64)]) -> f64 { + path.windows(2) + .map(|pair| (pair[1].0 - pair[0].0).hypot(pair[1].1 - pair[0].1)) + .sum() +} + +#[test] +fn synthetic_shake_produces_editable_undoable_preview_export_solution() { + let observed = [ + (0.000, 0.000), + (0.040, -0.018), + (-0.032, 0.022), + (0.047, -0.026), + (-0.038, 0.019), + (0.034, -0.015), + (0.000, 0.000), + ]; + let samples = observed + .iter() + .enumerate() + .map(|(frame, &(x, y))| StabilizationMotionSample { + frame: frame as i32, + translation_x: x, + translation_y: y, + rotation_degrees: 0.0, + }) + .collect::>(); + let cancel = MediaCancelToken::new(); + let solution = analyze_stabilization( + &samples, + "asset-shake", + StabilizationConfig::default(), + &cancel, + ) + .expect("synthetic stabilization analysis"); + + assert_eq!(solution.model, "opentake.motion-smoothing"); + assert_eq!(solution.model_version, 1); + assert_eq!(solution.source_identity, "asset-shake"); + let stabilized = samples + .iter() + .map(|sample| { + let correction = solution.sample(sample.frame); + ( + sample.translation_x + correction.translation_x, + sample.translation_y + correction.translation_y, + ) + }) + .collect::>(); + assert!(displacement(&stabilized) < displacement(&observed)); + assert!(solution.guarantees_coverage(16.0 / 9.0)); + + let mut timeline = Timeline::new(); + let mut track = Track::new("video-track", ClipType::Video); + track.clips.push(Clip::new( + "clip-shake", + "asset-shake", + 0, + samples.len() as i32, + )); + timeline.tracks.push(track); + let mut state = EditorState::from_timeline(timeline); + let ids = SeqIdGen::new("stabilization"); + + apply( + &mut state, + EditCommand::ApplyStabilization { + clip_id: "clip-shake".into(), + solution: solution.clone(), + }, + &ids, + ) + .expect("apply stabilization"); + assert_eq!(state.timeline.tracks[0].clips[0].media_ref, "asset-shake"); + assert_eq!(state.undo_depth(), 1); + + apply( + &mut state, + EditCommand::AdjustStabilization { + clip_id: "clip-shake".into(), + strength: Some(0.65), + crop_margin: Some(0.03), + }, + &ids, + ) + .expect("adjust stabilization"); + let edited = state.timeline.tracks[0].clips[0] + .stabilization + .as_ref() + .expect("persisted editable stabilization track"); + assert_eq!(edited.strength, 0.65); + assert_eq!(edited.crop_margin, 0.03); + + let preview = build_render_plan(&state.timeline, RenderSize::new(1920, 1080), &FullHdSource); + let export = build_render_plan(&state.timeline, RenderSize::new(1920, 1080), &FullHdSource); + for frame in 0..samples.len() as i32 { + let preview_draw = &preview.frame(&state.timeline, frame).draws[0]; + let export_draw = &export.frame(&state.timeline, frame).draws[0]; + assert_eq!(preview_draw.affine, export_draw.affine); + assert_eq!(preview_draw.crop_uv, export_draw.crop_uv); + } + + apply(&mut state, EditCommand::Undo, &ids).expect("undo adjustment"); + assert_eq!( + state.timeline.tracks[0].clips[0] + .stabilization + .as_ref() + .expect("analysis remains after undo") + .strength, + 1.0 + ); + apply( + &mut state, + EditCommand::ResetStabilization { + clip_id: "clip-shake".into(), + }, + &ids, + ) + .expect("reset stabilization"); + assert!(state.timeline.tracks[0].clips[0].stabilization.is_none()); + apply(&mut state, EditCommand::Undo, &ids).expect("undo reset"); + assert!(state.timeline.tracks[0].clips[0].stabilization.is_some()); + + let cancelled = MediaCancelToken::new(); + cancelled.cancel(); + assert!(analyze_stabilization( + &samples, + "asset-shake", + StabilizationConfig::default(), + &cancelled, + ) + .is_err()); +} diff --git a/crates/opentake-render/tests/transitions.rs b/crates/opentake-render/tests/transitions.rs new file mode 100644 index 00000000..51f59b2f --- /dev/null +++ b/crates/opentake-render/tests/transitions.rs @@ -0,0 +1,237 @@ +//! Owning acceptance test for the first advertised editable transition. + +use std::collections::HashMap; +use std::rc::Rc; + +use opentake_domain::{Clip, ClipType, Timeline, Track, Transform, TransitionKind}; +use opentake_ops::{apply, EditCommand, EditorState, SeqIdGen}; +use opentake_render::gpu::texture::upload_rgba; +use opentake_render::source::DecodedFrame; +use opentake_render::wgpu; +use opentake_render::{ + build_render_plan, Compositor, GpuTexture, RenderDevice, RenderSize, SourceMetrics, + TextureResolver, TextureSource, +}; + +const SIZE: RenderSize = RenderSize { + width: 16, + height: 16, +}; + +struct Metrics; + +impl SourceMetrics for Metrics { + fn natural_size(&self, _media_ref: &str) -> Option<(u32, u32)> { + Some((16, 16)) + } +} + +struct PairResolver<'d> { + device: &'d wgpu::Device, + queue: &'d wgpu::Queue, + cache: HashMap>, +} + +impl TextureResolver for PairResolver<'_> { + fn resolve(&mut self, source: &TextureSource, _frame: i64) -> Option> { + let media_ref = match source { + TextureSource::Decoded { media_ref } + | TextureSource::Image { media_ref } + | TextureSource::Lottie { media_ref } => media_ref, + TextureSource::Text { .. } => return None, + }; + if let Some(texture) = self.cache.get(media_ref) { + return Some(texture.clone()); + } + let color = match media_ref.as_str() { + "red" => [255, 0, 0, 255], + "blue" => [0, 0, 255, 255], + other => panic!("unexpected transition source {other}"), + }; + let mut rgba = vec![0; 16 * 16 * 4]; + for pixel in rgba.chunks_exact_mut(4) { + pixel.copy_from_slice(&color); + } + let frame = DecodedFrame::new(16, 16, rgba, true); + let texture = Rc::new(upload_rgba( + self.device, + self.queue, + &frame, + false, + Some("transition fixture"), + )); + self.cache.insert(media_ref.clone(), texture.clone()); + Some(texture) + } +} + +fn transition_timeline() -> Timeline { + let mut timeline = Timeline::new(); + timeline.fps = 30; + timeline.width = 16; + timeline.height = 16; + let mut outgoing = Clip::new("a", "red", 0, 12); + outgoing.transform = Transform::default(); + let mut incoming = Clip::new("b", "blue", 12, 12); + incoming.transform = Transform::default(); + let mut track = Track::new("v", ClipType::Video); + track.clips = vec![outgoing, incoming]; + timeline.tracks.push(track); + timeline +} + +fn render_frame(device: &RenderDevice, timeline: &Timeline, frame: i32) -> DecodedFrame { + let plan = build_render_plan(timeline, SIZE, &Metrics); + let frame_plan = plan.frame(timeline, frame); + let compositor = Compositor::new(&device.device); + let mut resolver = PairResolver { + device: &device.device, + queue: &device.queue, + cache: HashMap::new(), + }; + compositor + .render_to_rgba( + &device.device, + &device.queue, + SIZE, + &frame_plan, + &mut resolver, + ) + .expect("render transition frame") +} + +fn center_pixel(frame: &DecodedFrame) -> [u8; 4] { + let index = ((frame.height / 2 * frame.width + frame.width / 2) * 4) as usize; + frame.rgba[index..index + 4].try_into().unwrap() +} + +fn assert_pixel_near(actual: [u8; 4], expected: [u8; 4]) { + for channel in 0..4 { + assert!( + (actual[channel] as i16 - expected[channel] as i16).abs() <= 3, + "expected {expected:?}, got {actual:?}" + ); + } +} + +#[test] +fn adjacent_clip_transition_is_editable_undoable_and_matches_preview_export() { + let ids = SeqIdGen::default(); + let mut state = EditorState::from_timeline(transition_timeline()); + apply( + &mut state, + EditCommand::SetTransition { + from_clip_id: "a".into(), + to_clip_id: "b".into(), + kind: Some(TransitionKind::CrossDissolve), + duration_frames: 4, + }, + &ids, + ) + .expect("add advertised transition"); + + let transition = state.timeline.tracks[0].clips[0] + .transition_out + .as_ref() + .expect("transition persisted on outgoing clip"); + assert_eq!(transition.to_clip_id, "b"); + assert_eq!(transition.kind, TransitionKind::CrossDissolve); + assert_eq!(transition.duration_frames, 4); + + // Pair identity must be explicit in the saved object, not inferred solely + // from whichever clip happens to contain it after reopening. + let saved = serde_json::to_string(&state.timeline).expect("save timeline JSON"); + assert!(saved.contains(r#""fromClipId":"a""#)); + assert!(saved.contains(r#""toClipId":"b""#)); + let reopened: Timeline = serde_json::from_str(&saved).expect("reopen timeline JSON"); + assert_eq!(reopened, state.timeline); + + apply(&mut state, EditCommand::Undo, &ids).expect("undo transition"); + assert!(state.timeline.tracks[0].clips[0].transition_out.is_none()); + apply(&mut state, EditCommand::Redo, &ids).expect("redo transition"); + assert_eq!(state.timeline, reopened); + + apply( + &mut state, + EditCommand::SetTransition { + from_clip_id: "a".into(), + to_clip_id: "b".into(), + kind: Some(TransitionKind::CrossDissolve), + duration_frames: 3, + }, + &ids, + ) + .expect("change transition duration"); + assert_eq!( + state.timeline.tracks[0].clips[0] + .transition_out + .as_ref() + .unwrap() + .duration_frames, + 3 + ); + apply( + &mut state, + EditCommand::SetTransition { + from_clip_id: "a".into(), + to_clip_id: "b".into(), + kind: None, + duration_frames: 3, + }, + &ids, + ) + .expect("remove transition"); + assert!(state.timeline.tracks[0].clips[0].transition_out.is_none()); + apply(&mut state, EditCommand::Undo, &ids).expect("undo transition removal"); + assert_eq!( + state.timeline.tracks[0].clips[0] + .transition_out + .as_ref() + .unwrap() + .duration_frames, + 3 + ); + + // A 12-frame pair exposes a six-frame centered transition handle. An + // oversized request is an explicit refusal and must not mutate history. + let mut invalid = EditorState::from_timeline(transition_timeline()); + apply( + &mut invalid, + EditCommand::SetTransition { + from_clip_id: "a".into(), + to_clip_id: "b".into(), + kind: Some(TransitionKind::CrossDissolve), + duration_frames: 7, + }, + &ids, + ) + .expect_err("transition longer than either available handle must be rejected"); + assert_eq!(invalid.version(), 0); + assert!(!invalid.can_undo()); + + let device = match RenderDevice::try_new() { + Ok(device) => device, + Err(error) => { + eprintln!("[skip] transition pixel fixture: no GPU device ({error})"); + return; + } + }; + + // Duration four covers frames 8..12. Exercise the first transition frame, + // midpoint, last blended frame, and the exact cut/end frame. Preview and + // export are represented by fresh compositor/resolver executions. + for (frame, golden) in [ + (8, [255, 0, 0, 255]), + (10, [128, 0, 128, 255]), + (11, [64, 0, 191, 255]), + (12, [0, 0, 255, 255]), + ] { + let preview = render_frame(&device, &reopened, frame); + let export = render_frame(&device, &reopened, frame); + assert_eq!( + preview.rgba, export.rgba, + "preview/export drift at frame {frame}" + ); + assert_pixel_near(center_pixel(&preview), golden); + } +} diff --git a/docs/INDEX.md b/docs/INDEX.md index 0dd79c31..939d89bd 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -34,7 +34,7 @@ docs/ | 能力 | `opentake-render` | wgpu 合成器 + 文本栅格化(预览/导出共享 RenderPlan) | [总览](modules/opentake-render/OVERVIEW.md) · [目录](modules/opentake-render/INDEX.md) | | 能力 | `opentake-media` | FFmpeg 编解码 / 缩略图 / 波形 / 转写 / 语义搜索 | [总览](modules/opentake-media/OVERVIEW.md) · [目录](modules/opentake-media/INDEX.md) | | 能力 | `opentake-motion` | Lottie / web 动态图形 | [总览](modules/opentake-motion/OVERVIEW.md) · [目录](modules/opentake-motion/INDEX.md) | -| 能力 | `opentake-agent` | MCP server(44 工具) + 内置 Agent + Context Signal | [总览](modules/opentake-agent/OVERVIEW.md) · [目录](modules/opentake-agent/INDEX.md) | +| 能力 | `opentake-agent` | MCP server(当前 44 个可发布工具:38 基础 + 4 生成 + 2 动效,能力门控后按能力发布) + 内置 Agent + Context Signal | [总览](modules/opentake-agent/OVERVIEW.md) · [目录](modules/opentake-agent/INDEX.md) | | 能力 | `opentake-gen` | 生成式 AI 客户端(BYOK,无后端) | [总览](modules/opentake-gen/OVERVIEW.md) · [目录](modules/opentake-gen/INDEX.md) | | 装配 | `opentake-core` | 会话管理 / DI / 事件总线(命令路由层) | [总览](modules/opentake-core/OVERVIEW.md) · [目录](modules/opentake-core/INDEX.md) | | 装配 | `src-tauri` | Tauri 2 桌面壳 + Tauri 命令 | [总览](modules/src-tauri/OVERVIEW.md) · [目录](modules/src-tauri/INDEX.md) | @@ -63,4 +63,5 @@ docs/ | [CHANGELOG.md](../CHANGELOG.md) | 变更历史 | | [CONTRIBUTING.md](../CONTRIBUTING.md) | 贡献指南 | | [Specs Index](specs/INDEX.md) | 已批准/历史规格目录 | -| [Superpowers Recovery](superpowers/specs/2026-07-08-opentake-recovery-integration-design.md) | 本轮恢复集成设计与计划入口 | +| [Beta 发布与验证](releases/1.0.0-beta.2.md) · [功能验证记录](audit/2026-08-02/beta-functional-verification.md) | ★ 当前 Beta 2 范围、发布门槛与逐项执行证据 | +| [Superpowers Recovery](superpowers/specs/2026-07-08-opentake-recovery-integration-design.md) | 历史恢复集成设计与计划入口(Beta 2 已收口) | diff --git a/docs/architecture/ADVANCED-FEATURES.md b/docs/architecture/ADVANCED-FEATURES.md index eb4cd7e1..b71dd571 100644 --- a/docs/architecture/ADVANCED-FEATURES.md +++ b/docs/architecture/ADVANCED-FEATURES.md @@ -1,5 +1,19 @@ # OpenTake 进阶能力设计(对标剪映模块 1–5 的差距深化) +> **2026-08-01 Beta 对账附录(覆盖下表“现状”列)**:本文的 `missing` / `partial` +> 是实现前的历史快照,继续保留作为设计来源,不再表示当前代码状态。首个 Beta 已闭环: +> 通用特效、交叉溶解、线性光调色、Lift/Gamma/Gain、HSL、3D LUT、绿幕、圆形/线性/ +> 钢笔蒙版、参考色彩匹配、RVM 抠像、防抖、运动追踪、光流补帧、智能擦除、响度、降噪、 +> 声部分离、嵌套时间线、字幕样式同步与 SRT/VTT、口播清理、图文成片、字幕翻译、数字人和 +> 音色克隆。代码与实机证据以 +> `docs/audit/2026-07-14/runtime-artifacts/automated/` 及 +> `docs/releases/1.0.0-beta.1.md` 为准。 +> +> 后续 Beta 明确保留的扩展是:Bezier/Spring 物理 easing、RGB 多维曲线、wipe/slide/zoom +> 等通用双源转场、本地神经超分、任意混音的神经语义分轨、高阶曲线变速、多机位自动对齐、 +> 任意 Motion Canvas TSX 与透明 frame sequence。这些是产品路线,不属于 +> `1.0.0-beta.1` 可用性门禁;当前 UI 不把它们伪装成已可用能力。 + > 来源:`docs/CAPCUT-GAP.md`(5 个子 Agent 对照剪映模块 1–5 与上游源码的逐特性差距分析)。 > 范围:**不含**剪映模块 6(自然语言交互/语音助手 —— OpenTake 已有 Agent)与模块 7(云生态/企业协作)。 > 现状:33 项中 已有 2 / 部分 7 / 缺失 24。上游 Palmier Pro 自述「尚无:特效/转场/调色/蒙版/图形」,源码核对完全坐实。 @@ -76,7 +90,7 @@ OpenTake 补齐这些进阶能力,**几乎不需要新建基础设施**,全部 | 复合片段嵌套 nested clip | missing | high | p2 | **过渡方案先做**:`saveTimelineRange` 用 FFmpeg/wgpu 重写为「打组烧成内部媒体 + content-hash 缓存」满足「精简图层」;**完整方案后做**:domain 新增 `MediaSource::Nested(child_timeline_id)`,RenderPlan 递归展开或子序列离屏渲染成单层 | | 多机位自动对齐 multicam | missing | medium | p2 | 纯本地:各机位音轨 → PCM → rustfft **互相关**求最佳时移 → ops 整体平移到同一时基;多角度切换面板作后续 UI | | 字幕样式全局批量同步 | partial(共享样式在,批量算子缺) | low | p1 | 新增「改一处 → 批量回写整 captionGroup」编辑命令 | -| 导出 .srt 字幕文件 | missing | low | p1 | 从 caption 模型按时码序列化 SubRip;顺带支持 .vtt | +| 导出 .srt 字幕文件 | **已有** | low | p1 | caption 模型按时码序列化 SubRip/WebVTT;TitleBar 原生保存对话框接 `export_subtitles`,SRT/VTT 均有 Rust 与 UI 路由测试 | --- @@ -88,8 +102,8 @@ OpenTake 补齐这些进阶能力,**几乎不需要新建基础设施**,全部 |---|---|---|---|---| | 智能剪口播(剔除停顿/语气词) | partial(已规划) | medium | p1 | **本地为主**:词级 `get_transcript` + 静音检测 → Rust 内一次算好 ripple 区间(高阶工具 `remove_filler_words`/`tighten_silences`,避免把帧算术外包给 LLM) | | 图文成片 script-to-video | partial(地基在) | high | p1 | agent 编排既有工具:脚本 → `generate_image`→`generate_video`→`generate_audio`(配音)→`add_clips`/`add_texts`/`set_transition`;素材匹配用 SigLIP2 搜索 + import_media 接 stock | -| 音色克隆 voice cloning | missing | high | p2 | 外部 API(ElevenLabs 等)经 opentake-gen,扩展 audio 生成参数支持自定义音色 | -| 虚拟数字人 digital avatar | missing | high | p3 | 外部 API(HeyGen/fal 等)经 opentake-gen,新增 catalog kind | +| 音色克隆 voice cloning | **has** | high | p2 | ElevenLabs IVC 注册/TTS/永久撤销生产桥;参考音频、同意记录、请求哈希和 provider voice id 持久化,生成音频原子导入落轨并可试听/撤销 | +| 虚拟数字人 digital avatar | **has** | high | p3 | fal Sync Lipsync v3 image-to-video 生产桥;人像+驱动音频、同意与成本确认、结果探测、原子导入落轨和预览/撤销完整接入 | | 多语种翻译(字幕) | partial(靠 agent) | medium | p2 | 一等公民:离线 MT 或外部 API + LLM 兜底,翻译后保持时码 | --- diff --git a/docs/architecture/ARCHITECTURE.md b/docs/architecture/ARCHITECTURE.md index af78cb65..8b21fc41 100644 --- a/docs/architecture/ARCHITECTURE.md +++ b/docs/architecture/ARCHITECTURE.md @@ -77,7 +77,7 @@ OpenTake/ │ ├── opentake-motion/ # 原生 motion fallback:RGBA frame cache / sandbox / StubRenderer / 后续 alpha source │ └── opentake-core/ # 组装:EditorState(持有 timeline+manifest)、command 路由、事件总线 ├── plugins/ -│ └── motion-canvas-studio/ # 待新增:Motion Canvas(MIT) fork/plugin,渲染 mp4 后导入落轨 +│ └── motion-canvas-studio/ # Motion Canvas 3.17.2 MIT wrapper,确定性渲染 mp4 后导入落轨 ├── src-tauri/ # Tauri 2 app:#[tauri::command] 薄封装 + 窗口/菜单/生命周期 ├── web/ # React + TS 前端(Vite) ├── services/ @@ -87,7 +87,7 @@ OpenTake/ 依赖法则(经上游验证):`domain` 零依赖叶子;`ops` 只依赖 `domain`;`command` 是唯一编辑入口;UI/Agent/MCP 是命令层三个对等客户端。 -> Motion / AI Video 主线改为 `plugins/motion-canvas-studio/`:fork Motion Canvas(MIT),渲染 materialized mp4 后由 OpenTake 当普通媒体导入并落轨。`crates/opentake-motion/` 已有 scaffold 保留为后续透明 alpha / PNG sequence / HTML-CSS fallback,不作为 v1 主渲染器 blocker。 +> Motion / AI Video v1 已由 `plugins/motion-canvas-studio/` 落地:锁定 Motion Canvas 3.17.2(MIT),渲染 materialized mp4 后由 OpenTake 当普通媒体原子导入并落轨。`crates/opentake-motion/` 同时提供离线 Chromium 宿主与 HTML/CSS fallback;透明 alpha / PNG sequence 留给后续版本。 ## 4. 领域模型(可直接复刻,见 MODULE-PORT-MAP.md「Models」) @@ -150,7 +150,10 @@ struct EditResult { changed: bool, action_name: String, affected_clip_ids: Vec`/`` 路径渲染,未接入 `useTimelineFrame`。后果:看不到关键帧动画、transform/crop/text/effects,preview ≠ export | | **当前状态** | 已有完整的 wgpu 合成管线,只需在 Preview.tsx 中接入 `useTimelineFrame` hook | -### D2. Agent/MCP 工具 12/40 为 stub(高) +### D2. Agent/MCP Lottie 检查(已关闭) | 属性 | 值 | |---|---| -| **位置** | `crates/opentake-agent/src/mcp/dispatch.rs:177-191` | -| **描述** | 40 个工具中 12 个(30%)返回 `"not yet implemented"` stub 错误:InspectMedia、GetTranscript、InspectTimeline、SearchMedia、GenerateVideo/Image/Audio、UpscaleMedia、ImportMedia、AddCaptions、AddMotionGraphic、EditMotionGraphic。另有 `create_folder` 和 `move_to_folder` 的 batch 形式 stub | +| **位置** | `src-tauri/src/mcp.rs`、`src-tauri/src/render.rs` | +| **描述** | 已关闭:`inspect_media` 使用共享 Velato/Vello 管线在中性灰底上均匀采样 Lottie,返回尺寸、帧率、时长和真实 JPEG 帧;`inspect_timeline` 也不再跳过 Lottie 层。无效文档、离线源和无 GPU 环境返回类型化失败。 | ### D3. Media 缩略图始终返回 `None`(中) @@ -112,9 +113,9 @@ | **B1** | swapMedia IPC 缺失 | 🔴 关键 | 单功能完全不可用 | 低(~10 行代码) | | **B2** | 前端帧数学截断不一致 | 🟠 高 | 所有媒体导入时长偏差 | 低(3 处 Math.round→floor) | | **B3** | rippleDeleteRanges 忽略 clipId | 🟠 高 | Agent 工具精度下降 | 中 | -| **B4** | canGenerate 硬编码 false | 🟡 中 | AI 生成功能不可用 | 低 | +| **B4** | canGenerate 硬编码 false(已修复) | ✅ 已关闭 | 动态生成能力已恢复 | — | | **D1** | 预览未接入 GPU 合成 | 🟠 高 | 所有编辑效果不可见 | 中 | -| **D2** | Agent 工具 30% stub | 🟠 高 | AI 协作核心缺失 | 高 | +| **D2** | Agent/MCP Lottie 检查 | ✅ 已关闭 | 真实抽帧与合成已恢复 | — | | **D3** | 缩略图始终 None | 🟡 中 | 媒体库 UX 差 | 中 | | **D4** | Export 仅 H.264 | 🟡 中 | 输出格式受限 | 低 | | **D5** | TextTab/AIEdit scaffold | 🟡 中 | 功能不完整 | 中 | diff --git a/docs/architecture/CAPCUT-GAP.md b/docs/architecture/CAPCUT-GAP.md index f2928d69..77d05ecd 100644 --- a/docs/architecture/CAPCUT-GAP.md +++ b/docs/architecture/CAPCUT-GAP.md @@ -6,17 +6,19 @@ **整体差距**:总体判定:6 项里只有「基础线性变速」是 OpenTake 设计稿已完整覆盖(has);「50 条多轨道复杂工程」属于"数据模型已覆盖、但大工程性能尚未验证"的 partial;其余 4 项(复合片段嵌套、多机位自动对齐、高阶曲线变速、光流补帧)在上游 Palmier Pro 源码与 OpenTake 三份设计文档里都完全没有踪迹,均为 missing,且恰好对应上游 FAQ 自述「尚无特效/转场/调色/蒙版/图形」之外的另一类"高阶时间线能力"空白。\n\n关键证据链:① 上游 Clip 模型(Models/Timeline.swift:82-85)速度是单个标量 `var speed: Double = 1.0`,而非关键帧轨;可关键帧的属性枚举 `AnimatableProperty`(Models/Keyframe.swift:77-78)只有 opacity/position/scale/rotation/crop/volume,根本没有 speed → 曲线变速在现模型里不可表达。② 插值类型 `Interpolation` 只有 linear/hold/smooth(Models/Keyframe.swift:3-5),没有任意贝塞尔曲线。③ 对全仓做穷举式 grep,multicam/nested/compound/optical-flow/interpolation/speedCurve/retime/frameblending 这些关键词除了 SF Symbol 图标名"film"和被排除的 compoundPredicate 外零命中;导出器甚至把 `frameblending=FALSE` 写死(Export/XMLExporter.swift:336)。④ 唯一与"嵌套"沾边的是 saveClipAsMedia/saveTimelineRangeAsMedia(Editor/ViewModel/EditorViewModel+SaveAsMedia.swift),那是用 AVMutableComposition 把片段/区间烘焙成一个新扁平媒体的破坏性 flatten,不是活的嵌套序列;OpenTake 设计稿(MODULE-PORT-MAP.md:233)也只把它当 FFmpeg flatten 处理。⑤ 轨道是无上限的 `tracks: [Track]` 数组,没有任何 maxTracks 闸门;多轨编辑逻辑(OverwriteEngine/RippleEngine/SnapEngine)已被 Phase 1 完整规划进 opentake-domain/opentake-ops,所以 50 轨的难点不在"功能缺失"而在 wgpu 合成器/预览引擎(架构文档点名的两大 blocker)能否扛住几十轨逐帧合成的性能。\n\n补齐落点优先级:p0 = 先保证 50 轨工程在 wgpu 合成器/预览引擎上的可用性能(否则其余高阶能力无处施展);p1 = 曲线变速(需把 speed 升级为关键帧轨 + 重做 setpts/RenderPlan 时间映射,工程量中等但牵动核心模型);p2 = 复合片段嵌套(架构性较大,建议先做"非破坏 flatten 缓存"过渡)、多机位自动对齐(可用 FFmpeg 音频互相关纯本地实现,工程独立);p3 = 光流补帧(唯一需要 ML 模型/外部 API、且对画质一致性要求最高的 blocker 级特性,放最后)。 +> **2026-07-31 状态更新**:上面的总体判定与证据链是实现前的上游历史基线。当前 OpenTake 已完整覆盖「基础线性变速」「复合片段嵌套」和确定性本地光流补帧;「50 条多轨道复杂工程」仍是性能待验证的 partial;多机位自动对齐与高阶曲线变速仍为 missing。当前状态以各条目的验证证据为准。 + ### 高达 50 条多轨道复杂工程 — `partial` · 难度 high · 优先级 p0 - **判定依据**:上游轨道为无上限 `tracks: [Track]` 数组,全仓无 maxTracks/track limit 闸门;多轨编辑算法 OverwriteEngine/RippleEngine/SnapEngine 已是纯函数,OpenTake 已在 Phase 1 规划进 opentake-domain/opentake-ops(ROADMAP.md:10-17、MODULE-PORT-MAP.md:233、304)。但架构文档(ARCHITECTURE.md:22-27)明确把 wgpu 帧合成器与播放/预览引擎列为两大 🔴 blocker,且 PoC 场景只到『单轨视频+1个 transform 关键帧+1条字幕』(ROADMAP.md:29)——几十轨逐帧『解码→采样关键帧→仿射/裁剪/混合→多轨合成』的实时性能完全未验证。结论:数据模型与编辑逻辑层 has,但大工程的渲染/预览性能 partial。 - **落点(crate/层)**:opentake-render(wgpu 合成器 + 预览后端,性能关键)+ opentake-domain/opentake-ops(模型与算法,已覆盖) - **实现方案**:模型与编辑无需新增,直接沿用既定移植。性能侧需在 wgpu 合成器做工程化:(1) 每帧只对『当前帧实际可见、未被上层完全遮挡且 opacity>0』的轨道解码合成,跳过空轨/全透明轨;(2) ffmpeg 解码器池 + 帧缓存,避免每轨重复 seek;(3) 预览分辨率降档 + scrub 丢帧到最新请求(架构已提该策略 ARCHITECTURE.md:130、MODULE-PORT-MAP.md:308);(4) RenderPlan 预计算每帧合成指令,wgpu 端用单 render pass 批量混合多层纹理。全部跨平台 Rust/wgpu,无需外部依赖。 - **前置依赖**:强依赖 wgpu 帧合成器(blocker 1)与播放/预览引擎(blocker 2)先落地;Phase 3/4 完成后才能压测 50 轨 -### 新建复合片段(工程嵌套 / nested compound clip) — `missing` · 难度 high · 优先级 p2 -- **判定依据**:上游领域模型只有扁平的 Timeline→Track→Clip,Clip 永远指向单个 media_ref(asset id),没有『Clip 引用一个子 Timeline』的类型(Models/Timeline.swift)。全仓 grep nested/compound 零命中(仅 SF Symbol 与被排除的 compoundPredicate)。唯一相邻能力是 saveClipAsMedia/saveTimelineRangeAsMedia(EditorViewModel+SaveAsMedia.swift:8/60),用 AVMutableComposition 把片段或时间线区间烘焙成一个新的扁平 mp4/m4a 媒体——这是破坏性 flatten,不可再编辑内部,且 OpenTake 设计稿(MODULE-PORT-MAP.md:233)也只把它当 FFmpeg flatten 移植。真正的『活的嵌套序列/精简图层』完全缺失。 +### 新建复合片段(工程嵌套 / nested compound clip) — `has` · 难度 high · 优先级 p2 +- **判定依据**:OpenTake 现已具备持久化 `nestedSequenceId` 与子时间线注册表、依赖和循环校验、共享撤销路径下的创建/进入/编辑/重命名/移动/修剪/复制/解散、递归 RenderPlan 展开,以及预览/导出共用的扁平计划。精确 round-trip、递归渲染、循环拒绝测试和 2026-07-31 的打包 macOS 实机流程均通过;实机完成创建、进入、修剪、移动、联动音视频复制粘贴、保存重开、连续预览和 231 帧 H.264/AAC 导出。证据见 `docs/audit/2026-07-14/runtime-artifacts/automated/nested-timeline-compound-real-device-2026-07-31.md`。 - **落点(crate/层)**:opentake-domain(新增 nested 片段类型与子 Timeline 引用)+ opentake-ops(进入/退出嵌套、子树编辑命令)+ opentake-render(嵌套 RenderPlan 递归展开/物化)+ opentake-project(子序列序列化) -- **实现方案**:分两步落地,优先级有别。【过渡方案,先做】把上游 saveTimelineRangeAsMedia 的 flatten 用 FFmpeg/wgpu 重写为『一键打组烧成内部媒体』(content-hash 缓存,源区间改了才重烧),立即满足『精简图层』的视觉诉求,纯本地、工程小。【完整方案,后做】在 domain 新增 `MediaSource::Nested(child_timeline_id)` 或独立 `CompoundClip{child: Timeline}`,Clip 可引用子 Timeline;RenderPlan 生成时对嵌套节点递归展开成扁平合成指令(或先渲染子序列为离屏纹理再当单层合成,二选一按性能定);ops 增加 enter/exit 嵌套、子树内复用既有 OverwriteEngine/RippleEngine。全程跨平台 Rust/wgpu,无外部模型。 -- **前置依赖**:完整方案依赖 wgpu 合成器支持离屏渲染/递归 RenderPlan;过渡 flatten 方案依赖导出/FFmpeg 链路可用即可 +- **实现方案**:已采用活的子时间线模型而非破坏性烘焙:父 Clip 保存子序列引用,子树复用既有编辑命令并由根命令提供单次撤销;渲染计划递归展开并对非法循环、缺失引用及当前不支持的复合级像素效果明确失败。Web 时间线提供创建、进入/返回、重命名与解散入口。 +- **前置依赖**:功能实现与 macOS 打包验证已完成;Developer ID/公证和 Windows UI 实机属于独立发布验收,不在本条功能完成声明内。 ### 多机位自动对齐剪辑(multicam auto-sync) — `missing` · 难度 medium · 优先级 p2 - **判定依据**:上游与 OpenTake 三份设计文档全无 multicam/angle/sync-cam/多机位 任何痕迹(穷举 grep 仅命中无关的 sync-locked 轨道锁与 audio/video 链接 link_group)。上游的 A/V Link(同 link_group_id 作为一个单位移动,ARCHITECTURE.md:113)只是单机位音画捆绑,与多机位多角度对齐切换是两回事。该能力 0 覆盖。 @@ -36,11 +38,11 @@ - **实现方案**:纯本地 Rust,不需外部模型。(1) domain:把速度从标量升级为可选 `speedCurve: KeyframeTrack`(或独立 time-remap 曲线,关键帧值=源时间或瞬时速度),复用现有 sample/smoothstep 框架,新增 bezier 段以支持平滑加减速;(2) 核心是时间映射:对速度曲线做累积积分得到『时间线帧→源帧』的单调映射函数,ops 据此重算 durationFrames 与 trim,split/ripple 钳制逻辑要按曲线重做;(3) render:导出走 FFmpeg setpts='非线性表达式' 或预采样成密集 sendcmd/逐帧 PTS(类比上游 smooth 段 8 等分细分思路 MODULE-PORT-MAP.md:347),预览在 wgpu 端按映射取源帧。难点在曲线积分与帧对齐的数值一致性。 - **前置依赖**:依赖基础线性变速链路先稳;依赖关键帧采样框架(已有);若要配合补帧出丝滑慢动作则进一步依赖光流补帧 -### 光流法智能补帧(optical-flow frame interpolation) — `missing` · 难度 blocker · 优先级 p3 -- **判定依据**:上游零实现:穷举 grep optical/interpolat(非关键帧插值)/RIFE/FILM/motion-interpolat 全部零命中;导出器把 frameblending 写死为 FALSE(XMLExporter.swift:336),连最基础的帧混合都不开。OpenTake 媒体引擎清单(ARCHITECTURE.md:123-137)涵盖解码/合成/字幕/转写/语义搜索,但完全没有补帧/插帧条目。该能力 0 覆盖,且是本模块技术门槛最高的一项。 -- **落点(crate/层)**:opentake-media(补帧推理:本地 ort/candle 跑光流或学习式插帧模型;或 opentake-gen 走外部 API)+ opentake-render(变速/慢动作时按需调用补帧填充中间帧) -- **实现方案**:需要算法/模型,分两条路权衡:【本地优先】用 ort(onnxruntime,项目已用于 SigLIP2)或 candle 加载学习式插帧模型(RIFE/FILM 系)做两帧间插值,GPU 加速(wgpu/CUDA 视平台),完全离线、无云成本,契合 OpenTake 自托管定位;退一步可用传统光流(Farneback,OpenCV/纯 Rust 实现)做 warp,质量弱但零模型依赖。【外部 API 兜底】接 fal.ai/Replicate 的插帧端点(经既有 opentake-gen BYOK/代理),省去本地集成与算力,但要联网且按量计费。建议:慢动作变速场景默认本地补帧,质量不够再降级为帧混合/最近帧。这是 blocker 级:模型体积、跨平台 GPU 推理、与变速曲线的帧对齐都需打通。 -- **前置依赖**:依赖 ort/candle 推理链路(语义搜索已铺,可复用)与 GPU 推理后端;强依赖高阶曲线变速一起用才有意义;模型权重分发与许可需先确认 +### 光流法智能补帧(optical-flow frame interpolation) — `has` · 难度 blocker · 优先级 p3 +- **判定依据**:上游仍为零实现;当前 OpenTake 已在 `opentake-media` 落地确定性的局部分块运动估计、双向 warp 与混合,在 `opentake-render` 建立显式 source/target FPS、OpticalFlow/Blend/Nearest 模式和 Blend/Nearest/Error 降级策略,并让高质量暂停预览与导出使用相同解析策略。24→60 fps 的像素/时序测试固定首尾时间戳、精确目标帧数、反向局部运动与不支持设备降级。打包 macOS 工程已用 48 帧/2 秒的 24 fps 素材验证 120 帧、60/1 fps、2.000 秒 H.264 导出;第 1 帧预览/导出运动边界一致,SSIM 0.999515。证据见 `docs/audit/2026-07-14/runtime-artifacts/automated/optical-flow-24-to-60-real-device-2026-07-31.md`。 +- **落点(crate/层)**:`opentake-media`(帧率映射、局部分块运动场、双向重采样、显式降级)+ `opentake-render`(共享纹理解析契约)+ `src-tauri/render.rs` / `src-tauri/export.rs`(生产预览/导出接线) +- **实现方案**:当前基线为完全离线、无模型权重与云成本的传统局部 block-matching 光流。它按固定块和有限搜索半径计算亮度 SAD,确定性打破并列候选,按目标 alpha 将两个端点向中间时刻双向 warp 后混合;不可用时由调用方明确选择帧混合、最近帧或 fail-closed。后续若追求极端运动/遮挡质量,可在不改变 render 契约的前提下替换为 RIFE/FILM/RAFT 等模型后端。 +- **前置依赖**:当前本地基线无外部模型依赖;模型级升级才依赖 ort/candle、GPU 后端、权重分发与许可。低延迟连续播放流仍采用独立的实时帧归一化策略,不纳入本条高质量暂停预览/导出验收。 ## 模块2:视觉特效与空间重构 @@ -58,14 +60,14 @@ - **实现方案**:纯 Rust、零外部依赖、风险极低。① 把 Interpolation 从 enum 升级为可携参数:新增 CubicBezier{x1,y1,x2,y2}(用牛顿迭代或二分解 t,对齐 CSS cubic-bezier 语义)、Spring{stiffness,damping,mass} 或更简单的 Penner easing 预设集(easeIn/Out/InOut × Quad/Cubic/Quart/Quint/Sine/Expo/Circ/Back/Elastic/Bounce)。② sample() 的分支从 3 路扩到 N 路,smoothstep 作为默认保持向后兼容。③ 序列化保持 #[serde(default)] 容错——旧工程的 linear/hold/smooth 原样读;新曲线作为新 variant 追加。④ wgpu 合成器侧只需把『每段按曲线求 t』替换原 smoothstep,8 段细分对贝塞尔可提到 16~32 段保平滑。剪映的『物理级』本质是弹簧/惯性预设,用 Penner elastic/back + 可调阻尼即可覆盖 95% 体感,不必引真实物理积分器。 - **前置依赖**:依赖现有关键帧引擎(Phase 1 已规划),不依赖 wgpu 合成器即可先在 domain 层完成并单测;最终视觉验证需 Phase 3 合成器 -### 蒙版工具(线性 / 圆形 / 钢笔 mask) — `missing` · 难度 medium · 优先级 p1 -- **判定依据**:上游完全没有蒙版能力。Clip 结构体无 mask 字段(Models/Timeline.swift:77-107);Crop 只是矩形四边内缩(left/top/right/bottom 归一化 0-1,Models/Timeline.swift、Keyframe.swift:65-74),不是任意形状遮罩,无羽化(feather)、无反转(invert)、无形状类型。源码里『mask』只出现在 SwiftUI 视图裁剪(GeneratingOverlay/TourOverlay)与 Search 文本 tokenizer 的 attention mask,与视频蒙版无关。AnimatableProperty 是封闭集 {opacity,position,scale,rotation,crop,volume}(Keyframe.swift:78),无 mask 项。 +### 蒙版工具(线性 / 圆形 / 钢笔 mask) — `has` · 难度 medium · 优先级 p1 +- **判定依据**:上游完全没有蒙版能力;OpenTake 已在 Clip 上持久化线性、圆形和钢笔/多边形蒙版及羽化、反相、偏移、缩放、旋转参数,wgpu 以共享 CPU/GPU SDF/even-odd 几何路径在预览与导出中合成。Inspector 支持创建、切换、增删点和删除蒙版,Preview 支持直接拖动多边形顶点,全部变更经过 `SetMasks` 的撤销/重做事务且不改源媒体;每片段最多 4 个蒙版、每个多边形 3–16 点由命令层显式拒绝越界,避免 GPU 静默截断。打包 macOS 工程已验证保存重开、偏移 0.100、旋转 20°、羽化 0.100、反相和真实 H.264 导出;第 60 帧预览/导出 SSIM 0.999753、PSNR 62.854540 dB。证据见 `docs/audit/2026-07-14/runtime-artifacts/automated/mask-rendering-2026-07-31.md`。 - **落点(crate/层)**:opentake-domain(Clip 新增 mask: Option,Mask 枚举 Linear/Radial/Rectangle/Ellipse/Pen{points},含 feather/invert/可关键帧化)+ opentake-render(wgpu 片元着色器算 mask alpha)+ web(前端蒙版手柄/钢笔锚点绘制) - **实现方案**:wgpu 片元着色器 + 纯几何,无需 AI。① 线性/圆形/矩形/椭圆蒙版:着色器内按当前像素 UV 到蒙版几何的有符号距离场(SDF)算 alpha,feather 用 smoothstep 在边界过渡带插值,invert 取 1-alpha,蒙版参数(中心/角度/半径/羽化)全部可挂关键帧(复用上面 easing 体系)。② 钢笔 mask:前端用贝塞尔锚点画闭合路径,Rust 端三角化(lyon crate)或在着色器里用 even-odd 环绕数判定内外 + 距离场做羽化。③ 蒙版作用对象 = 当前 clip 纹理的 alpha 通道,合成时按 z 序混合即可天然实现『局部显隐』。④ 与 Transform 解耦:蒙版坐标系绑定 clip 还是绑定画布需提供开关(剪映两种都有)。这是 wgpu 合成器最自然的扩展,上游因 AVFoundation layer 模型做不了,是 OpenTake 结构性优势点。 - **前置依赖**:强依赖 wgpu 帧合成器(ROADMAP Phase 3)先落地;前端钢笔交互依赖 Phase 6 React Preview;关键帧化依赖 easing 体系 -### AI 运动追踪(自适应形变 motion tracking) — `missing` · 难度 high · 优先级 p2 -- **判定依据**:上游零实现且零基础设施:全目录 grep 不到 import Vision / VNTrackObjectRequest / VNDetectTrajectories / opticalFlow / 任何 tracking 相关(『tracking』命中全是 AppTheme 字间距 letter-spacing 与 NSTrackingArea 鼠标区,见 UI/AppTheme.swift:200、Timeline/TimelineView.swift:894)。关键帧只能手 K,无『跟踪点驱动关键帧』通路。 +### AI 运动追踪(自适应形变 motion tracking) — `partial` · 难度 high · 优先级 p2 +- **判定依据**:OpenTake Beta 已有本地区域块匹配的生产后端和能力门控 Agent 工具:在真实视频解码帧上跟踪归一化矩形,最多采样 48 帧,输出带算法版本与最低置信度的线性位置关键帧;低于 0.25 时返回稳定的 `MCP_ANALYSIS_LOW_CONFIDENCE`,取消、解码失败或工程版本竞争均不提交。`apply=true` 通过统一 `SetKeyframes` 事务写入可编辑 position track,并支持一步撤销。确定性移动目标测试在最终采样点保持小于等于 5 像素误差,真实 MP4 测试覆盖预览、应用、取消与撤销。Inspector 区域选取/进度交互和贴纸/文字绑定仍未完成,因此不判为 `has`。代码证据见 `docs/audit/2026-07-14/runtime-artifacts/automated/motion-tracking-agent-backend-2026-08-01.md`。 - **落点(crate/层)**:opentake-media(新增追踪 worker:ort/candle 跑光流或点追踪模型,输出逐帧 2D 轨迹)→ opentake-domain(把轨迹烘焙成 position/scale/rotation 关键帧)+ opentake-render(贴合 clip/蒙版/文字到轨迹) - **实现方案**:本地推理优先,双轨可选。① 平面/点追踪(剪映主力场景:贴文字/贴 logo 跟随):本地用 CoTracker / TAPIR(点追踪)或经典 Lucas-Kanade 光流(OpenCV-rust 或纯 candle 实现 KLT),输出目标点逐帧 (x,y[,scale,rotation]),再烘焙成现有 position/scale/rotation 关键帧轨——下游复用既有合成,改动面小。② 复用先例:上游已用 SigLIP2 经 ort/candle 做视觉嵌入(Search/Models/VisualEmbedder.swift、VisualModelLoader.swift),OpenTake 推理栈(candle/ort)已就位,追踪模型走同一条 ONNX/CoreML 通道,不必新搭 ML 基础设施。③ 『自适应形变』(网格变形跟随)较重,可二期:用 RAFT 稠密光流 + 薄板样条/网格 warp,在 wgpu 着色器里做形变采样。④ 外部 API 兜底:对追踪质量要求极高时可接 runwayml/第三方,但作为可选 BYOK,默认本地。 - **前置依赖**:依赖 candle/ort 推理栈(设计稿已含,Phase 8 语义搜索同栈);关键帧烘焙依赖 Phase 1 关键帧引擎;形变贴合依赖 wgpu 合成器 @@ -82,13 +84,14 @@ - **实现方案**:本地推理优先,与 chroma key 共用合成下游(都是给 clip 一张 alpha)。① 视频抠像本地模型:RobustVideoMatting(RVM,轻量、时序稳定、有 ONNX 权重)是首选,经 ort 跑,逐帧出 alpha;人像场景可用 MODNet/BiRefNet,通用前景可用 SAM2(分割+追踪一体,但较重)。② 复用上游已验证的 ONNX 推理通道(ort/candle + ModelDownloader 先例,Search/Models/ModelDownloader.swift),模型按需下载缓存,与 SigLIP2/whisper 同套基础设施。③ alpha matte 用 content-hash 缓存(ARCHITECTURE §6 已有物化缓存策略),避免重复推理。④ 外部 API 兜底:质量优先可接 fal/Replicate 的 matting 端点,作为 BYOK 可选(沿用 opentake-gen 双模),默认本地零成本。⑤ matte 出来后下游与 chroma key 完全一致(clip alpha → 多轨混合),边缘可再过 wgpu 羽化/前景色净化。 - **前置依赖**:依赖 candle/ort 推理栈 + ModelDownloader(设计稿已含同类先例);依赖 wgpu 合成器消费 alpha;受益于追踪(SAM2 可一体化) -### 视频防抖(stabilization) — `missing` · 难度 medium · 优先级 p2 -- **判定依据**:上游零实现:全目录 grep 不到 stabiliz/stabilis/VNDetectTrajectories/opticalFlow/任何防抖逻辑;无 Vision 框架引用。无运动估计基础设施。 +### 视频防抖(stabilization) — `has` · 难度 medium · 优先级 p2 +- **判定依据**:上游零实现;OpenTake 已落地本地运动估计和平滑补偿。Clip 持久化独立的防抖轨道(模型/版本、源身份、强度、额外裁切和逐帧平移/旋转关键帧),不覆盖手工变换或源媒体;渲染计划把补偿与手工变换组合,并按最坏位移/旋转自动安全缩放,预览与导出共用同一路径。Inspector 支持分析、重新分析、取消、强度/裁切调整、重置和完整撤销/重做;分析在后台阻塞任务运行,取消保持旧结果且不产生历史事务。打包 macOS 工程已验证 48 个运动采样、65% 强度、3% 裁切、保存重开、重置恢复与 H.264 导出;导出为 1920×1080、60 fps、120 帧,预览/导出第 0 帧 SSIM 0.999557、PSNR 56.843845 dB。证据见 `docs/audit/2026-07-14/runtime-artifacts/automated/stabilization-real-device-2026-07-31.md`。 - **落点(crate/层)**:opentake-media(防抖 worker:两遍分析——估计逐帧全局运动→平滑相机轨迹→反向补偿)+ opentake-domain(把补偿写成 position/scale/rotation 关键帧,或标记 stabilize 参数由 render 应用) - **实现方案**:成熟开源算法可移植,本地、无需重型 AI、难度中。① 最省力路线:直接复用 FFmpeg vidstab(vidstabdetect + vidstabtransform 两遍),OpenTake 已绑 ffmpeg-next(ARCHITECTURE §10),导出路径几乎零成本接入;实时预览可先用降采样代理。② 自研路线(更可控):光流/特征点(KLT)估全局仿射运动 → 用低通/高斯平滑相机路径 → 反向仿射补偿 + 自动裁掉边缘黑边(轻微放大)。补偿量可烘焙进现有 transform 关键帧轨,复用既有合成,无需新合成原语。③ 平滑强度/裁切比例作为可调参数。④ 可选 AI 增强(深度学习防抖如 DUT)作为远期 p3,默认经典算法已够用。 - **前置依赖**:FFmpeg vidstab 路线仅依赖 ffmpeg-next(已有);自研路线依赖光流(与追踪共用);补偿应用依赖关键帧引擎/wgpu ### 画质超清修复(super-resolution / enhance) — `partial` · 难度 medium · 优先级 p1 +- **OpenTake 当前状态(2026-07-29)**:BYOK 云端 2x 超分竖切已完成。`upscale_media` 经共享 GenerationBridge 创建耐久占位、上传源素材、轮询 Replicate、校验结果尺寸严格为源宽高 2 倍并作为新资产导入;原资产字节与元数据保持不变。UI 已提供进度/取消/失败/重试,重试要求重新确认成本。确定性生产路径测试覆盖成功、取消不导入、恢复、鉴权/限流、源不变与精确 2x。本地 Real-ESRGAN/SeedVR 修复轨仍未实现,因此总体仍标 `partial`。 - **判定依据**:上游有『Upscale』入口但纯云端、且仅放大不修复:AIEditTab『AI Enhance / Upscale / Enhance resolution with AI』(Inspector/AIEditTab.swift:31-36)、UpscaleModelConfig 从 Convex ModelCatalog 拉取(Generation/Catalog/UpscaleModelConfig.swift)、EditSubmitter.submitUpscale 把 sourceURL+durationSeconds 发后端(UpscaleModelConfig.swift:3-15)、需登录订阅否则禁用(ToolExecutor+Generate.swift:320)、video 仅 <2160 可放大(MODULE-PORT-MAP.md:528)。本地零推理。OpenTake 设计稿 Generation 章把它归为 cloud-rebuild,未规划本地超分实现(MODULE-PORT-MAP.md:231、ARCHITECTURE §8 只讲生成代理双模,未列本地 SR)。 - **落点(crate/层)**:opentake-gen(BYOK 云超分,复刻 UpscaleGenerationParams + job 状态机,设计稿已含)+ 新增 opentake-media 本地超分 worker(ort/candle 跑 SR 模型)作为 OpenTake 增强项 - **实现方案**:双轨。① 云端 BYOK(低成本起步、对齐上游):opentake-gen 已规划复刻 GenerationParams 联合类型与 job 抽象(ARCHITECTURE §8、ROADMAP Phase 9),upscale 作为其中一类,用户自带 fal/Replicate key 直连厂商超分模型(Topaz/SeedVR/Real-ESRGAN 端点),零运营成本,这是把上游云能力『去 Convex 化』的自然落点。② 本地推理(OpenTake 反超点,p2):经 ort 跑 Real-ESRGAN / SwinIR(图像)或 SeedVR2(视频,时序一致),复用 SigLIP2/RVM 同套 ONNX 通道与 ModelDownloader;『修复』(去噪/去压缩伪影/人脸增强 GFPGAN/CodeFormer)与『放大』分开提供,补上上游只放大不修复的短板。③ 处理结果作为新媒体资产回填时间线(沿用上游 upscale 回填语义)。 @@ -100,13 +103,15 @@ - **实现方案**:依赖蒙版先行,本地+云双轨。① 用户/AI 用蒙版工具(本模块第2项)圈出待消除区域(或 AI 自动分割出对象),生成逐帧 mask。② 本地推理:图像用 LaMa(ONNX,擦除效果好);视频用 ProPainter / E2FGVI(时序一致视频补全,有开源权重),经 ort 跑;轻度瑕疵/人脸可用更小模型。复用 RVM/SAM2/超分同套 ONNX 通道。③ 外部 API 兜底:fal/Replicate 的 video-inpaint / object-removal 端点作为 BYOK 可选(opentake-gen 双模),质量优先时用。④ 与 AI 抠像、追踪强协同:SAM2 可同时给分割 mask + 跨帧追踪,是消除/抠像/追踪三项的共享上游。结果回填为新片段或叠加层。这是模块2 里依赖链最长、工程量最大的一项。 - **前置依赖**:强依赖蒙版工具(p1)产出区域 + AI 抠像/追踪(SAM2 共享)+ candle/ort 推理栈;视频 inpaint 模型较重 -### 通用视觉特效 / 滤镜(effects/filters)+ 调色 — `missing` · 难度 medium · 优先级 p0 +### 通用视觉特效 / 滤镜(effects/filters)+ 调色 — `has` · 难度 medium · 优先级 p0 +- **OpenTake 当前状态(2026-07-31)**:首个完整效果链竖切已落地。闭合注册表提供灰度、棕褐、反相三个真实 WGSL 滤镜,每项支持 0–100% 强度、启用开关和有序串联;领域/命令/渲染边界校验名称与参数,未知效果返回类型化错误而不静默直通。Inspector 的新增、上移/下移、调参、开关、删除均经同一个可撤销 `SetEffects` 命令;原生播放路由与导出复用同一 GPU 合成器。拥有测试覆盖每个滤镜默认/非默认像素基准、顺序可观察性和预览/导出逐字节一致;打包端真实工程还完成了保存重开、原生播放、H.264 导出和首帧一致性复核(SSIM 0.999838、PSNR 39.679823 dB),证据见 `docs/audit/2026-07-14/runtime-artifacts/automated/generic-effects-real-device-2026-07-31.md`。更大的模糊、发光、LUT 和关键帧化库属于后续扩展,不影响本竖切的完成语义。 - **判定依据**:上游 FAQ 明示『尚无特效/调色/图形』,源码坐实:Clip 无 effect/filter/colorGrade/lut 字段(Models/Timeline.swift:77-107);Preview 合成 grep 不到 CIFilter/blendMode/colorControls/任何调色或滤镜(Preview/CompositionBuilder.swift 等);AVVideoComposition 只做 transform/crop/opacity ramp。AnimatableProperty 封闭集无 effect。导出 XMLExporter 也只导 transform/crop/opacity/fade/音量,不导任何滤镜。 - **落点(crate/层)**:opentake-domain(Clip 新增 effects: Vec 效果链 + 调色字段/LUT 引用,Effect 参数可关键帧)+ opentake-render(wgpu 片元着色器效果库 + RenderPlan 串联效果链)+ opentake-agent(MCP 加 apply_effect 工具) - **实现方案**:wgpu 着色器效果框架 = 一切像素特效的地基,本地、无需 AI。① 在 RenderPlan 里给每个 clip 增加『效果链』:解码纹理 → 依次过 N 个着色器 pass → 合成。② 基础调色(剪映高频):brightness/contrast/saturation/exposure/temperature/tint/HSL/曲线,纯片元运算;专业级支持 3D LUT(.cube 加载为 3D 纹理,着色器三线性采样)对齐达芬奇/剪映滤镜包。③ 滤镜/特效:模糊(高斯/方向/径向)、锐化、晕影、颗粒、故障、复古、发光(bloom,需多 pass)等,逐个着色器实现,所有参数挂关键帧(复用 easing)。④ 效果链顺序、开关、强度可调。⑤ FFmpeg 滤镜(eq/curves/lut3d/gblur)作为导出兜底,但实时预览统一走 wgpu 保预览=导出一致(ARCHITECTURE §6 共享 RenderPlan 原则)。⑥ 这是上游被 AVFoundation 锁死、OpenTake 自写合成器后『整类能力解锁』的核心机会(MODULE-PORT-MAP.md:391 自渲染方案)。建议先搭『效果链 + 1~2 个调色 pass』的最小框架,再增量加效果。 - **前置依赖**:强依赖 wgpu 帧合成器(Phase 3,项目命门);效果参数关键帧化依赖 easing 体系;前端调参 UI 依赖 Phase 6 -### 转场(transitions) — `missing` · 难度 medium · 优先级 p0 +### 转场(transitions) — `has` · 难度 medium · 优先级 p0 +- **OpenTake 当前状态(2026-07-31)**:首个可编辑、可持久化、可渲染的 clip-to-clip 转场竖切已完成。`Transition` 明确保存 `fromClipId`、`toClipId`、类型和帧时长;命令只接受同一视觉轨的直接相邻片段,超出双方可用安全 handle 的时长会明确拒绝而不是静默夹取。素材面板“转场”页已启用,可添加、改时长、删除,并共享全局撤销/重做;时间线接缝绘制转场标记。首批注册类型为交叉溶解,预览与导出复用同一 RenderPlan/GPU alpha-over 路径;拥有测试覆盖保存重开、过长拒绝、添加/修改/删除/撤销/重做,以及转场首帧、中点、末混合帧和切点的像素基准与预览/导出逐字节一致。打包应用还完成了旧模型迁移、删除/撤销/重做/重加、20 帧调时长、保存重开、跨切点播放和 920 帧完整导出;第 140 帧原生预览/导出对比为 SSIM 0.997060、PSNR 37.91 dB,证据见 `docs/audit/2026-07-14/runtime-artifacts/automated/transition-real-device-2026-07-31.md`。更多 wipe/slide/3D 类型是后续库扩展,不影响本竖切完成语义。 - **判定依据**:上游无真正转场引擎:Clip 无 transition 字段、轨道无 clip-to-clip 转场模型(Models/Timeline.swift);源码『transition』仅两处——AppTheme.Anim.transition(UI 动画时长,与视频无关)与 XMLExporter 把单边 fade 映射成 Final Cut 的 Cross Dissolve/Cross Fade 导出占位(Export/XMLExporter.swift:296-341,且注释明示『no clip-to-clip model』『single-sided』)。所谓『matte 转场』只是 Agent 用 crop 关键帧做信箱遮罩的提示词技巧(AgentPanelView.swift:20),非转场原语。现有只有 fade in/out(fadeInFrames/fadeOutFrames + 插值,Timeline.swift:87-90),是单片淡变不是双片转场。 - **落点(crate/层)**:opentake-domain(新增 Transition 模型:轨道上两相邻 clip 之间的转场区,含类型/时长/参数;或 clip 持 transition_in/out)+ opentake-ops(转场区与覆盖/波纹/分割的交互算法)+ opentake-render(wgpu 双源混合着色器)+ opentake-agent(MCP add_transition 工具) - **实现方案**:wgpu 双纹理混合着色器 + 时间线模型扩展,本地无 AI。① 数据模型:在两相邻 clip 重叠/接缝处定义转场区(duration、type、参数),需扩展 ops 层处理转场区与 ripple/overwrite/split/trim 的交互(这是逻辑难点——上游编辑算法纯按 clip 区间,引入转场区要保证不破坏对齐)。② 渲染:转场区内同时解码前后两 clip 的帧,着色器按进度 t(挂 easing)混合——溶解(crossfade,两纹理 alpha 插值)、擦除/滑动(wipe/slide,按 UV 阈值/位移切换)、缩放/旋转、模糊溶解、亮度/亮片过渡等,GL Transitions 开源库有大量现成着色器可直接移植到 wgsl。③ fade in/out 作为退化的单边转场保留兼容。④ 导出可继续映射到 XMEML Cross Dissolve(上游已有)+ wgpu 烧录非标准转场。⑤ 建议先做 crossfade(改动最小)验证转场区与编辑算法的交互,再铺开 wipe/3D 等。 @@ -124,7 +129,8 @@ - **实现方案**:纯本地 wgpu 方案,零外部模型:在合成片元着色器里把每帧像素 BT.709→线性光(去 gamma)后,以 f32 RGB 跑统一调色链,最后线性→BT.709 编码回去。这是其余 5 项特性的公共底座——色轮/曲线/HSL/LUT 都是这条链上的算子。domain 加 ColorGrade{exposure,contrast,saturation,temp,tint,lift/gamma/gain,curves,hsl,lut_ref}(全 #[serde(default)] 保证读旧工程不破),render 把它编进 RenderPlan 逐 clip 传 uniform/storage buffer。预览与导出共享同一 RenderPlan 保证像素一致(对齐 ARCHITECTURE.md:120)。导出走 ffmpeg 编码前在 wgpu 已调好,无需 ffmpeg 调色滤镜。 - **前置依赖**:强依赖 Phase 3 wgpu 帧合成器 blocker 先打通(ARCHITECTURE.md:24 列为🔴命门);需先确立'线性光工作空间'与色彩管理约定(BT.709 解码/编码节点)。 -### 色轮(暗部 lift / 中灰 gamma / 亮部 gain 矩阵) — `missing` · 难度 medium · 优先级 p1 +### 色轮(暗部 lift / 中灰 gamma / 亮部 gain 矩阵) — `has` · 难度 medium · 优先级 p1 +- **OpenTake 当前状态(2026-07-31)**:`ColorGrade` 已持久化三组 RGB lift/gamma/gain,Inspector 可逐通道调节并走统一 `SetColorGrade` 撤销/重做。CPU 参考与 WGSL 锁定同一线性光公式 `gain * pow(max(x + lift * (1 - x), 0), 1 / gamma)`:lift 向高光滚降、gamma 塑形中灰、gain 独立控制高光。所有字段按 Inspector 范围做有限数校验,gamma 严格大于 0;命令在变更前拒绝恶意输入,合成器在解析素材前拒绝损坏持久化值。拥有 GPU 测试覆盖三通道非中性参数、CPU/源公式一致、预览/导出逐字节一致和无副作用拒绝。打包应用已完成可见调节、四步撤销/重做、保存重开、播放和 920 帧完整导出;第 0 帧预览/导出 SSIM 0.999283、PSNR 38.47 dB,证据见 `docs/audit/2026-07-14/runtime-artifacts/automated/lgg-real-device-2026-07-31.md`。 - **判定依据**:grep lift-gamma-gain/colorWheel 上游命中 0;Clip 模型与 6 条关键帧轨(Models/Timeline.swift:102-107)均无颜色通道;设计稿无色轮规划。属浮点调色引擎之上的标准算子,上游/OpenTake 均未覆盖。 - **落点(crate/层)**:opentake-render(着色器内 lift/gamma/gain 数学)+ opentake-domain(ColorGrade 内 lift/gamma/gain 各一组 RGB 偏移)+ web 前端(三色轮 UI 控件) - **实现方案**:纯本地着色器数学,DaVinci 经典公式:out = gain * (in + lift*(1-in)) ^ (1/gamma),三参数各为 RGB 三通道向量。在线性光空间(浮点引擎已建)对每像素做一次乘加+幂运算即可,GPU 成本极低。前端三个色轮控件输出 RGB 偏移,经 Tauri command 写 ColorGrade,走 SetColorGrade 命令入 UndoStack。可叠加关键帧化(复用上游关键帧采样 hold/linear/smoothstep 算法,但需为颜色新增关键帧轨,工作量在 domain 侧)。 @@ -136,14 +142,16 @@ - **实现方案**:纯本地方案:前端用控制点定义曲线,Rust 端把每条曲线在 CPU 预烘焙成 1D LUT(256/1024 项),作为 1D 纹理上传,着色器对 R/G/B 各通道做 1D 纹理采样(textureSample)即可,运行时零计算开销。曲线插值可复用 Catmull-Rom 或单调三次样条(可直接引 crate splines)。Master 曲线作用于亮度或三通道统一,RGB 各自独立。注意曲线应在合适色彩空间(常用 gamma 编码空间而非线性,需与色轮顺序明确定义)。 - **前置依赖**:依赖'高阶浮点调色引擎'(P0);需明确调色链内曲线相对色轮/LUT 的次序。 -### HSL 分区调色 — `missing` · 难度 medium · 优先级 p1 -- **判定依据**:grep HSL/hue/saturation 上游命中 0(全部 color 标识符均为 UI 主题色 primaryColor/TrackColor/textColor,见 grep 统计);Clip 无颜色字段;设计稿无规划。 +### HSL 分区调色 — `has` · 难度 medium · 优先级 p1 +- **OpenTake 当前状态(2026-07-31)**:`ColorGrade` 已持久化可环绕的色相中心/范围、羽化、色相偏移、相对饱和度和明度偏移,并在命令与合成器入口拒绝非有限或越界值。CPU 参考与 WGSL 共享圆周色相距离和 smoothstep 羽化,灰色像素不被误选;Inspector 提供启用、六参数编辑和重置,全部走统一 `SetColorGrade` 撤销/重做。真实 Metal/wgpu 四色色卡测试证明选中红色改变、边界橙色只受羽化影响、绿/蓝保持在 2 码容差内,预览/导出逐字节一致。打包应用已完成可见蓝转紫、五步撤销/重做、重置恢复、保存重开、原生播放和 920 帧完整 H.264/AAC 导出;证据见 `docs/audit/2026-07-14/runtime-artifacts/automated/hsl-secondary-real-device-2026-07-31.md`。 +- **判定依据**:上游仍无 HSL/hue/saturation 调色实现;本状态描述的是 OpenTake 已完成的增强能力。 - **落点(crate/层)**:opentake-render(着色器内 RGB↔HSL 转换 + 分区加权)+ opentake-domain(ColorGrade 内 8 个色相区的 H/S/L 偏移)+ web 前端(分区滑杆/取色器) - **实现方案**:纯本地着色器方案:片元着色器内把像素 RGB 转 HSL,按目标色相区(如红/橙/黄/绿/青/蓝/紫/品红 8 区)用平滑权重(基于色相距离的高斯/三角窗,避免硬边带)对落在该区的像素施加 H/S/L 偏移,再转回 RGB。这是 Lumetri/达芬奇 HSL Qualifier 的简化版。进阶可做'限定器'(luma/sat/hue 三维 key + 羽化),但首版做 8 固定区即可覆盖剪映同类能力。全部为本地像素数学,无需外部模型。 - **前置依赖**:依赖'高阶浮点调色引擎'(P0);需 RGB↔HSL 着色器工具函数。 -### 3D LUT 导入 — `missing` · 难度 medium · 优先级 p1 -- **判定依据**:grep LUT/.cube/colorMatrix 上游命中 0;_analysis/02:11 明确上游无任何 GPU shader/CoreImage 滤镜;设计稿(含技术选型清单 ARCHITECTURE.md:172-190)无 LUT 解析或 3D 纹理规划。 +### 3D LUT 导入 — `has` · 难度 medium · 优先级 p1 +- **OpenTake 当前状态(2026-07-31)**:已实现 4 MiB 上限、严格元数据/DOMAIN 校验和完整 17/33 点表解析;导入只接受绝对路径的 no-follow 普通 `.cube` 文件,验证后按 SHA-256 原子发布到工程 `media/luts`,Clip 仅持久化无路径的 id/name/intensity。预览、原生播放、MCP 检查与导出共享同一哈希复验/解析/RGBA16F 3D 纹理三线性采样链,缺失或篡改时显式失败。Inspector 已提供导入、名称、强度、移除和撤销/重做。真实 Metal/wgpu 测试覆盖 identity、已知变换、预览/导出逐字节一致与损坏/过大拒绝;打包应用已完成可见导入、35% 强度、撤销/重做、移除恢复、保存重开、播放和 920 帧完整 H.264/AAC 导出,证据见 `docs/audit/2026-07-14/runtime-artifacts/automated/lut-real-device-2026-07-31.md`。 +- **判定依据**:上游仍无 LUT/.cube/colorMatrix 或 GPU shader/CoreImage 滤镜;本状态描述的是 OpenTake 已完成的增强能力。 - **落点(crate/层)**:opentake-media 或 opentake-project(.cube/.3dl 文件解析 + 校验)+ opentake-render(3D 纹理三线性采样)+ opentake-domain(ColorGrade 内 lut_ref 指向工程内 LUT 资产 + 强度 0..1) - **实现方案**:纯本地、跨平台、无外部模型:Rust 端写 .cube 解析器(行业标准 ASCII 格式:LUT_3D_SIZE N + N³ 行 RGB,可参考 crate 'lut' 或几十行自写,顺带支持 .3dl/.cube 1D)。把 LUT 上传为 wgpu 3D 纹理(rgba16f),着色器对线性/log 输入做三线性插值采样,再按强度与原图 mix。LUT 应放进 .opentake 工程包(媒体清单管理,复刻 content-hash 缓存机制 ARCHITECTURE.md:121),clip 只存 lut_ref(=资产 id),与上游'clip 永不存路径'语义一致(_analysis/01:127)。需明确 LUT 期望的输入色彩空间(sRGB/Rec709/Log)并在采样前做对应转换,否则色偏。 - **前置依赖**:依赖'高阶浮点调色引擎'(P0)的色彩空间约定;依赖工程包媒体管理(opentake-project,Phase 2 已规划)。 @@ -156,25 +164,25 @@ ## 模块4:音频工程与智能字幕 -**整体差距**:总体判断:本模块在 OpenTake/上游中呈"字幕强、音频空"的两极分布。字幕侧上游有完整端上转写子系统(Transcription 模块)+确定性断句计时(CaptionBuilder)+一键生成(CaptionTab/generateCaptions)+ MCP 工具(add_captions/get_transcript),OpenTake 设计稿已在 ARCHITECTURE §6 与 Phase 8 明确把这套纯逻辑直译进 opentake-domain/opentake-media,并用 whisper-rs 替换 Apple Speech——故"高精度 ASR 一键转字幕"判 has。音频工程侧(响度统一/降噪/人声分离)经 word-boundary grep 确认上游零实现:全工程无 loudness/LUFS/EBU-R128/loudnorm/denoise/noise gate/vocal isolation/source separation/EQ/compressor 任一关键字(唯一的 VideoCompressor 是上传前视频体积压缩,与音频无关),音频能力只有 Clip 静态 volume(线性)+ volumeTrack 关键帧(以 dB 存,VolumeScale=20·log10,floor -60/ceil +15)+ fade in/out + track mute。OpenTake 设计稿同样只移植 volume 包络与 VolumeScale,未引入测量/降噪/分离——故三项音频修复全判 missing。字幕翻译上游仅以 LLM Agent 草稿提示形式存在(captionTask("translate the captions to X")→handoff 到聊天面板),非独立 MT 引擎,OpenTake 继承同一 agent 机制但无一等公民翻译能力,判 partial。字幕样式全局批量同步:上游用 captionGroupId 把同组字幕共享样式、get_timeline 把众数样式 hoist 进 shared,但无"改一处样式→批量回写整组"的命令(实际靠 agent 逐条 set_clip_properties),数据模型在、批量算子缺,判 partial。导出 .srt:上游 Export 仅有视频渲染(文字经 CoreAnimation 烤进画面)+ FCPXML/XMEML(XMLExporter 明确声明文字不导出)+ .palmier 打包,全工程零 .srt/.vtt/SubRip,OpenTake 文档亦从未提及字幕文件导出,判 missing。落地优先级:ASR 已覆盖(p0 仅指接 whisper);响度统一与 SRT 导出是高性价比纯 FFmpeg/纯逻辑补齐(p1);降噪 p1(FFmpeg 内置滤镜先行,深度学习降噪 p2);批量样式同步是低成本编辑命令(p1);人声分离需引入额外深度模型,工程量最大(p2);翻译可先靠 agent 兜底再补离线/外部 API(p2)。所有音频特性的"施加层"已存在(per-clip dB 增益 + FFmpeg 音频滤镜链),真正缺口在"分析/处理层"。 +**整体差距**:总体判断:本模块在 OpenTake/上游中呈"字幕强、音频处理逐项补齐"的分布。字幕侧 OpenTake 已用 whisper-rs + 确定性断句/计时完成一键字幕,并已补齐上游没有的 SRT/VTT 软字幕导出;前端、Tauri 文件边界和纯序列化层均已接通。音频工程侧已完成响度归一、本地智能降噪和限定范围的本地 centre/side 双 stem 提取,三者都接入真实预览/导出链;其中 stem 提取尚不等同于对任意混音进行语义级人声/伴奏分离,仍判 partial。字幕翻译仍只是 Agent 草稿路径,未形成一等公民翻译工作流,判 partial。字幕样式已具备 captionGroupId 与共享样式模型,但缺少"改一处→原子批量回写整组"的完整 UI/命令闭环,判 partial。当前落地优先级是字幕批量样式;语义级人声分离与一等公民翻译保持后续独立验收项。 -### 音频响度统一(loudness normalization / LUFS 归一) — `missing` · 难度 medium · 优先级 p1 -- **判定依据**:上游源码 word-boundary grep 对 loudness/LUFS/EBU/R128/loudnorm 全部 0 命中;音频模型仅 Models/Timeline.swift 的 volume(线性静态)+ volumeTrack(dB 关键帧)+ fadeIn/Out + track muted。VolumeScale(Inspector/InspectorView.swift:1072,20·log10,floor -60dB/ceil +15dB)证明只有手动增益、无任何自动测量/归一。OpenTake docs(MODULE-PORT-MAP Preview/Timeline 移植策略)只把 VolumeScale 与 volume 包络 direct-port,未提响度测量;ARCHITECTURE/ROADMAP 无任何响度相关条目。 -- **落点(crate/层)**:分析落 opentake-media(新增 audio analyze 模块,产出 integrated/true-peak/LRA);应用落 opentake-ops(新增 EditCommand::NormalizeLoudness,把目标 LUFS 反算成 clip 静态 volume 的 dB 增量,复用既有 VolumeScale/volume);单段也可走 opentake-render 导出期 FFmpeg loudnorm 二次扫描。 -- **实现方案**:纯 FFmpeg 跨平台,无需外部 API:用 ffmpeg ebur128 滤镜(或 loudnorm print_format=json 第一遍分析)测得每个音频源的 I/TP/LRA,按目标(常用 -14 LUFS / 短视频 -16 LUFS)算增益,写回 clip.volume(dB→linear 经 VolumeScale)。导出要严格达标时再走 loudnorm 双轨(linear=true,measured_* 回填)。本地算法,零模型、零联网。可做整轨/逐clip/全时间线三档。 -- **前置依赖**:依赖 opentake-media 的 FFmpeg 音频解码链(Phase 2 已规划);写回增益依赖 opentake-domain 的 volume/VolumeScale(Phase 1 已就绪)。无 blocker。 +### 音频响度统一(loudness normalization / LUFS 归一) — `has` · 难度 medium · 优先级 p1 +- **判定依据**:OpenTake 已实现本地 EBU R128/BS.1770 集成响度、门限分块和 4× 插值真峰值分析,并把目标 LUFS、真峰值上限、测量值和增益持久化在 Clip。操作可撤销/重做,检查器提供分析、重新分析、重置、进度、取消和静音/无法读取的类型错误。 +- **落点(crate/层)**:`opentake-media::analysis::loudness` 拥有纯 Rust 分析和增益计算;`opentake-domain::LoudnessNormalization` 拥有持久化合同;`opentake-ops::EditCommand::SetLoudnessNormalization` 拥有撤销语义;Tauri 分析命令、原生播放和导出混音复用同一增益与真峰值限制阶段。 +- **验收结果**:在重建的 macOS release `.app` 中,语音和音乐的 AAC 导出成品分别由独立 FFmpeg `loudnorm` 测得 `-16.07 LUFS / -1.15 dBTP` 与 `-16.02 LUFS / -1.74 dBTP`,均满足 `-16 LUFS ±1 LU` 且不超过 `-1 dBTP`。保存/重开保持可写和分析结果,静音文件在真实 UI 返回 `loudness_silent_audio`。详见 `docs/audit/2026-07-14/runtime-artifacts/automated/loudness-real-device-2026-08-01.md`。 +- **前置依赖**:已使用现有 FFmpeg 音频解码链和 Clip volume 包络完成,无外部 API、模型或联网依赖。 -### 智能降噪(noise reduction) — `missing` · 难度 medium · 优先级 p1 -- **判定依据**:上游对 denoise/noiseReduction/noiseGate/noiseFloor/spectralGate/audioFilter/AVAudioUnit 全部 0 命中,无任何音频效果链。Export 仅 audioMix 做音量混音(CompositionBuilder),无降噪节点。OpenTake docs 无降噪条目。 -- **落点(crate/层)**:opentake-media 新增 audio-fx 处理层(离线渲染音频片段→处理后 content-hash 缓存,与缩略图/波形缓存同构);opentake-domain clip 新增可空 audio_fx 描述字段,由 opentake-render 在合成/导出时插入 FFmpeg 音频滤镜链。 -- **实现方案**:分两档跨平台。轻量档(p1 先行):FFmpeg 内置 afftdn(FFT 频域)/anlmdn(非局部均值)/highpass+lowpass+agate(噪声门),纯本地零依赖,覆盖稳态底噪/嗡声/嘶声。深度档(p2):引入 RNNoise(成熟 C 库+Rust 绑定,可静态链接,跨平台)或用 candle/ort 跑 DeepFilterNet ONNX 做非稳态人声降噪(贴近剪映'智能降噪')。降噪做成可选音频效果而非破坏式,结果落 content-hash 缓存。避免调外部云 API。 -- **前置依赖**:需先有 opentake-media 音频解码/重编码 PCM 往返;深度档需 ort/candle 运行时(已在栈内用于 SigLIP2)+ 模型分发渠道(可学上游 SearchIndexConfig 静态 CDN 下载);需在 domain 增 clip 效果字段并打通 render 音频路径。 +### 智能降噪(noise reduction) — `has` · 难度 medium · 优先级 p1 +- **判定依据**:OpenTake 已实现自适应/人声两种本地频域降噪模式及可调强度,参数作为可空 `AudioDenoise` 合同持久化在 Clip,源素材保持不变。检查器提供预览开关、应用/重新应用、重置、进度、取消和撤销/重做;保存重开后项目仍可写且参数保持。 +- **落点(crate/层)**:`opentake-media::analysis::denoise` 是共享的纯 Rust FFT 处理 owner;`opentake-domain::AudioDenoise` 与 `opentake-ops::EditCommand::SetAudioDenoise` 分别拥有 wire/持久化和撤销语义;Tauri 原生播放与导出都调用同一参数和处理 owner,异步准备任务具备单飞、进度与取消。 +- **验收结果**:在重建的 macOS release `.app` 中,真实操作覆盖两种模式、强度、预览开关、应用/重置、长素材取消、撤销/重做、播放、保存重开和“预览关闭仍导出处理”。确定性语音加噪声夹具的 SDR 从 `10.414 dB` 提升至 `16.2872 dB`(`+5.8732 dB`),AAC 成片峰值为 `-8.645 dB`,无削顶。详见 `docs/audit/2026-07-14/runtime-artifacts/automated/denoise-real-device-2026-08-01.md`。 +- **前置依赖**:仅使用现有 PCM 解码/编码路径与新增纯 Rust FFT 处理,无云 API、模型下载或联网依赖。 -### 人声分离 / 提取人声(vocal isolation / source separation) — `missing` · 难度 high · 优先级 p2 -- **判定依据**:上游对 vocalIsolation/sourceSeparation/stemSeparation/demucs/spleeter 全部 0 命中(grep 命中的 stem 是 ToolExecutor+Import.swift 文件名变量,与音频无关)。无任何 stem 分离能力。OpenTake docs 无相关条目。 -- **落点(crate/层)**:opentake-media 新增 stem-separation 模块(输入音频源→输出 vocals/accompaniment 多轨 stem,落工程 media/ 作为派生素材);分离出的 stem 作为新 MediaAsset 回填,经 opentake-ops AddClips 放新轨,复用既有素材/轨道机制,无需改 domain 核心。 -- **实现方案**:必须引入深度模型,无纯 FFmpeg 等价。优先本地跨平台:用 ort(onnxruntime 已在栈)跑 Demucs(htdemucs)或 MDX-Net 的 ONNX 导出,GPU 可选 CPU 兜底;或集成成熟 C/C++ 推理库经 FFI。把分离做成异步任务(对齐上游 generate 的 job 语义:placeholder→ready),结果作为派生 stem 入库。次选:托管模式走 opentake-gen-proxy 外接分离 API。本地优先以保隐私与零成本。这是本模块工程量最大、最依赖模型与算力一项。 -- **前置依赖**:依赖 ort/candle 推理运行时 + 模型权重分发 + opentake-media 音频 IO + 任务/进度框架(可复用 opentake-gen job 状态机);GPU 加速可选。建议放在音频基建(解码/缓存/效果链)就绪之后。 +### 人声分离 / 提取人声(vocal isolation / source separation) — `partial` · 难度 high · 优先级 p2 +- **判定依据**:OpenTake 已提供可用的本地 `opentake-center-v1` centre/side 双 stem 提取:模型清单原子安装并校验 SHA-256,异步任务支持进度/取消/失败清理,vocals/accompaniment 作为带源素材和模型哈希 provenance 的派生素材原子回填工程。检查器明确显示本地不上传;托管模式必须填写 provider/model 并确认上传,但在无适配器时 fail-closed,当前不会发生网络上传。该本地算法适合人声/对白位于立体声中央、伴奏具有侧向差异的素材;它不是 Demucs/MDX 语义模型,对居中乐器、混响或单声道混音不能保证语义隔离,因此不判 `has`。 +- **落点(crate/层)**:`opentake-media::analysis::stems` 拥有安装、校验、解码、centre/side 处理和原子 WAV 发布;`opentake-gen::stems` 拥有本地/托管显式路由与隐私门槛;`opentake-core` 以普通 MediaAsset 批量导入两个 stem 并持久化 provenance;Tauri 单飞任务和 Inspector 提供进度、取消、恢复提示及直接预览。两路输出采用 stereo dual-mono,以兼容当前 mono 导出混音。 +- **验收结果**:重建的 macOS release `.app` 已验证托管门槛、本地分离、保存重开、直接预览、时间线播放、两路 H.264/AAC 导出和 1,800 秒任务取消清理。确定性 centre/side 夹具的直接 vocals/accompaniment SDR 分别为 `85.3732 dB` / `83.8316 dB`;导出 AAC 相对对应参考为 `34.1008 dB` / `25.5688 dB`。详见 `docs/architecture/STEM-SEPARATION.md` 与 `docs/audit/2026-07-14/runtime-artifacts/automated/stems-real-device-2026-08-01.md`。 +- **剩余差距**:若目标是任意混音的语义级人声/伴奏分离,仍需受校验的 Demucs/MDX 等本地模型及运行时,或实现经过隐私同意的托管 provider adapter;该能力保持独立验收项。 ### 高精度 ASR 一键转字幕 — `has` · 难度 medium · 优先级 p0 - **判定依据**:上游有完整链路:Transcription 模块(Transcription.swift/TranscriptCache.swift,词级+段级时间戳、语言匹配、缓存)、CaptionBuilder(确定性断句+按字符计时+最小时长防重叠)、EditorViewModel.generateCaptions(可见源区间转写→短语归属 clip→插新文本轨,一步撤销)、CaptionTab 一键 Generate Captions UI、MCP 工具 add_captions/get_transcript(ToolDefinitions.swift)。OpenTake 在 ARCHITECTURE §6(转写=whisper-rs,word/segment 时间戳)、Phase 8、MODULE-PORT-MAP Transcription 移植策略明确:数据模型与 CaptionBuilder/缓存/搜索算法 direct-port 进 Rust,ASR 引擎换 whisper-rs,上层'区间转写→切行→归属'逻辑不变。设计稿已完整覆盖。 @@ -194,10 +202,10 @@ - **实现方案**:纯编辑层、零媒体依赖、低成本:按 captionGroupId 选出组内全部文本 clip,对 textStyle 做不可变 partial merge(字体/字号/颜色/背景/描边/对齐/大小写),复用既有 mutateClips 快照撤销与 timelineFrame 不变性;样式 patch 语义对齐 set_clip_properties 文本字段。可选支持'跨组/全工程所有字幕'范围与'仅改差异项'。高频刚需且实现廉价,建议早做。 - **前置依赖**:依赖 opentake-domain captionGroupId 与 TextStyle(Phase 1 就绪)、opentake-ops 命令/撤销栈(Phase 1)、前端 Inspector(Phase 6)。无前置 blocker。 -### 导出 .srt 字幕文件 — `missing` · 难度 low · 优先级 p1 -- **判定依据**:上游 Export 模块(ExportService/XMLExporter/PalmierProjectExporter)对 .srt/.vtt/SubRip/WebVTT 全部 0 命中;导出只有三类——视频渲染(文字经 AVVideoCompositionCoreAnimationTool 烤进画面)、FCPXML/XMEML(MODULE-PORT-MAP 明确'文字不导出'/XMLExporter 注释声明文字叠加不进 XMEML)、.palmier 工程包。OpenTake docs(ARCHITECTURE/ROADMAP/MODULE-PORT-MAP Export)从未提及字幕文件导出。完全空白。 -- **落点(crate/层)**:opentake-project 或 opentake-render 导出层新增 subtitle exporter(纯函数 Timeline→SRT/VTT 文本);opentake-agent 可加 export_captions 工具;前端导出对话框加'导出字幕(.srt/.vtt)'选项。 -- **实现方案**:纯逻辑、跨平台、极低成本:遍历时间线文本/字幕 clip(优先 captionGroupId 组),按 startFrame/durationFrames 用 timeline.fps 反算时间码(SRT 用 HH:MM:SS,mmm 逗号毫秒;VTT 用点毫秒),按起始时间排序、合并同帧、输出标准 SubRip。帧→时间用既有 frameToSeconds(f/fps)、秒→时码做整毫秒;可选导出'烧录字幕'(已有,走视频渲染)对'软字幕文件'(本项)两条路。无外部依赖,纯字符串生成。建议同时支持 .vtt 便于 Web/平台分发。 +### 导出 .srt 字幕文件 — `has` · 难度 low · 优先级 p1 +- **判定依据**:上游仍没有 .srt/.vtt/SubRip/WebVTT 导出;OpenTake 已补齐并反超。`opentake-domain::subtitle_export` 从 caption clip 生成标准 SRT/VTT,Tauri `export_subtitles` 负责落盘并返回 cueCount,TitleBar 通过原生保存对话框提供两种格式且对完成、空字幕、失败给出可见反馈。 +- **落点(crate/层)**:`crates/opentake-domain/src/subtitle_export.rs`(纯 Timeline→SRT/VTT)→ `src-tauri/src/commands.rs#export_subtitles`(文件边界)→ `web/src/lib/api.ts#exportSubtitles` → `web/src/components/shell/TitleBar.tsx#onExportSubtitles`。 +- **验证**:`exports_non_empty_srt_with_cue_count` / `exports_vtt_with_header` 验证标准文件内容;`subtitle export menu routes srt and vtt` 与 TitleBar 交互测试验证 SRT/VTT 的扩展名、保存过滤器和 typed API 路由。真实工程文件导出证据另存审计 runtime artifact。 - **前置依赖**:依赖 opentake-domain clip 时间字段与帧/秒换算(Phase 1 就绪);前端入口依赖导出 UI(Phase 6)。无 blocker。 ## 模块5:AI 生成式创作(AIGC) @@ -210,27 +218,30 @@ 对 OpenTake 的含义:四项里两项(图文成片、剪口播)OpenTake 设计稿已有"地基"(agent 工具层=opentake-agent、生成后端=opentake-gen、whisper-rs 转写、SigLIP2 搜索),且 ARCHITECTURE §7/ROADMAP Phase 7/report 04 已明确把 remove_filler_words/tighten_silences 列为"超越上游"的内置高阶工具——故判 partial。另两项(数字人、音色克隆)上游零代码、OpenTake 设计稿也完全未提,且本质都依赖外部生成模型,判 missing。所有四项的生成执行最终都要落到自建后端/BYOK(opentake-gen + opentake-gen-proxy)直连 fal/Replicate/ElevenLabs/HeyGen 等厂商,本地 Rust/FFmpeg/wgpu 只能承担编排、转写、静音检测、素材物化与落轨,不可能本地跑生成大模型。 -### 图文成片(文案指令 → 自动匹配素材 + 配音 + 转场 → 成片) — `partial` · 难度 high · 优先级 p1 +### 图文成片(文案指令 → 自动匹配素材 + 配音 + 转场 → 成片) — `has` · 难度 high · 优先级 p1 +- **2026-08-01 完成状态**:OpenTake 已提供 capability-gated `script_to_video` 与可视化“智能成片”面板。文案、确定的画面/旁白素材 ID、帧时长和转场先作为带 SHA-256/规划器版本来源的 `ScriptAssemblyPlan` 持久化到 `project.json`;未审阅或编辑后的计划不得直接应用。应用在一个 ops 事务中建立画面/旁白轨道、精确帧起点和相邻转场,失败/取消无部分写入,整体一步撤销。真媒体三分镜 fixture 已验证 3+3 画面/旁白对齐、两处转场、保存/重开和 18 帧 H.264/AAC 导出。下方历史判定/实现方案作为实现前背景保留。 - **判定依据**:上游无一键成片功能,但构件齐全且 OpenTake 已规划对应层:① 编排能力=AgentPanelView.swift:13-35 的 starterPrompts("Generate an AI video"/"Generate B-roll: Inspect the current edit, identify sections...generate suitable B-roll, place it"/"Create a voiceover: Draft concise narration...add to audio track"),全靠 LLM agent 调原子工具涌现,AgentInstructions.swift:75-78 规定"Default flow: images first, then video"。② 配音=generate_audio 的 TTS 分支(AudioGenerationParams,云端;OpenTake report03 已逆向出 {kind:audio,prompt,voice,styleInstructions,...} 契约)。③ 素材匹配=端侧 SigLIP2 语义搜索(Search/,OpenTake 用 candle/ort 复刻)+ import_media 接外部 stock(ToolDefinitions.swift:431)。④ 转场=上游 FAQ 自承认"尚无",仅 fade 单边 dissolve(XMLExporter.swift:296)。OpenTake 设计稿:opentake-agent 工具层 + opentake-gen 生成 + 转写/搜索都已规划,但无"成片"高阶编排工具,也无真正的 transition 渲染。 - **落点(crate/层)**:opentake-agent(高阶编排工具 + 系统提示词)为主;依赖 opentake-gen(TTS/视频生成)、opentake-media(whisper-rs 转写 + SigLIP2 搜索)、opentake-render(转场合成)、opentake-ops(批量落轨)。 - **实现方案**:分两段。【编排即可达 80%】沿用上游"单一能力层、agent 编排"思路:在 opentake-agent 内置一个高阶工具/提示词模板 script_to_video(把"解析文案分镜→search_media 匹配本地素材/import_media 拉 stock→缺口走 generate_image/video→generate_audio TTS 配音→add_clips/add_texts 按节拍落轨"在 Rust 内编排成一次多步事务,易错的帧算术在 Rust 完成,只把创意决策留给 LLM)。生成执行走 BYOK/自建代理直连 fal/Replicate(opentake-gen 的 GenerationParams 联合类型已在 report03 设计)。【真转场需补 wgpu】上游本就只有 fade;若要做剪映式 dissolve/wipe/zoom 转场,必须在 opentake-render 的 wgpu 合成器里实现 clip-to-clip 双源混合(两段重叠帧按曲线 alpha/位移/缩放插值)——FFmpeg filter_complex 的 xfade 可作导出兜底但与预览难像素一致,推荐 wgpu 自渲染。配音建议优先"先转写脚本→TTS→落轨",时长按 audioTTSDurationSeconds=10s 默认(Constants,MODULE-PORT-MAP:1032)。 - **前置依赖**:强依赖 opentake-gen 生成后端(BYOK/代理)先可用;转场依赖 wgpu 帧合成器(项目最大 blocker)落地;素材匹配依赖 SigLIP2 语义搜索与 whisper-rs 转写就绪;落轨依赖 ops 命令层。 ### 智能剪口播(剔除无意义停顿与语气词 / filler & silence removal) — `partial` · 难度 medium · 优先级 p1 -- **判定依据**:上游无专用本地算法,且 OpenTake 已明确规划为"超越上游"的增强点。上游两条路径:① CaptionTab.swift:226-227 "Remove filler words (um, uh, er, like, you know)" 实为 LLM captionTask,只改字幕文本、"keeping each caption's timing unchanged",并不剪音视频;② 真正剪辑靠通用 agent:读词级 get_transcript(ToolDefinitions.swift:79)+ ripple_delete_ranges(ToolDefinitions.swift:282,"the fast path for filler-word/dead-air removal")组合,AgentInstructions.swift:65-69 反复警告"段视图有损、要把词级 transcript 当散文读"(说明这是踩坑痛点)。OpenTake 侧:ARCHITECTURE §7 与 ROADMAP Phase7 与 _analysis/04:227 均明确"内置 remove_filler_words / tighten_silences 高阶工具(参数化阈值),把读词→定位→ripple 在 Rust 内一次完成";转写已规划 whisper-rs(词/段时间戳)。故"语气词删除"设计层已覆盖且更强,但"无意义停顿检测"还缺一个真正的本地静音检测算法。 +- **判定依据**:上游无专用本地算法,且 OpenTake 已明确规划为"超越上游"的增强点。上游两条路径:① CaptionTab.swift:226-227 "Remove filler words (um, uh, er, like, you know)" 实为 LLM captionTask,只改字幕文本、保持原时码,并不剪音视频;② 真正剪辑靠通用 agent 组合词级 transcript 与 ripple。OpenTake 当前已把两条生产分析路径接通:`remove_filler_words` 从真实媒体桥读取词级项目帧,支持可配置单词/多词词典并返回逐条审阅范围;`tighten_silences` 从解码 PCM 做参数化 RMS 静音检测。二者均只预览,接受范围再由一个 `ripple_delete_ranges` 命令原子落地并一步撤销。无媒体桥时 filler 工具从 MCP/Chat 发现面消失。状态仍为 `partial`,因为审计要求的用户可见逐条接受/拒绝以及真实媒体保存/重开/导出实机证据尚未完成。 - **落点(crate/层)**:opentake-agent(remove_filler_words/tighten_silences 工具,复用 ripple_delete_ranges 内核)+ opentake-media(whisper-rs 词级转写 + 新增基于 PCM 的 VAD/静音检测)+ opentake-ops(RippleEngine 已是纯函数,可直接承接区间删除)。 - **实现方案**:全本地、跨平台,不需外部模型。【语气词】用 whisper-rs 拿词级时间戳,语气词识别两选一:轻量=内置多语停用词/语气词词典(um/uh/er/like/嗯/呃/那个…)直接命中;增强=可选调 LLM 给"哪些词是 filler"判定(只判定不算帧)。命中后把 [start,end] 词级区间喂给已移植的 RippleEngine::ripple_delete_ranges(MODULE-PORT-MAP:100 行该工具规格),linked A/V 同删、sync-locked 同移、放不下整体拒绝——帧算术全在 Rust,杜绝 LLM 帧错。【停顿/dead-air】用 Symphonia 解 PCM(波形管线已规划)做能量阈值 VAD:滑窗 RMS < 阈值且时长 > 最小静音时长(参数化,如 0.3s)即判为停顿,生成删除区间;同样走 ripple_delete。可选保留"留白"参数(每段静音保留 N ms 呼吸感)。导出为单步可撤销事务。 -- **前置依赖**:依赖 whisper-rs 转写(Phase8)就绪;停顿检测依赖 Symphonia PCM 波形管线(Phase2);区间删除依赖 RippleEngine(Phase1,已是纯函数);最好在 agent 工具层(Phase7)统一暴露。 +- **当前验证(2026-07-29)**:固定 30 秒、30 fps 的 linked video/audio 夹具中,词 `um` 的 `[6,12)` 帧删除后两轨均精确变为 `[(0,6),(6,894)]`,未接受的 `you know` 保留,一步 undo 恢复完整时间线。该回归同时发现并修复了 `clear_region` 在 linked split 后可能选中另一轨 right-half、留下重复中段的问题。保存/重开/导出和真实媒体 UI 流程仍待实机门禁。 +- **前置依赖**:代码依赖已接通:媒体桥词级转写、`CoreHandle` PCM 提取/静音检测和 `RippleEngine`;发布完成仍依赖上述实机门禁。 -### 虚拟数字人出镜(digital avatar / 数字人口播) — `missing` · 难度 high · 优先级 p3 +### 虚拟数字人出镜(digital avatar / 数字人口播) — `has` · 难度 high · 优先级 p3 +- **2026-08-01 完成状态**:OpenTake 已接入固定、可审计的 [fal `fal-ai/sync-lipsync/v3/image-to-video`](https://fal.ai/models/fal-ai/sync-lipsync/v3/image-to-video/api) 生产桥和“智能包裹 → 数字人”界面。仅接受项目内已探测的人像与音频素材;用户必须分别记录同意 ID 和确认外部成本。请求输入 SHA-256、请求哈希、provider/model/request id 写入生成来源,下载结果需通过视频+音频流与驱动音频一帧以内时长一致性检查,随后才在单个持久化事务中注册媒体并落轨。[fal 队列取消端点](https://fal.ai/docs/documentation/model-apis/inference/queue)已接入,取消/失败不产生半导入;一步撤销、保存重开及真实 H.264/AAC 导出由 fixture 集成测试覆盖。付费账号真实请求留在 Beta 实机清单,自动化不消费用户额度。 - **判定依据**:上游完全没有。全仓库搜 avatar/digital human/lip-sync/talking head,只命中 Account/IdentityViews.swift 的 UserAvatar(账户头像 UI),零生成相关代码。模型目录的 Kind 枚举只有 {video,image,audio,upscale}(ModelCatalog.swift:126),responseShape 只有 {video,images,audio,upscaledImage},根本没有 avatar/lip-sync 类目;AudioCaps.category 只有 tts/music/sfx。OpenTake 的 ARCHITECTURE/ROADMAP/MODULE-PORT-MAP 三份设计稿也均未提及数字人。 - **落点(crate/层)**:opentake-gen(新增 provider adapter + 一个新生成 kind=avatar/talking-head,落入 BackendGenerationParams 联合类型与 models catalog)+ services/opentake-gen-proxy(对接外部数字人 API);落轨复用 opentake-ops add_clips。 - **实现方案**:本地无法实现(数字人=驱动型生成大模型,需 GPU 集群),必须调外部模型 API。沿用 OpenTake 既定"统一 job 抽象 + provider adapter"架构(report03 §4),新增一类生成:输入=一张人像图(或预置形象)+ 一段脚本文本或一条音频(可串联前面的 TTS/音色克隆产物),provider 调 HeyGen / D-ID / fal 上的 talking-head/lip-sync 模型(如 sadtalker/hallo/latentsync 类),后端只吃 URL(人像/音频先预签名上传),返回 resultUrls→下载落库→add_clips 落到视频轨。catalog 用 uiCapabilities 描述"需人像+音频/文本、时长上限、分辨率"。BYOK 模式下 Rust core 直连厂商。难度主要在外部依赖与成本,不在本地工程。 - **前置依赖**:依赖 opentake-gen 生成后端骨架(job 状态机 + 上传预签名 + catalog 下发)先落地;通常需先有 TTS 或音色克隆产出音频作为驱动输入;无外部数字人 provider 则无法交付。 -### 音色克隆(voice cloning / 自定义音色) — `missing` · 难度 high · 优先级 p2 +### 音色克隆(voice cloning / 自定义音色) — `has` · 难度 high · 优先级 p2 +- **2026-08-01 完成状态**:OpenTake 已接入 [ElevenLabs Instant Voice Cloning](https://elevenlabs.io/docs/api-reference/voices/ivc/create) 注册、`eleven_multilingual_v2` TTS 和永久撤销生产桥,并提供“智能包裹 → 音色克隆”注册/生成/试听/撤销界面。参考音频 SHA-256、同意 ID、请求哈希、provider voice id 和撤销状态持久化到工程;注册后的生成音频经有界流式下载和媒体探测,在单个事务中导入落轨并可一步撤销。注册落盘/取消失败会清理远端 voice;voice 记录及撤销状态不进入普通工程 undo/redo,远端已不存在也按幂等撤销完成本地落盘,已撤销 voice 在调用 provider 前即拒绝。fixture 集成测试覆盖同意/成本、取消、provider 失败零导入、保存重开、撤销、撤销后禁用与普通 undo 无法复活。付费账号真实请求留在 Beta 实机清单。 - **判定依据**:上游完全没有。搜 voice clone/voiceprint/custom voice/speaker embedding 零命中。TTS 的 voice 只能从后端 catalog 下发的【预设字符串枚举】里选:AudioCaps.voices 是 [String]?(ModelCatalog.swift:223),GenerationView.swift:852-856 的 voicePicker 仅 ForEach 预设 voices 列按钮,无任何"上传参考音频/录制样本/创建我的音色"入口;AudioGenerationParams.voice 也只是 String?(预设名),无 referenceAudio 字段用于克隆(AudioModelConfig.swift:3-9)。agent 侧同理,只暴露 voicesSample/voiceCount(ToolExecutor+Generate.swift:445-446)。OpenTake 三份设计稿亦未提音色克隆。 - **落点(crate/层)**:opentake-gen(扩展 AudioGenerationParams 增加 referenceAudioURL/voiceId 字段 + provider adapter)+ services/opentake-gen-proxy(对接 ElevenLabs voice-clone / fal / MiniMax 等)+ 音色库管理(可存于 .opentake 工程或账户级)+ keyring 存厂商 key。 - **实现方案**:本地无法稳定实现高质量克隆(需说话人编码+声码器大模型),走外部 API 最务实。两步:① 创建音色:用户提供参考音频样本→预签名上传→调 ElevenLabs Instant Voice Cloning / MiniMax voice clone 等→拿回一个 voiceId,存入音色库。② 使用:把该 voiceId 当作 TTS 的 voice 传入现有 generate_audio 流水线即可复用全部落轨逻辑。需扩展 OpenTake 既有的 AudioGenerationParams 联合类型(report03 已设计该 enum)加 referenceAudioURL/clonedVoiceId 字段,catalog 用 caps 标注"该模型支持克隆"。合规上需加"声音授权确认"。若坚持本地路线,可评估 OpenVoice/XTTS(candle/ort 跑)做轻量零样本音色迁移,但质量与多语稳定性远逊云端,定位为可选实验功能。 - **前置依赖**:依赖 opentake-gen 生成后端 + 上传预签名 + keyring 密钥存储先就绪;依赖支持克隆的外部 provider;与 TTS(图文成片配音)、数字人驱动音频可串联复用。 - diff --git a/docs/architecture/EDITING-ENGINE-PLAN.md b/docs/architecture/EDITING-ENGINE-PLAN.md index ab2251f9..bb5805aa 100644 --- a/docs/architecture/EDITING-ENGINE-PLAN.md +++ b/docs/architecture/EDITING-ENGINE-PLAN.md @@ -1,5 +1,9 @@ # 剪辑引擎实现现状与规划(EDITING-ENGINE-PLAN) +> **2026-08-03 状态注记:**本文是剪辑引擎的测绘文档。剪辑「算法核」(`ops/*` + `engines/*`) +> 已 1:1 写通并随 Beta 1 交付;本文所述「前端接线层缺口」已收口(时间线手势单事务、原子分割 / +> Transform、撤销/重做)。Beta 2 范围与发布门槛见 `docs/releases/1.0.0-beta.2.md`。 + > 目标:把剪辑(片段增删改移 + 链接音频 + 吸附 + 右键/快捷键)按上游 `palmier-pro` **1:1 写通**。 > 本文是「现状测绘 + 1:1 差距 + 收口计划」,与 [PORT-1TO1-GAP.md](PORT-1TO1-GAP.md)、[ROADMAP.md](ROADMAP.md) 配套。 > 渲染/播放管线另见 issue [#142](https://github.com/appergb/OpenTake/issues/142) 与 `memory/opentake-render-pipeline-rewrite`。 @@ -31,6 +35,7 @@ - 加入「带音频的视频」时,会在视频轨下方生成一条**独立的链接 audio clip**(共享 `linkGroupId`),trim/move 联动。 - 门控条件(前端 `editActions.ts` `addLinkedAudio = item.type==="video" && item.hasAudio` → 后端 `place.rs` `should_link` 4 条件含 `spec.has_audio`)与上游 `EditorViewModel.swift:341` `shouldLink = addLinkedAudio && targetIsVideo && asset.type==.video && asset.hasAudio` **逐字一致**。 - **无音频视频** → `has_audio=false` → 不建音轨(`probe.rs` 的 `channels==0` 守卫;`channels` 缺失保守保留,因 ffprobe 对真实音频必报 channels,且纯音频文件不能误杀)。 +- **验收结果(2026-08-01)**:Agent 的 `add_clips` / `insert_clips` owning tests 均证明 `has_audio=false` 时只保留视频 Clip 且 `link_group_id=None`;零声道 probe 测试也通过。重建的 release `.app` 在独立工程中导入一个确定性的 5 秒 H.264 无音轨文件,素材清单持久化 `hasAudio:false`,双击后时间线只出现 V1(无 A1/A2),播放推进正常;导出为 1280×720/150 帧 H.264 后 FFprobe 仍只报告视频流。详见 `docs/audit/2026-07-14/runtime-artifacts/automated/linked-audio-real-device-2026-08-01.md`。 ### ⚠️ 待用户定夺(1:1 偏离决策) 用户反馈「带音频的视频应把音频显示在视频片段内,而非独立 A1/A2 轨」。这与上游设计(独立链接音轨)**相悖**。两条路: diff --git a/docs/architecture/FULL_PROJECT_SCAN_REPORT.md b/docs/architecture/FULL_PROJECT_SCAN_REPORT.md index 9a553b12..e2f97542 100644 --- a/docs/architecture/FULL_PROJECT_SCAN_REPORT.md +++ b/docs/architecture/FULL_PROJECT_SCAN_REPORT.md @@ -94,10 +94,10 @@ - Shell 好 (short_id、encode_timeline、context_signal、dispatch 管道、描述 verbatim port)。 **问题/风险**: -- 大量 stub (dispatch.rs:177-189 + 注释): InspectMedia/GetTranscript/InspectTimeline/SearchMedia/Generate*/Upscale/Import/AddCaptions/Motion。 -- CoreHandle 窄,未暴露 media/render。 -- 细节: rippleDeleteRanges 不完整、batch folders 未实现、canGenerate 硬编码 false、undo scoping 弱。 -- 严重性: **高** (AI 协作核心缺失 → transcript-driven 编辑等 workflow 不可用)。 +- 最多 39 个基础工具均已脱离 `not yet implemented` 分支,并按主机媒体桥能力 fail-closed 过滤;`inspect_media` 与 `remove_filler_words` 已通过 Tauri `MediaBridge` 接入抽帧/本地转写与词级项目帧分析。 +- 生成/超分四个工具已接共享生产 GenerationBridge;Motion 两工具已接 Motion Canvas 3.17.2 + 本地 fallback 生产桥,并仅在 Chromium/FFmpeg 能力可用时动态发布。Lottie 源检查与时间线合成均已接共享 Velato/Vello 路径。 +- 生成作业已具备成本授权、耐久占位/日志、进度、取消、部分成功、失败码、重试与重启恢复;结果下载受协议/地址/大小/重定向约束并在探测后原子导入。 +- 严重性: **中**(生成、Motion 与 Lottie 检查主竖切已闭合;高级 AI workflow 与付费真实账号冒烟仍属发布验证项)。 **测试**: mcp_http.rs (传输) 存在;全工具执行弱。 @@ -122,7 +122,7 @@ **问题/风险**: - Import: thumbnails 永远 None (media.rs:79,“placeholder”)、无进度/反馈、扩展静默丢弃、folder 浏览不全。 - Export: H.264 spine 好,但 H265/ProRes 未接、无进度/取消。 -- Gen: 占位部分,但 agent 路径 stub。 +- Gen: image/video/audio/upscale 已从 MCP/Chat 走生产桥并有无付费网络的 provider 合约与应用集成测试;时间线视频源音频生成已预留渲染路径,但当前内置目录没有 `inputs:["video"]` 的音频模型,因此按能力拒绝。 - Bundle 细微差异 (chat 目录名)。 - 严重性: **中高** (工作流断裂)。 @@ -142,7 +142,7 @@ **Critical**: 1. 预览不反映真实合成 (DOM 主导 + GPU 合成 infrastructure 已就绪但未接入 Preview.tsx) → [BUGS.md](BUGS.md#d1-预览未接入-gpu-合成高) -2. Agent/MCP 工具大量 stub (inspect/transcript/search/generate/captions/import 等) → [BUGS.md](BUGS.md#d2-agentmcp-工具-1240-为-stub高) +2. Agent/MCP Lottie 生产检查已关闭;剩余 Agent 目标按完成度计划继续验收。 3. Media thumbnails 永远 None、import 无反馈/进度 → [BUGS.md](BUGS.md#d3-media-缩略图始终返回-none中) **High**: @@ -179,4 +179,4 @@ --- *本报告由多子代理并行探索 + 直接工具调用合成,所有路径均为仓库内绝对路径。无任何代码变更。 -修正记录:2026-06-26 经代码验证,修正了报告中的 4 处误报(fade knee 缺失、hidden track hittable、空 timeline no-op、Toolbar 死按钮),将实际确认的问题归档到 [BUGS.md](BUGS.md)。* \ No newline at end of file +修正记录:2026-06-26 经代码验证,修正了报告中的 4 处误报(fade knee 缺失、hidden track hittable、空 timeline no-op、Toolbar 死按钮),将实际确认的问题归档到 [BUGS.md](BUGS.md)。* diff --git a/docs/architecture/HANDOFF-2026-07.md b/docs/architecture/HANDOFF-2026-07.md index b61dd5d0..14bf8a52 100644 --- a/docs/architecture/HANDOFF-2026-07.md +++ b/docs/architecture/HANDOFF-2026-07.md @@ -1,5 +1,12 @@ # 交接 · 未完成工作规划(2026-07-04) +> **2026-08-03 状态裁决:**本文件是历史交接记录,不再是当前状态真值。Beta 1 +> 已发布(`v1.0.0-beta.1`,起点 `c73c192`),Beta 2 收尾进行中;当前范围与发布 +> 门槛见 `docs/releases/1.0.0-beta.2.md`,逐项执行证据见 +> `docs/audit/2026-08-02/beta-functional-verification.md`,代码现状以 `agent/advanced-ai-workflows` +> 分支工作树为准(前端 129 文件 / 1072 测试全绿)。本文档仅保留 2026-07-04 快照与 +> Wave 1A 增补作为历史/设计参考,不再作为操作手册。 + ## 2026-07-10/11 Wave 1A reviewed addendum This dated addendum supersedes the **current-status** playback and real-device @@ -98,9 +105,9 @@ default-off Rust renderer or to perform the then-missing first device check. | Issue | 已做 | 剩余 | |---|---|---| -| #13 进阶能力 | 调色链/绿幕/蒙版着色器(`8813df2`) | 转场(§3.9)、AI 推理(#27)、音频工程(#28) | +| #13 进阶能力 | 调色链/绿幕/蒙版着色器(`8813df2`)、首个交叉溶解转场竖切(§3.9) | 扩展转场库、AI 推理(#27)、音频工程(#28) | | #25 Inspector/MediaPanel tab | 关键帧钻石(#187)、Text MVP(#107)、Captions(#184) | AI-Edit tab、Music tab(§3.6) | -| #29 进阶纯逻辑 | SRT/VTT 纯逻辑(#110)、字幕批量样式纯逻辑(#113) | 两者接 UI/命令;曲线变速(§3.10)、多机位、复合片段 | +| #29 进阶纯逻辑 | SRT/VTT 已接 UI/Tauri 命令(#110)、字幕批量样式纯逻辑(#113) | 字幕批量样式接 UI/命令;曲线变速(§3.10)、多机位、复合片段 | | #37 全局素材库 | 后端+前端全量(#104/#106/#115) | 库→时间线拖拽、媒体面板星标迁 `library_favorite` | | #48 片段编辑收尾 | 右键菜单(Copy/Swap 已接)、nudge/间隙/ripple(#186) | **Save as Media 仅前端菜单占位,后端零实现**(§3.5) | | #131 编解码/无缝切换 | 播放路径已被流式引擎取代 | ffmpeg 未随包分发(§3.12) | @@ -138,19 +145,19 @@ default-off Rust renderer or to perform the then-missing first device check. - **现状**:`web/src/components/agent/AgentPanel.tsx` 仅渲染占位文案;`crates/opentake-agent/src/` 只有 mcp/plugin/prompt/signal/tools,**无会话循环**。 - **怎么写**(对照上游 `Sources/PalmierPro/` 的 Agent 聊天 UI 结构): - 1. 后端 `crates/opentake-agent/src/chat/`:会话结构(消息历史 + streaming)+ LLM 调用走 `opentake-gen` 的 provider/keys 基建(BYOK,钥匙串已有);工具调用直接复用 `tools/` 现有 44 工具的 dispatch(与 MCP 同一入口,保证"唯一编辑入口"不破)。 + 1. 后端 `crates/opentake-agent/src/chat/`:会话结构(消息历史 + streaming)+ LLM 调用走 `opentake-gen` 的 provider/keys 基建(BYOK,钥匙串已有);工具调用直接复用统一 dispatch(基础集合最多 39 个、按主机能力过滤,另有 4 个动态生成工具;45 个兼容线名保留在 `KNOWN`)。 2. `src-tauri` 命令:`chat_send`/`chat_history`/`chat_cancel` + `chat_delta` 事件流(参照 `transcribe` 的进度事件模式)。 3. 前端:AgentPanel 消息列表 + streaming 渲染 + 工具调用卡片;Context Signal(`signal/`)注入系统提示已有,接上即可。 - **验收**:面板内发"把这段静音剪掉"能走 tighten_silences 工具链并落 EditCommand;无 key 时有引导文案。 -### 3.4 【P1】生成式 UI + `generate_*`/`upscale` 去 stub(#30 切片) +### 3.4 【P1,代码竖切已完成】生成式 UI + `generate_*`/`upscale` 去 stub(#30 切片) -- **现状**:`opentake-gen` client/job/provider/keys **全部建成、零调用**;MCP `GenerateVideo/Image/Audio | UpscaleMedia` 返回 "not yet implemented"(`dispatch.rs:158-166`);`encode_timeline(..., false)` 硬编 canGenerate=false;web 无任何生成 UI(上游 `GenerationView.swift` 1884 行)。 +- **现状(2026-07-29)**:`GenerateVideo/Image/Audio | UpscaleMedia` 已通过 MCP/Chat 共用的 Tauri GenerationBridge 接入 fal/Replicate/OpenAI/ElevenLabs;先持久化占位和 generation log,再异步上传/提交/轮询/下载/探测/导入。`canGenerate` 由可用凭据派生,四工具仅在能力可用时动态发布。MediaPanel 卡片显示进度、取消、固定错误码与需重新确认成本的重试。 - **怎么写**: 1. `src-tauri` 命令层:`generate_start(params)->job_id` / `generate_status` / `generate_cancel`,内部 `opentake-gen::client` 异步 job 轮询,产物落项目 `media/` 并走 `import_media` 现有通路(保证出现在素材面板 + 可撤销)。 2. MCP dispatch 同一命令复用,去掉 4 个 stub;canGenerate 改为"有 BYOK key 即 true"(`keys.rs` 可查)。 3. 前端:对照上游 GenerationView 做 MediaPanel「AI 生成」分区(与 #91 的左侧分区合流):模型选择(`catalog/` 已有 list_models)、提示词、参考图、进度卡片。 -- **验收**:配 fal.ai key 后文生图落库并可拖上时间线;无 key 显示引导;取消可用。 +- **验收状态**:无付费网络的配置 provider 生产路径已覆盖 image/video/audio/upscale、N 图顺序、部分/全部失败、取消、恢复、鉴权/限流、重试和精确 2x;完整工作区测试通过。真实账号付费请求保留到 Beta 实机验证清单,未在自动化中消费用户额度。 ### 3.5 【P1】Save as Media(#48 尾巴) @@ -170,14 +177,15 @@ default-off Rust renderer or to perform the then-missing first device check. ### 3.8 【P1·用户加单】HDR v1 / 代理媒体 / 账号脚手架 -- **HDR v1**:`probe.rs:MediaProbe` 加 `color_primaries/transfer/matrix`(ffprobe 已能给);导出 `encode/preset.rs:80` 从硬编 BT.709 改透传;预览 wgpu 加 PQ/HLG→SDR tonemap pass(shader 链已有挂点)。 -- **代理媒体**:`opentake-media` 加 `proxy.rs`(ffmpeg 半分辨率 H.264 后台转码,进度事件);`MediaItemDto` 加 `proxyPath`;流式引擎 resolver 优先解代理、导出永远用原件;设置页加开关。 -- **账号脚手架**:可配置后端 URL 的登录面板(设置页新 pane),仅 token 存钥匙串 + 状态显示,**如实标注无官方后端**;不阻塞任何本地功能。 -- **验收**:HDR 素材导出色彩元数据不丢;4K 素材开代理后时间线流畅;账号面板断网不影响编辑。 +- **功能完成状态(2026-08-01)**:三个独立 child 均已通过,并由 `crates/opentake-render/tests/composite_acceptance.rs#hdr_proxy_account_children_close_one_composite_acceptance` 关闭同一个父验收。 +- **HDR v1 PASS**:`MediaColorMetadata` 持久化 primaries/transfer/matrix/range;PQ/HLG 在单帧、流式预览和导出共用路径进入 BT.709 SDR。运行时按实际 FFmpeg filter 能力选择 `scale_vt` 或 `zscale`,已修复 bundled sidecar 导出全黑。当前是明确的 SDR delivery policy,不声称 HDR passthrough。 +- **代理媒体 PASS**:项目内 `media/proxies/.mp4` 以 H.264/AAC 原子生成并记录原件 SHA-256;设置开关驱动播放 resolver,导出只读原件。创建、目录祖先、授权、移除和重连均使用 exact-leaf/no-follow 约束,拒绝符号链接/Windows reparse;项目切换会取消在途转码,移除/重连在旧代理文件清理完成前持续持有项目身份租约。打包 GUI 已完成开关、播放、移除、重建、保存重开与原件导出验证。 +- **账号脚手架 PASS**:设置页如实标注无官方后端;远端只接受 HTTPS,本机回环 HTTP 仅作开发例外;token 只进钥匙串。失败登录和断网时,播放、编辑、保存与导出不被账号状态门控。 +- **证据与限制**:[`hdr-proxy-account-real-device-2026-08-01.md`](../audit/2026-07-14/runtime-artifacts/automated/hdr-proxy-account-real-device-2026-08-01.md)。最新本地 `.app`/DMG 仅为 ad-hoc 签名且未 notarize,不能作为 Developer ID Beta 发布证据;完成度账本仍需在不覆盖用户改动的前提下重建文件清单。 ### 3.9 【P2】转场(#13 切片,对标剪映) -- **怎么写**:`opentake-domain` 加 `Transition { kind, duration_frames }` 挂在相邻 clip 对;`opentake-ops` 加 `SetTransition` 命令(校验相邻/时长夹取);`opentake-render` 合成器对重叠区做双纹理混合 pass(cross-dissolve 起步,shader 框架已有调色链可挂);前端时间线 clip 接缝处画转场标记 + Inspector 选型。**先 cross-dissolve 一种打通全链,再扩库。** +- **完成状态(2026-07-31)**:cross-dissolve 首个竖切已打通。`opentake-domain::Transition` 持久化双方 clip ID、类型和时长;`SetTransition` 校验同轨、视觉源、直接相邻和最大 handle,过长请求显式失败。素材面板转场页支持添加/调时长/删除,操作进入统一 undo/redo,时间线接缝显示标记。RenderPlan 在转场区保持出片完整底层、以进度 alpha-over 入片,消除了旧双透明度造成的中点变暗;拥有测试 `crates/opentake-render/tests/transitions.rs#adjacent_clip_transition_is_editable_undoable_and_matches_preview_export` 覆盖保存重开、拒绝/恢复和四个关键帧的像素/预览导出一致性。后续工作仅是扩展 wipe/slide/3D 等转场库,不再属于首个竖切缺口。 ### 3.10 【P2】曲线变速(#29 切片) @@ -185,20 +193,20 @@ default-off Rust renderer or to perform the then-missing first device check. ### 3.11 【P2】流式音频分块解码(#160 剩余) -- **现状**:播放前一次性 `extract_pcm` 全时间线预混(长工程首帧慢、改动即整段重混)。 -- **怎么写**:按 N 秒窗口分块解码 + 环形缓冲后台填充(cpal 回调只读环形缓冲,不破 lock-free);seek 丢弃未播块。参照 `playback/audio.rs` 现有 `AtomicU64` 主时钟结构。 +- **完成状态(2026-08-01) PASS**:播放以 2 秒窗口解码、4 窗口有界队列后台填充;cpal 回调只做非阻塞读取,underrun 输出静音且音频主时钟继续前进。seek 递增 generation、取消在途解码并丢弃旧块;pause 保留当前 generation 的下一块,resume 从同一音频时钟继续。停止会取消并回收解码、音频和协调线程。 +- **导出与另存 PASS**:视频导出逐窗口混音并把 PCM 直接追加到 encoder 私有文件 spool,完成时再 mux,不保留全时间线 `Vec`;音频片段另存 WAV 也逐窗口写文件。60 秒打包 GUI 时间线完成跨窗口播放、暂停/继续、首尾跳转后继续、完整 H.264/AAC 导出和 8 秒 WAV 另存。自动化覆盖长时间线恒定峰值分配、短参考一致性、取消、underrun、暂停不吞块及增量 spool。证据见 [`bounded-audio-streaming-real-device-2026-08-01.md`](../audit/2026-07-14/runtime-artifacts/automated/bounded-audio-streaming-real-device-2026-08-01.md)。 ### 3.12 【P2】收尾杂项 | 项 | 怎么写 | |---|---| -| Lottie 接线(#34/#65) | `opentake-motion` 实现已在但 src-tauri 未依赖;评估 wgpu 直渲 vs 预烘焙 PNG 序列(后者先通:motion→PNG 序列→图片 clip) | +| Lottie 接线(#34/#65) | 已完成:Velato/Vello 直渲共用于 preview/playback/export/Agent 源检查和时间线检查;无效文档及无 GPU 时 fail closed。 | | ffmpeg 随包(#131) | tauri sidecar 打包 ffmpeg/ffprobe,`OPENTAKE_FFMPEG` 已支持自定义路径,只差 bundle 配置 + 许可证说明(GPL 兼容) | | solo(#147) | `Clip.is_soloed` + 混音/合成时非 solo 轨静音/隐藏;前端轨头加 S 按钮 | | 布局常量(#148) | 对照上游 rulerHeight/dropZoneHeight/trackHeight 改 `theme.ts`,纯数值对齐 | | CSP 加固(#161) | null→非 null 白屏高风险,**必须真机逐项验证**,独立小 PR | | Storage/Models 设置页 | `SettingsView.tsx` 加两 pane:模型缓存管理(whisper/SigLIP 已下载模型列表+删除)、存储占用(项目/缓存目录大小) | -| MCP 剩余 stub | `InspectMedia`(接 MediaBridge 已有通路)、`AddMotionGraphic/EditMotionGraphic`(依赖 Lottie 接线) | +| MCP 动态能力 | `InspectMedia` 已接 MediaBridge,包含 Lottie 均匀抽帧;生成/超分按凭据动态发布;Motion add/edit 于 2026-08-01 接生产桥并按 Chromium/FFmpeg 能力动态发布 | | 技术债 | `fcpxml.rs` 1489 行拆分;`export_fcpxml` 名实不符(产物 XMEML)可改名 `export_xml`;`library.rs:322` remove 静默吞错补 `tracing::warn!` | --- diff --git a/docs/architecture/INDEX.md b/docs/architecture/INDEX.md index 76a1ecf5..b729aa49 100644 --- a/docs/architecture/INDEX.md +++ b/docs/architecture/INDEX.md @@ -17,7 +17,8 @@ | 文档 | 内容 | |---|---| -| [HANDOFF-2026-07.md](HANDOFF-2026-07.md) | ★ **当前权威 TODO / 交接文档**:issue 盘点 + 未完成清单 + 每项怎么写(2026-07-04 逐项核对) | +| [../releases/1.0.0-beta.2.md](../releases/1.0.0-beta.2.md) | **当前 Beta 2 范围、11 阶段验收顺序与发布门槛** | +| [HANDOFF-2026-07.md](HANDOFF-2026-07.md) | 2026-07-04 交接时点快照;保留作历史参考,不是当前 TODO 真值 | | [ROADMAP.md](ROADMAP.md) | 分阶段路线图(Phase 0 脚手架 → Motion Canvas 插件) | | [EDITING-ENGINE-PLAN.md](EDITING-ENGINE-PLAN.md) | 剪辑引擎现况与规划:已移植的 ops 层 + 待收口 gap | | [PORT-1TO1-GAP.md](PORT-1TO1-GAP.md) | 1:1 复刻差距与实现计划(P0/P1/P2 逐项)。⚠️ 历史参考,以更新的 DOS / 模块文档为准 | diff --git a/docs/architecture/MODULE-PORT-MAP.md b/docs/architecture/MODULE-PORT-MAP.md index cc467060..634ba218 100644 --- a/docs/architecture/MODULE-PORT-MAP.md +++ b/docs/architecture/MODULE-PORT-MAP.md @@ -119,8 +119,9 @@ - 工程缩略图生成:遍历时间线首个可用图片/视频片段,图片直接缩放,视频用 AVAssetImageGenerator 在 trimStartFrame 对应时间抽帧,JPEG 编码缓存 - 素材恢复:打开工程时按 MediaManifest 把每个条目解析为 MediaAsset,触发波形/缩略图/元数据生成,统计 restored/missing - 最近工程注册表(ProjectRegistry):记录工程 URL/创建时间/最近打开时间,JSON 持久化到 ~/Documents/Palmier Pro/project-registry.json,支持注册/移除/删除到废纸篓/重命名后改 URL,带加载期间挂起变更队列 -- Home 主屏 UI:侧边栏(登录/新建/打开/设置)、工程卡片网格(缩略图/名称/相对时间/缺失态/删除确认/右键菜单)、示例工程横条、欢迎浮层、版本更新浮层 +- Home 主屏 UI:侧边栏(新建/打开/素材库/设置)、工程卡片网格(缩略图/名称/相对时间/缺失态/安全废纸篓确认/右键菜单)、示例工程横条,以及按 `__APP_VERSION__` 和本地 last-seen 版本驱动、可键盘关闭并持久化的首次欢迎/版本更新浮层 - 示例工程服务(SampleProjectService):从 Convex HTTP 后端拉取示例列表并把单个示例「物化」成本地 .palmier 包(并发下载素材+对话,带进度回调,失败清理) +- OpenTake 已在 `src-tauri/src/samples.rs` 接入同一 resolve 契约:所有远程路径与响应大小先校验,在同缓存卷的临时目录组装并用 `Project::open` 验证后原子发布,任一失败会删除完整暂存目录且保留旧缓存。未配置自定义后端时提供三个可离线打开的内置 `.opentake` 示例;示例缓存路径不会登记为用户最近项目,教程入口只在成功打开后进入编辑器并显示引导状态。 - 工程设置不匹配对话框:导入视频片段时检测其 FPS/分辨率与时间线是否一致,提供「保持当前」或「改为匹配」(后者会按比例重算所有片段帧值) - 应用级工程编排(AppState):新建(NSSavePanel)、打开(NSOpenPanel/URL)、打开示例、Home/Editor 窗口切换、通知点击后定位生成资产 @@ -149,6 +150,7 @@ - 【FPS 变更时的全片段帧重算(applyTimelineSettings 核心算法)】当新 fps≠旧 fps 且两者>0:scale = newFps/oldFps。currentFrame 与 sourcePlayheadFrame 各 ×scale 取整。对每条轨道按 startFrame 升序处理片段,维护 previousEnd:scaledStart=round(start×scale),scaledEnd=round(end×scale);新 startFrame=max(scaledStart, previousEnd ?? scaledStart)(防止重叠);durationFrames=max(1, scaledEnd−newStart);trimStart/trimEnd 各 round(×scale);关键帧 rescaleKeyframes(×scale)(每个 kf.frame=round(frame×scale) 后 upsert);fadeIn/fadeOut 各 round(×scale);再 clampKeyframesToDuration + clampFadesToDuration;更新 previousEnd=新 endFrame。这是有损但确定性的重采样,Rust 必须逐帧复刻取整与 max 防重叠逻辑。 - 【分辨率变更时的自动适配】当 width/height 改变:对每个片段,若其 transform 恰好等于 fitTransform(asset, 旧 canvas)(即此前是自动适配的),则替换为 fitTransform(asset, 新 canvas);手动调过的 transform 保持不变。判定依据是 Transform 的 Equatable 全等。 - 【设置不匹配判定 checkProjectSettings】传入待导入 assets:若没有 video 资产→proceed。若 timeline.settingsConfigured==false(史上第一个片段)→静默自动检测:fps=round(firstVideo.sourceFPS)、width/height=源宽高(缺则保持当前),applyTimelineSettings 后 proceed。若时间线非空→proceed(不打扰)。仅当「时间线为空但已配置过设置」时才比较:fpsMismatch = 源 fps 存在且≠时间线 fps;resMismatch = 源宽存在且≠时间线宽 或 源高存在且≠时间线高;有任一不匹配则返回 .mismatch(用源值,缺失项回退时间线值),由 UI 弹 ProjectSettingsMismatchView,用户选「改为匹配」则 applyTimelineSettings(源值)。这些都登记 undo(action name "Change Project Settings")。 + - OpenTake 完成证据(2026-07-31):`web/src/lib/projectSettings.ts#checkProjectSettings` 是上述四分支的单一判定边界;普通追加与定位拖放均在落片前调用该边界,首个视频通过可撤销的 `setTimelineSettings` 命令自动应用探测到的 `sourceFps`/尺寸,已配置空时间线的不匹配则由全局 `ProjectSettingsMismatchDialog` 显式保留或匹配。精确测试:`web/src/lib/projectSettings.test.ts#first_video_auto_configures_and_only_configured_empty_mismatch_prompts`、`web/src/store/editActions.test.ts#applies first-video settings before placing the first clip`、`web/src/store/editActions.test.ts#waits for configured-empty mismatch choice and preserves either decision`、`web/src/components/shell/ProjectSettingsMismatchDialog.test.tsx#renders an accessible mismatch and resolves match or keep choices`、`crates/opentake-ops/src/command.rs#set_timeline_settings_is_undoable`。 - 【ProjectRegistry 持久化与并发】注册表 JSON 存到 ~/Documents/Palmier Pro/project-registry.json([ProjectEntry] 数组)。所有 URL 比较用 standardizedFileURL。register:已存在则更新 lastOpenedDate,否则追加新 UUID 条目。所有变更走 mutate(apply):若正在异步加载(isLoading)则把闭包加入 pendingMutations 队列延后执行,否则立即 apply 并 save。加载完成 finishLoading 后依次回放挂起变更再保存一次。delete 走后台 actor trashItem 到废纸篓成功后才从注册表移除。工程文件 URL 改名(VideoProject.fileURL setter)会调 updateURL 同步注册表并刷新 lastOpenedDate。 - 【示例工程物化 SampleProjectService.materialize】GET base/v1/samples/resolve?slug=,返回 JSON 含 title/project/manifest/downloads[]/可选 chat[]/generationLog/posterUrl。downloads 每项{id,relativePath,url};chat 每项{name,url}转成 relativePath=chats/name。在 ~/Library/Application Support/PalmierPro/Samples// 下建 .palmier 包:写 project.json、media.json、(有则)generation-log.json,下载 posterUrl→thumbnail.jpg;用 withThrowingTaskGroup 并发下载所有 downloads(到 dest/relativePath,建父目录、覆盖旧文件),每完成一个回调进度 completed/total。任一步失败则删除整个 slug 目录并抛错。safeName:把 / : \ 替换为空格并 trim,空则用 "Sample"。Rust 复刻:HTTP 拉 JSON + 并发下载文件落盘组装目录。 - 【新建工程流程 AppState.createNewProject】NSSavePanel(默认名 "Untitled Project",目录 ~/Documents/Palmier Pro,内容类型 io.palmier.project)→用户确认后 new VideoProject,设 fileURL/fileType,makeWindowControllers+showWindows,addDocument,save(.saveOperation) 落盘后 register 到注册表。打开:VideoProject(contentsOf:ofType:) 解码后同样建窗口并 register。 @@ -516,6 +518,7 @@ - 【GenerationService.generate 主流程 — 必须一比一复刻的顺序与边界】1) count = clamp(numImages,1,4);baseName = name ?? prompt 前30字符;resolvedFolderId 仅当该 folder 存在才保留。2) 目标目录:有 projectURL 则 projectURL/'media' 并创建,否则系统临时目录;占位文件名 'gen-.'。3) 同步创建 count 个占位 MediaAsset(generationStatus=.generating, folderId 设好, 追加进 editor.mediaAssets),记 primaryId=占位[0].id 并立即 return(异步在后台 Task 继续)。4) 异步:若给了 preUploadedURLs 则直接用;否则:取 references 的 url 列表 urlsToUpload;若 trimmedSourceOverride.hasTrim 且非空则用 VideoTrimExtractor 替换 urlsToUpload[0] 并登记待清理;若有 preprocessRef 则对每个 reference 并发执行(TaskGroup)可能产出改写 URL;计算 cacheKeys(只有既无 preprocessRef 且不是被裁剪的第0项时,才用该 MediaAsset 作缓存键);uploadReferences 并发上传(命中 freshRemoteURL 缓存则跳过实际上传,上传成功则写回缓存 TTL=6天)。5) finalGenInput:若给 snapshotRefs 则调用它写回各类 URL,否则把 uploaded 整体塞进 imageURLs;createdAt 为空则置 now;把 finalGenInput 赋给所有占位的 generationInput。6) params=buildParams(uploaded)→runJob。7) 任意环节抛错:所有占位 generationStatus=.failed('Upload failed: …'),调 onFailure。8) defer 清理所有临时文件。 - 【runJob 提交+订阅状态机】生成 runId(UUID前8)。submit(model,params,projectId) 拿 jobId,失败→占位.failed + onFailure。subscribe(jobId) 拿 Combine publisher(nil→.failed('Backend not configured'))。把 publisher 包成 AsyncStream 在主线程消费:job.status == .queued/.running 时 continue;== .succeeded → finalizeSuccess 后 return;== .failed → 占位.failed(job.errorMessage ?? 'Generation failed') + onFailure + return。Rust 复刻:用一个状态轮询/推送通道(WebSocket 或轮询 REST)驱动同一状态机。 - 【finalizeSuccess 下载落地 — N 图分发规则】resultUrls 为空→全部占位 .failed('No URL in response')+onFailure。若 resultUrls.count < 占位数,多出的占位标记失败(只 log 不报错)。逐 i 配对占位[i]↔resultUrls[i]:URL 非法→.failed('No URL for placeholder');否则 downloadAndFinalize 成功才 onComplete?(占位) 并计入 finalized。最后只要有 finalized 就对 finalized.first 发 generationComplete 通知(count=finalized.count);全失败→onFailure。 +- 【OpenTake 复刻状态(2026-07-29)】`finalize_terminal_outputs` 已按占位下标确定性配对全部 URL,覆盖 0/少于/等于/多于 N、下载失败、部分成功与全失败;每个输出持久化独立终态,重复回调由 lease + 耐久状态幂等化。Tauri 下载器只接收受限 data/HTTPS,逐跳拒绝私网/凭据/非 443、限制重定向与 1 GiB 解码后字节数,取消或失败清理 staging;探测真实容器后才导入,签名 URL 和 provider 正文不落盘。 - 【downloadAndFinalize 落地细节】先置 .downloading。URLSession 下载到临时文件;若远端扩展名非空、与占位 url 扩展名不同、且该扩展名是已知 ClipType,则把目标 url 改成新扩展名(纠正真实类型)。删除旧文件→移动临时文件到目标。清 pendingDownloadURL、status=.none、importMediaAsset(skipAppend:true)→appendGenerationLog→finalizeImportedAsset(异步补全元数据如尺寸/帧率/音轨)。失败:记 pendingDownloadURL=远端 URL、status=.failed(message),供 retryDownload 重试。 - 【uploadReferences 并发+缓存+顺序保持】对每个 url 起 TaskGroup 任务:cacheKey.freshRemoteURL 命中则直接产出该 URL;否则按文件扩展名/回退类型推断 contentType(jpg→image/jpeg, png, webp, heic, gif, mp4/m4v→video/mp4, mov→video/quicktime, mp3→audio/mpeg, wav, m4a→audio/mp4;回退:image→image/jpeg, video→video/mp4, audio→audio/mpeg, text→octet-stream, lottie→application/json),上传后写缓存。最终按原始下标排序返回(顺序对后续 frames/refs 切分至关重要)。 - 【上传引用缓存 freshRemoteURL】MediaAsset.cachedRemoteURL + cachedRemoteURLExpiresAt;freshRemoteURL 仅当存在且 expiresAt>now 才返回。TTL=6*24*60*60 秒。缓存只对『字节纯净』的资产生效(未裁剪、未预处理)。Rust 复刻:按资产内容哈希做上传去重缓存更稳。 @@ -975,6 +978,8 @@ - 【是否有回信邮箱 hasReplyEmail】若已登录(account.isSignedIn)则取决于 account.account?.user.email 是否非 nil;若未登录则取决于 trimmedEmail 非空。该值决定『允许回访』勾选框是否可用(disabled 当无邮箱),且无邮箱时即使勾选,提交时 mayContact 也被强制改为 false(submit 里 mayContact: hasReplyEmail ? mayContact : false)。 - 【提交流程 submit()】guard canSubmit;清空 errorText;isSending=true;启动 @MainActor Task,defer 里复位 isSending;计算 attachedScreenshot = (includeScreenshot ? screenshot : nil)?.base64EncodedString()(即用户关掉开关就不带截图);await AccountService.shared.sendFeedback(message: trimmedMessage, email: trimmedEmail 为空则 nil, mayContact: 上述强制规则, screenshotPngBase64: attachedScreenshot, appVersion, osVersion);成功 didSend=true(切到致谢视图),失败把 error.localizedDescription 写入 errorText 显示为红字。 - 【环境信息采集】appVersion = "{CFBundleShortVersionString} ({CFBundleVersion})"(任一缺失用 "?");osVersion = ProcessInfo.processInfo.operatingSystemVersion 拼成 "major.minor.patch"。这两个值随每次反馈一并上报。 + +OpenTake 对应边界已落在 `src-tauri/src/feedback.rs`: `submit_feedback` 只通过类型化 `FeedbackSubmission` 发送,每次都从 Cargo 包版本/可选构建号和当前系统版本生成 `appVersion`/`osVersion`,缺失时显式回退为 `? (?)`/`?.?.?`。载荷采用白名单序列化,消息、邮箱和截图在 `Debug` 输出中脱敏;默认不联网,仅当运营方显式配置 `OPENTAKE_FEEDBACK_ENDPOINT` HTTPS 端点时才提交,客户端禁止重定向并设置 15 秒超时。 - 【致谢文案分支 successDetailText】replyAddr = 登录邮箱 ?? (trimmedEmail 为空则 nil 否则 trimmedEmail);若 replyAddr 存在且 mayContact:『...may reach out at {replyAddr}』;若 replyAddr 存在但不允许:『...won't email you, as requested』;若无邮箱:『...Add an email next time...』。 - 【主窗口截图算法 captureMainWindow(),需用 FFmpeg 体系外的方案复刻】候选窗口选择顺序:NSApp.keyWindow ?? NSApp.mainWindow ?? 第一个 (isVisible 且 title 不以 "Send feedback" 开头) 的窗口;取其 contentView;用 view.bitmapImageRepForCachingDisplay(in: bounds) + view.cacheDisplay(in:to:) 做离屏位图缓存;representation(using:.png) 得 PNG。关键时序:FeedbackWindowController.show() 必须在反馈窗口成为 key 之前先截图,否则会把反馈窗自身拍进去。 - 【截图缩放规则 downscaledIfNeeded】maxDimension = 1920;若宽和高都 ≤1920 直接返回原 PNG;否则 scale = min(1920/width, 1920/height),newW=Int(width*scale)、newH=Int(height*scale);用 CGContext(8 位/分量, premultipliedLast, sRGB 或源色彩空间, interpolationQuality=.high)把 cgImage 重绘到新尺寸,再转回 PNG。任一步失败均回退返回原始 PNG(降级而非报错)。 @@ -1131,6 +1136,8 @@ MCPService 端口 19789 与工具注册属 Agent/MCP 子系统,App 层只做开 **核心类型**: - `AppTheme` (enum) — 全局设计令牌根命名空间(纯静态、不可实例化)。内含嵌套枚举:Background/Border/BorderWidth/Accent/Glass/Status/Text/Opacity/TrackColor/Radius/Spacing/FontSize/FontWeight/Tracking/IconSize/ComponentSize/Window/Caption/GenerationPanel/MediaPanel/Anim,以及顶层 aiGradient/aiGradientDark 渐变与 ShadowStyle 结构和 Shadow 枚举。所有 UI 数值都必须从这里取(项目硬性规定),是整个前端的视觉单一真相源。 + +OpenTake 的对应真相源是 `web/src/lib/theme.ts` + `web/src/styles/tokens.css`:前者供 Canvas/TypeScript 数值路径消费,后者是 DOM 投影。完整表契约测试锁定每项精确值,使用审计测试覆盖六个主界面并拒绝未定义 CSS 令牌。旧组件的语义别名只能通过 `var(...)` 指向规范令牌,不得重复声明颜色或时长字面量。 - `AppTheme.Caption` (enum) — 字幕相关的约束常量(本目录唯一带编辑业务语义的部分):defaultFontSize=48、minFontSize=12、maxFontSize=300、minPosition=0、maxPosition=1、centerSnapValue=0.5、centerSnapThreshold=0.02、defaultCenterY=0.9、defaultCenter=(0.5,0.9)、minDisplayDuration=0.7。被 CaptionTab、ToolExecutor+Captions、EditorViewModel+Captions 消费,决定字幕默认样式、归一化坐标钳制与吸附、最短分句时长。 - `AppTheme.ShadowStyle` (struct) — 阴影参数值类型(color/radius/x/y),配合 Shadow.sm/md/lg 预设与 View.shadow(_:) 扩展使用。 - `CapsuleButtonStyle` (struct) — 实现 SwiftUI ButtonStyle 的胶囊按钮样式。含 Variant(secondary/prominent)与 Size(small/regular)枚举及可选 fill。内部私有 Chrome 视图持有 @State hovered,负责字号/内边距/前景背景色推导与悬停/按下交互态;通过 ButtonStyle 扩展暴露 .capsule 便捷构造器。 @@ -1246,7 +1253,9 @@ MCPService 端口 19789 与工具注册属 Agent/MCP 子系统,App 层只做开 **闭源云**:不触达 Convex/Clerk/任何闭源生成式 AI 云。唯一的网络出口是第三方崩溃监控服务 Sentry(自托管或 sentry.io 均可,DSN 从 Info.plist 注入)。上报内容刻意脱敏:sendDefaultPii=false、enableCaptureFailedRequests=false,且产品文案明确声明"媒体与工程内容绝不收集",仅发送崩溃/错误/面包屑与脱敏后的 id(shortId 取前 8 位)。此为可选诊断遥测,与生成式 AI 业务云无关,且受用户开关控制(默认开启,可在隐私设置关闭后重启生效)。 -**移植策略**:逻辑本身简单可直接移植,但 Sentry Cocoa SDK 必须替换为 Rust 生态对应物。方案:在 Rust core 用官方 `sentry` crate(sentry-rust,支持 native 崩溃/panic 捕获、breadcrumb、scope、performance transaction,概念一一对应)。映射:SentrySDK.start→sentry::init(ClientOptions{ dsn, environment, traces_sample_rate:0.1, release:Some('palmier-pro@+'.into()), send_default_pii:false, .. });breadcrumb→sentry::add_breadcrumb(Breadcrumb{ message, category, level, data });captureMessage/Error→sentry::capture_message / capture_error(或 anyhow/Error 经 sentry-anyhow);setExtra→sentry::configure_scope(|s| s.set_extra(...));logWarning/Error/Fault 的'warning 只进面包屑、error/fatal 才成事件并打 log_category tag' 这套分级语义需在 Rust 侧用同样的分支显式复刻;trace→sentry 的 start_transaction + finish(失败时 set status=internal_error)。isEnabled 三态默认(键缺省=开启)用 Tauri 的设置存储或自管 JSON/SQLite 复刻,键名沿用 'io.palmier.pro.telemetry.enabled' 或改 OpenTake 命名空间。appHangTimeoutInterval(8s 卡顿)、enableUncaughtNSExceptionReporting 这类 macOS 特有项无对应,可丢弃或用 sentry panic handler 近似。前端(React)若也要采集可用 @sentry/browser/@sentry/tauri,但核心崩溃上报建议放 Rust 端。隐私/合规:保留默认脱敏(不发 PII、不收媒体/工程内容)、保留用户可关开关、上报前对 id 做 shortId(前 8 char)截断。整体属诊断基础设施,非编辑算法,移植优先级低,可后置。 +**OpenTake 实现状态**:`src-tauri/src/telemetry.rs` 已接入固定版本的官方 Rust `sentry` SDK,并在 Tauri 启动入口最早阶段持有初始化 guard。只有显式提供有效 DSN 才会创建客户端:运行时按 `OPENTAKE_SENTRY_DSN`、`SENTRY_DSN` 顺序读取,未设置时才回退到构建期 `OPENTAKE_PACKAGED_SENTRY_DSN`;缺失、空白或无效 DSN 均不上报。运行时环境变量优先于打包值,便于受控 Beta 诊断且不会把 DSN 写入日志或 `Debug` 输出。初始化固定关闭默认 PII、失败请求体、结构化日志、指标与自动会话,仅保留 10% trace、panic 与 stacktrace;事件发送前移除 user/request/server/extra、源码上下文、局部变量、寄存器及绝对路径,并对消息、标签、异常、线程和 breadcrumb 中的路径、Bearer token 及 credential-shaped 值再次脱敏。媒体、工程内容和请求正文没有采集入口。无 DSN 是默认且完全离线的状态;启用遥测必须由受控打包配置或进程环境作出显式操作。 + +**移植策略**:逻辑本身简单可直接移植,但 Sentry Cocoa SDK 必须替换为 Rust 生态对应物。OpenTake 已在 Rust core 使用官方 `sentry` crate 完成最小启动与隐私门控;后续若增加 breadcrumb/capture/trace 业务门面,应继续沿用同一模块及发送前脱敏边界。映射:SentrySDK.start→sentry::init(ClientOptions{ dsn, environment, traces_sample_rate:0.1, release, send_default_pii:false, .. });breadcrumb→sentry::add_breadcrumb;captureMessage/Error→sentry::capture_message / capture_error;setExtra→sentry::configure_scope;trace→Sentry transaction。用户可见的持久化启用开关与重启提示仍属于独立设置任务,不得通过把遥测改成默认开启来替代。appHangTimeoutInterval(8s 卡顿)等 Cocoa 专有项不直接移植,panic handler 仅作为近似。整体属诊断基础设施,非编辑算法。 **关键文件**:Sources/PalmierPro/Telemetry/Telemetry.swift、Sources/PalmierPro/Utilities/Log.swift、Sources/PalmierPro/Settings/PrivacyPane.swift、Sources/PalmierPro/App/main.swift @@ -1292,4 +1301,3 @@ MCPService 端口 19789 与工具注册属 Agent/MCP 子系统,App 层只做开 **移植策略**:ToolbarView 是纯 SwiftUI 表现层,需在 React/TS 前端整体重建为一个工具栏组件,不可直接移植。但它本身几乎没有逻辑——真正要忠实复刻的是它调用的 EditorViewModel 编辑算法(split/trim/addText),那些应放到 Rust core 实现,前端按钮只发命令。具体替换方案:(1) 布局:用 flex 行 + 分隔符/弹簧重建;图标用任意图标库(如 lucide)替换 SF Symbols(cursorarrow→鼠标、scissors→剪刀、square.split.2x1→分割、放大镜→zoom)。(2) 悬停高亮 hoverHighlight→CSS :hover + 圆角背景,命中区用 padding/伪元素扩大。(3) 撤销/重做:在 Rust core 维护撤销栈(对应 NSUndoManager 的 bidirectional swap 模式——每个 mutation 记录 before/after Timeline 或 per-clip 快照,撤销时回写并重新注册逆向 swap);前端按钮与 Cmd+Z/Shift+Cmd+Z 都调用 core 的 undo()/redo() 命令(invoke)。(4) 工具模式:toolMode 作为前端/或 core 的 UI 状态枚举(Pointer/Razor),razor 模式下时间线点击发 split 命令。(5) 分割/裁剪/新增文字算法严格按 coreLogic 在 Rust 实现:注意 round() 取整、源帧=时间线帧*speed、fade 在分割时左清淡出右清淡入、关键帧轨切点插边界帧并 rebase、trim 是 overwrite 式(不波纹)。secondsToFrame 用截断(Int(s*fps))而非四舍五入,务必一致。(6) 缩放滑块:前端 input[type=range] 存 ln(scale),区间 [ln(minZoom), ln(40)],回写 scale=Math.exp(v);minZoom 计算(availableWidth/(totalFrames*3),钳制到[0.0001,40])可放前端或 core。(7) 文字渲染独立于媒体合成(syncTextLayers→在 FFmpeg/前端 overlay 层单独绘制文字),新增文字时把文字 clip 放到顶层视频轨、按 currentFrame 起点、默认 3 秒。无 Apple 私有框架阻塞,无 blocker。 **关键文件**:Sources/PalmierPro/Toolbar/ToolbarView.swift、Sources/PalmierPro/Editor/ViewModel/EditorViewModel+ClipMutations.swift、Sources/PalmierPro/Editor/ViewModel/EditorViewModel+Ripple.swift、Sources/PalmierPro/Editor/OverwriteEngine.swift、Sources/PalmierPro/Models/Timeline.swift、Sources/PalmierPro/Utilities/Constants.swift - diff --git a/docs/architecture/PLAYBACK-ENGINE.md b/docs/architecture/PLAYBACK-ENGINE.md index 5f779cbc..d7ecc36d 100644 --- a/docs/architecture/PLAYBACK-ENGINE.md +++ b/docs/architecture/PLAYBACK-ENGINE.md @@ -1,6 +1,6 @@ # Playback engine architecture -> Current reviewed state: 2026-07-18. The original 2026-07-04 default-off +> Current reviewed state: 2026-08-01. The original 2026-07-04 default-off > MJPEG design is historical; this document records the Wave 1A implementation. ## Capability route is the sole authority @@ -115,6 +115,12 @@ the same project retain valid caches. 54/570; playback integration 7/7; transport 6/6; workspace Rust passed. Workspace still reports seven deliberate ignored probes: one ffmpeg/ffprobe environment probe and six real-device probes (three export, three playback). +- 2026-08-01 reconciliation: the four reviewed route/lifecycle owners passed + against the current code; Web passed 93 files/824 tests. The final packaged + macOS application exercised both a single-video WebKit project and a + text/color/multi-track Rust project, then switched back across the project + boundary without retaining the previous duration or playhead. Receipt: + [playback-route-lifecycle-real-device-2026-08-01.md](../audit/2026-07-14/runtime-artifacts/automated/playback-route-lifecycle-real-device-2026-08-01.md). Artifact hashes and the separation between older installed-app evidence and fresh detached bundles are recorded in diff --git a/docs/architecture/PORT-1TO1-GAP.md b/docs/architecture/PORT-1TO1-GAP.md index ad876f2f..a7608080 100644 --- a/docs/architecture/PORT-1TO1-GAP.md +++ b/docs/architecture/PORT-1TO1-GAP.md @@ -114,6 +114,10 @@ | P2-11 | isDocumentEdited 脏标记前端反馈 | core 用 `version != last_saved_version` 派生 dirty 传前端,标题栏显示未保存态;驱动防抖保存与退出 flush | `session.rs:251` | | P2-12 | 两套窗口尺寸 | 单窗不强求双窗,可设 minWidth 760 避免主页空旷 | `tauri.conf.json:21-26` | +P2-3 已由 `src-tauri/src/home.rs` 与 Home 卡片闭环:桌面端注册表以原子 JSON 持久化最近工程,启动同步只更新 `missing` 而不丢弃缺失条目;Reveal 和废纸篓命令只接受已注册的绝对 `.opentake` 路径。macOS 使用 `NSFileManager.trashItem`,Windows 使用系统回收站 API,Linux 使用 `gio trash`;仅在系统操作成功后才持久化移除。卡片提供缺失态、右键/按钮操作、从最近中移除、废纸篓二次确认、pending 与可重试错误状态。 + +P1-1、P2-1 与 P2-2 已按独立所有者闭环:`useAutosave` 保持 1.5 秒防抖,原生 CloseRequested 在隐藏窗口前同步 flush;成功保存只刷新最近工程的 `modifiedAt`/`thumbnailPath`,不会改动表示打开排序的 `openedAt`。原生注册表从工程包持久化的 `project.json` 修改时间与 `thumbnail.jpg` 刷新元数据,Home 卡片显示 16:9 封面和本地化相对修改时间(路径仅作 tooltip),缺失工程保留最后元数据并停止加载封面。 + --- ## 二、实现批次顺序(每批一个功能分支:写 → 审 → 修 → CI 绿 → 合并) @@ -175,6 +179,7 @@ ### 批次 9 | `feat/p2-samples-polish`(打磨) - P2-4 Sample 区、P2-12 窗口尺寸、剩余 P2 项。 +- **2026-08-01 代码门禁**:Sample 区已接入三个内置离线工程;物化成功、失败原子回滚、Home 路由成功/失败均有可执行测试。发布级 Web 门禁同时覆盖面板键盘聚焦、时间线 clip 的 24px 可访问代理、统一 `:focus-visible`、`prefers-reduced-motion` 与 `forced-colors`。打包桌面端的逐项视觉、键盘和核心编辑流证据仍必须在顺序 GUI 验证阶段留存后方可判定本批发布就绪。 --- diff --git a/docs/architecture/ROADMAP.md b/docs/architecture/ROADMAP.md index 5c1c708b..80eb6fc1 100644 --- a/docs/architecture/ROADMAP.md +++ b/docs/architecture/ROADMAP.md @@ -1,5 +1,11 @@ # OpenTake 分阶段实施路线图 +> **2026-08-03 状态裁决:**Phase 0–10 已全部走完并交付 Beta 1(`v1.0.0-beta.1`, +> 2026-08-01 发布)。当前处于 **Beta 2 收尾与发布验证**阶段:范围与门槛见 +> `docs/releases/1.0.0-beta.2.md`,逐项执行证据见 `docs/audit/2026-08-02/beta-functional-verification.md`。 +> 下文各 Phase 的「进度」注记为历史里程碑,不再代表阻塞项;剩余工作集中在收尾 +> (未决 finding 清零、真机/Windows 验证、候选包验收),不涉及新的大功能开发。 + > 原则:先把「纯逻辑层」做扎实并与上游对拍,再攻媒体引擎 blocker,最后接 UI / Agent / 生成后端。 > 每个阶段都有明确「交付物」与「验证标准」。详见 `docs/ARCHITECTURE.md`、`docs/MODULE-PORT-MAP.md`。 > 对标剪映的进阶能力(特效/转场/调色/蒙版/AI/音频工程等)穿插在下列阶段中,完整规划见 `docs/ADVANCED-FEATURES.md`。 @@ -60,12 +66,12 @@ 3. 应用内 chat(reqwest→Anthropic SSE,BYOK;prompt caching)。 4. **OpenTake 增强**:分层可组合系统提示词 + 模型策略配置化;高阶工具 `remove_filler_words`/`tighten_silences`;写工具返回结构化 JSON;新增 `get_capabilities`。 - **验证**:`claude mcp add` 能连;每个工具走通;应用内 chat 能完成多步链式编辑;助手专属 undo 正确。 -- **进度**:`list_models` 工具已从存根接到 `opentake-gen` 内置静态 catalog(#111,`?type=` 过滤 + `{ models, loaded }`,纯本地无网络/BYOK);`generate_*`/`upscale_media` 仍待 async + ProviderRegistry + BYOK。 +- **进度**:`list_models`、媒体检查/转写与口播高阶工具已接生产桥。基础集合最多 39 个真实路径工具并按媒体桥能力过滤;存在托管或兼容 BYOK 凭据时动态增加 `generate_*`/`upscale_media` 四个工具,无凭据时保持隐藏;Motion 两个兼容线名仍未发布。生成与 Chat/MCP 复用同一 Dispatcher/GenerationBridge。 ## Phase 8 — 文字/字幕渲染 + 转写 + 语义搜索 - **做**:cosmic-text + tiny-skia/Vello 文字渲染(阴影/描边/背景/对齐/换行,逐帧 opacity)接入合成器;whisper-rs 转写(word/segment 时间戳,`TranscriptionResult` 模型复用);candle/ort 跑 SigLIP2 + tokenizers 做视觉/口语搜索。 - **验证**:字幕静态渲染像素对齐上游;转写时间码映射正确;`search_media` 视觉/口语命中合理。 -- **进度**:SRT/VTT 字幕**导出纯逻辑**已落地(#110,`crates/opentake-domain/src/subtitle_export.rs`,按 `caption_group_id` 分组序列化,16 单测);剩接导出层 + `export_captions` agent 工具 + 前端导出对话框。 +- **进度**:SRT/VTT 字幕导出端到端已落地:`opentake-domain::subtitle_export` 按 `caption_group_id` 生成标准时码,Tauri `export_subtitles` 安全落盘,TitleBar 提供 SRT/VTT 原生保存入口;Rust 导出与前端菜单/交互路由均有测试。可选 Agent `export_captions` 工具不属于本 UI 导出验收项。 - **进阶扩展(ADVANCED-FEATURES B/C/D 层)**: - AI 推理特性(统一 ort worker):超分(Real-ESRGAN/SeedVR)、AI 抠像(RVM/BiRefNet)、运动追踪(CoTracker)、防抖(FFmpeg vidstab 起步)、光流补帧(RIFE/FILM,p3)、消除瑕疵(p3)。 - 音频工程(FFmpeg):响度统一(loudnorm/EBU R128)、降噪(afftdn/arnndn→DeepFilterNet)、人声分离(Demucs via ort)。 @@ -75,13 +81,15 @@ 对应 `opentake-gen` + `services/opentake-gen-proxy`。 - **做**:`GenClient`(复刻 `GenerationParams` 联合类型 + job 状态机);**BYOK 模式**(本地直连 fal/Replicate/OpenAI,keyring 存 key,内置静态 models catalog);**托管模式**(axum 代理 + provider adapters + 对象存储预签名 + 可选积分计费)。 - **验证**:BYOK 下能用自己的 fal key 生图/生视频并落回时间线;模型目录数据驱动 UI;托管代理可自部署。 +- **进度(2026-07-29)**:provider-neutral 耐久作业、BYOK/托管授权、成本确认、fal/Replicate/OpenAI/ElevenLabs dispatch、N 输出终局化、进度/取消/重试/恢复、MediaPanel 状态与安全下载已完成;确定性配置 provider 测试覆盖 image/video/audio/upscale 且不发起付费网络。托管代理自部署与真实账号付费冒烟仍需在 Beta 发布验证阶段完成。 - **进阶扩展 · AIGC 编排(ADVANCED-FEATURES E 层)**:智能剪口播(本地词级转写+静音检测→Rust 内算 ripple,高阶工具 `remove_filler_words`/`tighten_silences`)、图文成片(agent 编排既有工具+SigLIP2 选素材)、音色克隆(ElevenLabs 等)、虚拟数字人(HeyGen/fal,新增 catalog kind)、多语种字幕翻译(MT/LLM,保时码)。 ## Phase 10 —(新)Motion Canvas 动效 / AI Video 插件 -对应 `plugins/motion-canvas-studio`(待新增)+ `opentake-motion` fallback。详见 [MOTION-GRAPHICS-PLUGIN.md](../modules/opentake-motion/MOTION-GRAPHICS-PLUGIN.md)。 +对应 `plugins/motion-canvas-studio` + `opentake-motion` fallback。详见 [MOTION-GRAPHICS-PLUGIN.md](../modules/opentake-motion/MOTION-GRAPHICS-PLUGIN.md)。 - **做**:沿 issue #34,优先 fork / vendor Motion Canvas(MIT),作为独立 Motion / AI Video 插件。Agent 或 Motion Panel 生成 Motion Canvas scene/template → 插件渲染 `output.mp4` → OpenTake probe 并导入 media manifest → 单步落轨。`crates/opentake-motion` 现有 scaffold 保留为后续 PNG sequence / transparent alpha / HTML-CSS fallback,不再作为 v1 主渲染器 blocker。 - **验证**:Motion Canvas sample template 能生成 mp4;OpenTake 自动导入并创建 timeline clip;`composite_frame` 和 `export_video` 都包含该片段;失败不污染 manifest/timeline;README/NOTICE 保留 Motion Canvas MIT license 与修改说明。 - **时机**:v1 可复用普通视频导入/预览/导出链路,不阻塞 native alpha overlay。透明动效与 `ClipType::Motion`/frame sequence 放后续。 +- **进度(2026-08-01)**:Beta v1 竖切已完成。固定 `title-card` 使用锁定的 Motion Canvas 3.17.2 官方 `Renderer.renderFrame`,离线 Chromium 逐帧后由随包 FFmpeg 生成 `output.mp4`;Motion Panel、Agent add/edit、进度/取消、结果元数据校验、项目能力约束、单事务导入/落轨/替换/撤销/保存重开均已接通。自动化已验证重复构建与重复渲染确定性、异常/遍历/符号链接无污染,以及生成片段真实进入 `composite_frame` 和 `export_video`。透明输出、任意 TSX 和 frame-sequence 仍明确属于后续版本。 --- diff --git a/docs/architecture/STEM-SEPARATION.md b/docs/architecture/STEM-SEPARATION.md new file mode 100644 index 00000000..f8a655b5 --- /dev/null +++ b/docs/architecture/STEM-SEPARATION.md @@ -0,0 +1,28 @@ +# Stem separation execution boundaries + +OpenTake currently exposes two explicit execution choices. Only the local path is executable in this release; the hosted path is intentionally fail-closed. + +## Local execution + +`opentake-center-v1` is a bundled, inspectable DSP profile, not a neural semantic-separation model. On first use OpenTake atomically installs the 104-byte profile under the application model directory and verifies SHA-256 `9c72ab220f370000a702fc11c8071905648a56d1102d9519659a6062abb4b376`. An existing file with a different digest is rejected rather than silently replaced. + +The processor decodes the source to stereo 48 kHz PCM and derives centre `(L + R) / 2` and side `(L - R) / 2` signals. Vocals and accompaniment are published as stereo dual-mono WAV assets so each remains audible in the current mono export mixdown. This works well when voice/dialogue is centred and the desired accompaniment has stereo side information. It does not promise semantic separation of centred instruments, reverb, or mono material and must not be described as Demucs/MDX-equivalent. + +Local processing is offline. Source media is hashed but never modified or uploaded. Both result files are published and imported atomically, and each media entry records the source asset id, source SHA-256, execution id, model SHA-256, and stem kind. + +## Hosted execution + +Hosted selection requires all of the following before routing can be considered: + +- an explicit provider; +- a provider-qualified model whose prefix matches that provider; +- explicit confirmation that the source audio may be uploaded; +- a configured provider adapter. + +The desktop application currently has no stem-separation transport adapter. It rejects incomplete consent/configuration and performs no upload. Adding a hosted adapter later must reuse the provider registry and derived-media provenance path rather than placing credentials, signed URLs, or provider diagnostics in the project. + +## Job and failure semantics + +The Tauri owner allows one active separation job, emits source-scoped progress, and exposes cancellation. Output is written into a unique project `media/stems-` directory via partial files and atomic renames. Cancellation, processing failure, output-probe failure, or import failure removes the job directory. The two derived assets enter the shared media manifest in one persisted batch so a project cannot retain only one side of a successful operation. + +The Inspector identifies the local privacy boundary, the hosted upload boundary, active progress, cancellation, typed failures, and the ids/provenance of successful outputs. Derived assets use the same preview, timeline, playback, save/reopen, and export paths as ordinary imported audio. diff --git a/docs/architecture/editing-automation/EDITING-AUTOMATION-DOS.md b/docs/architecture/editing-automation/EDITING-AUTOMATION-DOS.md index d7db106d..87c206ef 100644 --- a/docs/architecture/editing-automation/EDITING-AUTOMATION-DOS.md +++ b/docs/architecture/editing-automation/EDITING-AUTOMATION-DOS.md @@ -42,7 +42,7 @@ The v1 editing automation set is: - `detect_beats`: read audio PCM and return beat/onset candidates without changing the timeline. - `auto_cut_to_beats`: align selected clips or media ranges to beat candidates through existing edit commands. - `tighten_silences`: find low-energy gaps and produce ripple delete ranges. -- `remove_filler_words`: disabled until timeline transcript tooling is truly wired; it depends on word-level transcript frames. +- `remove_filler_words`: uses the live timeline transcript bridge to return reviewable word-aligned filler cuts and exact `ripple_delete_ranges` commands; it is discovered only when that bridge is present. ## Scope Boundaries @@ -52,14 +52,16 @@ Automatic music beat sync v1 uses PCM energy/onset detection. It must not add he Agent tools may suggest edits without applying them. A write path must apply via `EditCommand` only. -Current MCP status: `detect_beats`, `auto_cut_to_beats`, and `tighten_silences` are typed tools backed by `CoreHandle::extract_analysis_pcm`, so they can produce PCM-based frame hints and candidate edit commands without mutating the timeline. `smart_reframe` is still a typed preflight surface that returns a vision-backend diagnostic until sampled-frame/saliency access is exposed. `remove_filler_words` remains disabled until transcript access is truly wired. +Current MCP status: `detect_beats`, `auto_cut_to_beats`, and `tighten_silences` are typed tools backed by `CoreHandle::extract_analysis_pcm`. Beat detection and silence tightening are read-only; `auto_cut_to_beats` defaults to a read-only proposal and, only with `write=true`, aligns selected visual clips plus linked A/V partners through one atomic `MoveClips` command. `remove_filler_words` is backed by word-level project-frame transcripts, supports configurable multi-word lexicons, and returns per-cut review data plus undoable ripple commands without mutating the timeline. `smart_reframe` is still a typed preflight surface that returns a vision-backend diagnostic until sampled-frame/saliency access is exposed. + +Completion evidence (2026-07-31): `crates/opentake-agent/tests/editing_automation_acceptance.rs#automation_children_are_atomic_reviewable_and_command_routed` executes the shared contract across deterministic beat and autocrop analysis, MCP beat/silence preview payloads, the typed unavailable smart-reframe boundary, one-command smart-reframe and beat-placement plans, rejection with zero mutation, and exact single-undo restoration. The seven source-bound owners in `tools/completion-tests/doc-*.test.mjs` delegate to that exact integration owner and reject a zero-test Cargo filter. ## Failure Semantics - No media decode: return a structured diagnostic and no edit. - Ambiguous short IDs: fail before typed args or command creation. - Analysis confidence below threshold: return suggestions, not writes. -- Transcript unavailable: `remove_filler_words` remains unavailable; `tighten_silences` can still use PCM energy. +- Transcript bridge unavailable: `remove_filler_words` is absent from discovery and direct calls fail closed as not advertised; `tighten_silences` can still use PCM energy. - `ripple_delete_ranges` accepts exactly one of `trackIndex` or `clipId`. `units="frames"` is the default; `units="seconds"` is valid only with `clipId` and is converted through the timeline fps plus the clip's source-frame trim/speed mapping before producing half-open project-frame ranges. - `add_clips` with omitted `trackIndex` must route through one atomic auto-track `EditCommand`; track creation and clip placement must undo together. - `swapMedia` consumes only `clipId` + `mediaRef`. Frontend types and wrappers must not expose duration/type/trim options unless the backend starts consuming them. diff --git a/docs/architecture/editing-automation/EDITING-AUTOMATION/acceptance-tests.md b/docs/architecture/editing-automation/EDITING-AUTOMATION/acceptance-tests.md index caff2b09..6af64287 100644 --- a/docs/architecture/editing-automation/EDITING-AUTOMATION/acceptance-tests.md +++ b/docs/architecture/editing-automation/EDITING-AUTOMATION/acceptance-tests.md @@ -46,13 +46,15 @@ Parent contract: [Editing Automation DOS](../EDITING-AUTOMATION-DOS.md). Source ## Agent / Workflow Checks - `detect_beats`, `auto_cut_to_beats`, `smart_reframe`, and `tighten_silences` are visible in tool metadata when implemented. -- `remove_filler_words` reports unavailable until word-level transcript is wired to timeline frames. +- `remove_filler_words` is advertised only with the transcript bridge; it returns word-aligned review cuts and direct dispatch fails closed when the bridge is absent. - Active workflow plugin roles affect tool target selection. - Plugin rules appear in `context_signal` warnings without suppressing built-in warnings. - Agent `ripple_delete_ranges` rejects calls that pass both `trackIndex` and `clipId`, accepts `clipId + units=seconds`, and emits half-open project-frame ranges after fps/source-trim conversion. - Agent `add_clips` with omitted `trackIndex` creates shared auto tracks and clips in one undoable transaction; one `undo` removes both clips and auto-created tracks. -- PCM-backed MCP tools return deterministic preview data: `detect_beats` returns beat frame hints, `auto_cut_to_beats` returns beat/cut/placement suggestions, and `tighten_silences` returns `ripple_delete_ranges` candidate commands without mutating the timeline. `smart_reframe` still returns a deterministic vision-backend diagnostic until sampled-frame analysis is wired. +- PCM-backed MCP tools return deterministic preview data: `detect_beats` returns beat frame hints, `auto_cut_to_beats(write=false)` returns beat/cut/placement suggestions, and `tighten_silences` returns `ripple_delete_ranges` candidate commands without mutating the timeline. `auto_cut_to_beats(write=true)` applies selected visual placements and linked A/V partners through exactly one `MoveClips` command. `smart_reframe` still returns a deterministic vision-backend diagnostic until sampled-frame analysis is wired. ## Minimum Local Verification Run a local Markdown link existence check over `docs/DOS/**/*.md`. This does not prove implementation behavior, but it prevents stale cross-document references in the DOS set. + +Current verification (2026-07-31): the source-bound completion owners for Documentation Checks, Shared Implementation Checks, Beat Sync Checks, and Minimum Local Verification all execute `crates/opentake-agent/tests/editing_automation_acceptance.rs#automation_children_are_atomic_reviewable_and_command_routed`. That integration test covers deterministic media analysis, review-only MCP results, typed failure with zero mutation, single-command plans, linked-A/V intent flags, command-bound mutation, and exact undo restoration. A local relative-link check over this file and its parent DOS passed with no unresolved targets. diff --git a/docs/architecture/editing-automation/EDITING-AUTOMATION/agent-editing-suggestions.md b/docs/architecture/editing-automation/EDITING-AUTOMATION/agent-editing-suggestions.md index 2beb75bc..f0a5bffe 100644 --- a/docs/architecture/editing-automation/EDITING-AUTOMATION/agent-editing-suggestions.md +++ b/docs/architecture/editing-automation/EDITING-AUTOMATION/agent-editing-suggestions.md @@ -23,9 +23,9 @@ V1 automation tools: - `smart_reframe`: proposal or write mode, applies crop/transform commands. - `tighten_silences`: detects low-energy PCM ranges and maps them to `RippleDeleteRanges`. -Deferred: +Transcript-backed: -- `remove_filler_words`: depends on word-level `get_transcript` being truly wired through timeline frames. Until then, it must report unavailable rather than guessing from captions or segments. +- `remove_filler_words`: reads word-level `get_transcript` project frames, matches a configurable multi-word lexicon, and returns reviewable cuts plus `ripple_delete_ranges` commands. It is fail-closed and undiscoverable without the transcript bridge. ## Suggestion Shape @@ -62,7 +62,7 @@ Workflow plugin rules are additive. Built-in signal rules still apply. ## Current Tool Availability -The analysis-driven tool names are intentionally visible in MCP. `detect_beats`, `auto_cut_to_beats`, and `tighten_silences` validate args and use PCM analysis through the `CoreHandle` boundary to return preview data or candidate edit commands. `smart_reframe` validates args but still returns a vision-backend diagnostic until sampled-frame/saliency access is available. +The analysis-driven tool names are capability-gated in MCP. `detect_beats`, `auto_cut_to_beats`, and `tighten_silences` validate args and use PCM analysis through the `CoreHandle` boundary to return preview data or candidate edit commands. `remove_filler_words` appears only with a live media/transcript bridge. `smart_reframe` validates args but still returns a vision-backend diagnostic until sampled-frame/saliency access is available. ## Acceptance Hooks @@ -72,4 +72,4 @@ See [acceptance tests](acceptance-tests.md). Required checks: - `write=false` never calls `CoreHandle::apply()`; - successful writes return shortened IDs; - `context_signal` survives both success and no-op proposal paths; -- `remove_filler_words` is unavailable until transcript is wired. +- `remove_filler_words` returns stable, word-aligned review cuts; applying only accepted ranges leaves rejected words untouched and one undo restores the exact prior timeline. diff --git a/docs/architecture/editing-automation/EDITING-AUTOMATION/beat-sync-auto-cut.md b/docs/architecture/editing-automation/EDITING-AUTOMATION/beat-sync-auto-cut.md index c0837d6d..727a8cf5 100644 --- a/docs/architecture/editing-automation/EDITING-AUTOMATION/beat-sync-auto-cut.md +++ b/docs/architecture/editing-automation/EDITING-AUTOMATION/beat-sync-auto-cut.md @@ -57,6 +57,15 @@ No FFT is required for v1. If a future version adds spectral flux, it must be do - trim clip boundaries to nearest beat when within a small tolerance; - return a proposal when confidence is low. +Implemented v1 write boundary: `write` defaults to `false`. The preview payload +contains deterministic `cutFrames` and `placements` and performs no edit. With +`write=true`, every requested visual root is aligned to a detected beat; each +linked A/V partner is expanded with the same frame delta and the complete move +set is applied once as `EditCommand::MoveClips`. Missing/nonvisual clips, +insufficient beats, and negative linked placements are rejected before the +command boundary. The BGM analysis source is never included in the move set +unless it is itself an explicitly linked partner of a selected visual clip. + It must apply edits only through the shared path: `TimelineContainer/Inspector/Toolbar` -> `web/src/store/editActions.ts` -> `web/src/lib/api.ts editApply()` -> `src-tauri/src/commands.rs edit_apply` -> `AppCore::apply()` -> `opentake-ops::EditCommand` -> `ops/*` -> `timeline_changed` -> `sync.ts`. @@ -81,3 +90,19 @@ See [acceptance tests](acceptance-tests.md). Minimum cases: - Low-energy speech track is not over-detected as montage beats. - `auto_cut_to_beats(write=false)` emits no `timeline_changed`. - Linked visual/audio pairs stay in the same `linkGroupId` alignment after auto cut. + +Completion evidence (2026-07-31): + +- `crates/opentake-media/src/analysis/beat.rs#pulse_audio_detects_beat_frame_with_strength` + verifies a real normalized pulse, while + `#low_energy_speech_is_not_overdetected` verifies the absolute energy floor. +- `crates/opentake-agent/src/mcp/dispatch.rs#auto_cut_to_beats_write_false_is_read_only` + proves preview mode emits no command or timeline change. +- `crates/opentake-agent/src/mcp/dispatch.rs#auto_cut_to_beats_write_true_is_one_atomic_command_and_preserves_links` + proves a rejected request is a no-op and a valid request emits exactly one + `MoveClips` command while retaining linked A/V identity and alignment. +- `web/src/store/editActions.test.ts#accepts one atomic request and refuses a multi-command pseudo-transaction` + prevents the frontend automation adapter from representing several IPC edits + as one transaction. +- The five source-bound `tools/completion-tests/doc-*.test.mjs` owners execute + these focused boundaries and reject zero-test filters. diff --git a/docs/architecture/editing-automation/EDITING-AUTOMATION/workflow-plugin-recipes.md b/docs/architecture/editing-automation/EDITING-AUTOMATION/workflow-plugin-recipes.md index 285b91c6..48b736f8 100644 --- a/docs/architecture/editing-automation/EDITING-AUTOMATION/workflow-plugin-recipes.md +++ b/docs/architecture/editing-automation/EDITING-AUTOMATION/workflow-plugin-recipes.md @@ -33,7 +33,7 @@ Stages: 1. `get_transcript` when available. 2. `tighten_silences` on the `VoiceOver` track. -3. `remove_filler_words` only after transcript is truly wired. +3. `remove_filler_words` when capability discovery exposes it; review word-aligned cuts before applying accepted ripple ranges. 4. `smart_reframe` for vertical repurposing if target aspect differs. Rules: diff --git a/docs/audit/2026-07-14/beta-1-convergence-2026-08-01.md b/docs/audit/2026-07-14/beta-1-convergence-2026-08-01.md new file mode 100644 index 00000000..032d1841 --- /dev/null +++ b/docs/audit/2026-07-14/beta-1-convergence-2026-08-01.md @@ -0,0 +1,56 @@ +# Beta 1 目标与规划对账 — 2026-08-01 + +## 对账范围 + +已检查根目录 README/CHANGELOG、`docs/architecture` 路线与差距文档、 +`docs/audit/2026-07-14/implementation-plans` 的十组生成计划、 +`docs/superpowers/plans` / `.superpowers/sdd` 的执行报告,以及所有 2026-07-29 至 +2026-08-01 的自动化与实机证据。 + +2026-07-14 的 completion ledger 是审计基线,不是随每个后续实现提交自动回填的动态状态。 +它仍保留大量 `incomplete` 与未勾选步骤,即使对应代码、测试和运行证据后来已经落地。 +因此本次不改写用户正在生成/维护的四个受保护台账文件,而以提交历史、现行代码测试和 +日期更晚的 runtime artifact 作当前裁决。 + +## Beta 代码状态 + +首个 Beta 范围内已经没有已知的“只有规划、没有生产入口”的代码项。最后一个确定缺口 +`requirement-fdd45062091b48f3`(运动追踪)已在 `73a245c` 补齐:Tauri 命令、Inspector、 +主预览拖框、帧范围、取消/重试、置信度/关键帧审阅、Apply、Undo、保存重开与 H.264 导出。 + +其余近期高级竖切均已有生产入口和自动化证据: + +- RVM 抠像、智能擦除、参考色彩匹配; +- 声部分离、响度、降噪; +- 口播清理、字幕翻译、图文成片; +- Motion Canvas、原生 Chromium fallback、Lottie; +- 数字人和音色克隆的 provider、同意、成本、取消、原子导入与撤销边界; +- HDR/代理/账号、保存重开、预览/播放/导出、数据安全与跨平台打包契约。 + +## 候选包仍需顺序验证 + +以下不是代码 TODO,而是必须在同一个 `1.0.0-beta.1` 候选包上完成的发布证据: + +1. 按 `docs/releases/1.0.0-beta.1.md` 的 11 个顺序阶段执行 macOS GUI 验收。 +2. 对抠像、擦除、色彩匹配、运动追踪、声部分离和 Motion 结果做保存重开与最终导出检查。 +3. 验证 Agent、生成、数字人、音色克隆在无凭据时明确拒绝,不发生付费请求或部分导入。 +4. 构建 Apple Silicon `.app` / `.dmg`,记录 SHA-256、签名状态与 Gatekeeper 结果。 +5. 把候选提交推送到远端,并运行 exact-SHA CI;Windows 安装器由该工作流生成。 + +## 外部条件(不允许伪造) + +- 当前没有 fal、ElevenLabs、OpenAI 或 Anthropic key,不能执行真实付费 provider 请求或 + Agent 云模型对话。确定性 fixture 覆盖代码契约;GUI 只验证无凭据、同意、成本、取消和 + 错误可见性。真实付费冒烟必须由用户提供 key 并明确授权费用后另行执行。 +- 钥匙串只有 Apple Development 身份,没有 Developer ID Application 与公证凭据。 + 本地 Beta 可用,但不是面向陌生用户的已公证分发包。 +- 当前主机是 macOS,不能伪造 Windows WebView 人工交互。Windows 编译、安装器、sidecar、 + safe-fs 由 GitHub exact-SHA 原生 runner 验证;人工 UI 烟测需 Windows 交互环境。 +- Unix 生产原子目录创建因缺少可移植“mkdir 并返回所创建对象 fd”原语而严格拒绝;这是 + 安全架构裁决,不是可通过普通补丁消除的测试失败。 + +## Beta 后续路线(不在本次发布门禁) + +Bezier/Spring、RGB 曲线、更多双源转场、本地神经超分、神经语义分轨、曲线变速、 +多机位对齐、任意 Motion Canvas TSX/透明序列均保留在后续 Beta。当前产品不展示虚假可用 +控件;这些未来目标不会阻止首个本地可用 Beta,但也不会被计为已经完成。 diff --git a/docs/audit/2026-07-14/document-reconciliation.md b/docs/audit/2026-07-14/document-reconciliation.md index 0af85f16..219d72df 100644 --- a/docs/audit/2026-07-14/document-reconciliation.md +++ b/docs/audit/2026-07-14/document-reconciliation.md @@ -542,3 +542,5 @@ Every row below is one current `incomplete` requirement from the normative ledge | preview-timeline | `doc-f0e04ccd5d84f1bf` | docs/specs/frontend/13-implementation.md:41 | Match sticky multi-target snapping including both 1.5x thresholds. | Implementation: Add exact vector and pointer tests for acquisition, sticky release, playhead multiplier, multi-probe tie-breaking and haptic re-arm at threshold boundaries.; Add exact geometry, hit-test, command-payload, interaction, boundary, high-DPI, and visual assertions for every named timeline behavior; the affected web suites must pass.; Exercise the named pointer, keyboard, playback, or canvas path in a real browser or packaged app and retain exact runtime or golden evidence before reclassification. | Unchecked exhaustive-conformance row; partial implementation is not enough to claim every enumerated behavior. | | preview-timeline | `doc-f17cf6d289a59abe` | docs/modules/web/SPEC.md:1274 | Verify every §9.3 trim rule, including one-frame minimum, unbounded stills, and linked propagation. | Translate every numbered checklist row into a named deterministic interaction test and a native/browser runtime evidence row where applicable.; Assert the exact store/API command, visible success/failure state, and no-op behavior for rejected/cancelled paths.; Pass the full matrix against the pinned upstream semantics with no unchecked rows. | web/src/store/editActions.ts; web/src/components/timeline/TimelineContainer.test.ts; web/src/components/timeline/hitTest.test.ts; web/src/store/editActions.test.ts; web/src/hooks/useKeyboardShortcuts.test.ts | | preview-timeline | `doc-f9f8b45c54e52979` | docs/architecture/CAPCUT-GAP.md:9 | Large 50-track projects remain responsive during edit, seek, playback, and save. | Load a deterministic fixture containing at least 50 tracks and 1,000 clips without dropping or reordering clips.; Perform select, move, trim, seek, undo, save, and reopen operations while preserving exact frame positions and track ownership.; Add a repeatable benchmark that records p95 interaction latency, playback underruns, peak memory, and save/reopen equality; set and enforce the release thresholds in CI. | open-gap:acceptance-criteria-required | + +Injected legacy reference: doc-00110a2e259a7a44 diff --git a/docs/audit/2026-07-14/final-verification.json b/docs/audit/2026-07-14/final-verification.json index 54416e0f..2fe91894 100644 --- a/docs/audit/2026-07-14/final-verification.json +++ b/docs/audit/2026-07-14/final-verification.json @@ -2,11 +2,11 @@ "schema": 1, "scope": "all", "auditDirectory": "docs/audit/2026-07-14", - "ok": true, - "passed": true, + "ok": false, + "passed": false, "counts": { - "tracked": 796, - "files": 796, + "tracked": 881, + "files": 881, "documents": 2704, "requirements": 2704, "controls": 259, @@ -73,7 +73,7 @@ } }, "scopeHashes": { - "files": "30dd6cebd521d34d65b29278aaa53e0f3d9ce77e110e84a07a027b6774670dd5", + "files": "0e21fa62d378c032b092b8426107a4656a0e8188fddb89120d233a75de09a82b", "documents": "0a16bb223006674f1606a1a7ce28ad54982dcbaa3ccdf9da3868ae97ebca4202", "controls": "b3117d6ef9db5b39867989d8cca4c046229b50d927755aeca77b03ef21be1994", "sources": "7ad8d78f87a494b0489599049c0b39968c6a857b28061c6b1b8cdfad8597ed7c", @@ -84,16 +84,16 @@ "files": { "passed": true, "counts": { - "tracked": 796, - "inventoried": 796, + "tracked": 881, + "inventoried": 881, "missing": 0, "orphan": 0, "hashMismatches": 0, - "deferred": 4 + "deferred": 1 } }, "documents": { - "passed": true, + "passed": false, "counts": { "candidates": 2704, "records": 2704, @@ -112,7 +112,7 @@ } }, "controls": { - "passed": true, + "passed": false, "counts": { "candidates": 259, "records": 259, @@ -131,7 +131,7 @@ } }, "sources": { - "passed": true, + "passed": false, "counts": { "sources": 4, "changedPaths": 132, @@ -142,5 +142,2216 @@ } } }, - "errors": [] + "errors": [ + { + "code": "document-candidate-ledger-drift", + "candidateId": null, + "message": "document-candidates.json differs from a full current-source re-extraction", + "scope": "documents" + }, + { + "code": "candidate-source-signal-missing", + "candidateId": "doc-4c6ebcb2ccfec8db", + "message": "candidate signal is absent at CLAUDE.md:79", + "scope": "documents" + }, + { + "code": "candidate-source-signal-missing", + "candidateId": "doc-404f1d1126d8665b", + "message": "candidate signal is absent at docs/architecture/BUGS.md:42", + "scope": "documents" + }, + { + "code": "candidate-id-mismatch", + "candidateId": "doc-404f1d1126d8665b", + "message": "candidate ID does not derive from current semantic source: doc-404f1d1126d8665b", + "scope": "documents" + }, + { + "code": "candidate-source-signal-missing", + "candidateId": "doc-0d70354c07036fd0", + "message": "candidate signal is absent at docs/architecture/BUGS.md:62", + "scope": "documents" + }, + { + "code": "candidate-id-mismatch", + "candidateId": "doc-0d70354c07036fd0", + "message": "candidate ID does not derive from current semantic source: doc-0d70354c07036fd0", + "scope": "documents" + }, + { + "code": "candidate-source-signal-missing", + "candidateId": "doc-3c2b1bdd46d20078", + "message": "candidate signal is absent at docs/architecture/BUGS.md:67", + "scope": "documents" + }, + { + "code": "candidate-source-signal-missing", + "candidateId": "doc-457715e9d0e73d1b", + "message": "candidate signal is absent at docs/architecture/CAPCUT-GAP.md:197", + "scope": "documents" + }, + { + "code": "candidate-source-signal-missing", + "candidateId": "doc-ee70be7aba4166b3", + "message": "candidate signal is absent at docs/architecture/FULL_PROJECT_SCAN_REPORT.md:97", + "scope": "documents" + }, + { + "code": "candidate-source-signal-missing", + "candidateId": "doc-a9ca85cdc22c88d8", + "message": "candidate signal is absent at docs/architecture/FULL_PROJECT_SCAN_REPORT.md:100", + "scope": "documents" + }, + { + "code": "candidate-source-signal-missing", + "candidateId": "doc-445290562fb2b28f", + "message": "candidate signal is absent at docs/architecture/FULL_PROJECT_SCAN_REPORT.md:125", + "scope": "documents" + }, + { + "code": "candidate-source-signal-missing", + "candidateId": "doc-7a1eaa5cce0fa7ea", + "message": "candidate signal is absent at docs/architecture/FULL_PROJECT_SCAN_REPORT.md:145", + "scope": "documents" + }, + { + "code": "candidate-source-signal-missing", + "candidateId": "doc-d4af138e98e3b504", + "message": "candidate signal is absent at docs/architecture/HANDOFF-2026-07.md:146", + "scope": "documents" + }, + { + "code": "candidate-id-mismatch", + "candidateId": "doc-d4af138e98e3b504", + "message": "candidate ID does not derive from current semantic source: doc-d4af138e98e3b504", + "scope": "documents" + }, + { + "code": "candidate-source-signal-missing", + "candidateId": "doc-b6561533456f47b0", + "message": "candidate signal is absent at docs/architecture/HANDOFF-2026-07.md:151", + "scope": "documents" + }, + { + "code": "candidate-id-mismatch", + "candidateId": "doc-b6561533456f47b0", + "message": "candidate ID does not derive from current semantic source: doc-b6561533456f47b0", + "scope": "documents" + }, + { + "code": "candidate-source-signal-missing", + "candidateId": "doc-e2f5d13fe630a705", + "message": "candidate signal is absent at docs/architecture/HANDOFF-2026-07.md:201", + "scope": "documents" + }, + { + "code": "candidate-source-signal-missing", + "candidateId": "doc-f5044a046aaa65d8", + "message": "candidate signal is absent at docs/architecture/MODULE-PORT-MAP.md:122", + "scope": "documents" + }, + { + "code": "candidate-id-mismatch", + "candidateId": "doc-f5044a046aaa65d8", + "message": "candidate ID does not derive from current semantic source: doc-f5044a046aaa65d8", + "scope": "documents" + }, + { + "code": "candidate-source-signal-missing", + "candidateId": "doc-df1706c5a1ff3f67", + "message": "candidate signal is absent at docs/modules/opentake-agent/OVERVIEW.md:122", + "scope": "documents" + }, + { + "code": "candidate-id-mismatch", + "candidateId": "doc-df1706c5a1ff3f67", + "message": "candidate ID does not derive from current semantic source: doc-df1706c5a1ff3f67", + "scope": "documents" + }, + { + "code": "candidate-source-signal-missing", + "candidateId": "doc-a936d6ca69fb1768", + "message": "candidate signal is absent at docs/modules/opentake-agent/OVERVIEW.md:124", + "scope": "documents" + }, + { + "code": "candidate-source-signal-missing", + "candidateId": "doc-107089ca7c128cf4", + "message": "candidate signal is absent at docs/modules/opentake-agent/OVERVIEW.md:130", + "scope": "documents" + }, + { + "code": "candidate-source-signal-missing", + "candidateId": "doc-5f4c21fe798697e4", + "message": "candidate signal is absent at docs/modules/opentake-agent/SPEC.md:118", + "scope": "documents" + }, + { + "code": "candidate-source-signal-missing", + "candidateId": "doc-49175d48852d43c0", + "message": "candidate signal is absent at docs/modules/opentake-agent/SPEC.md:188", + "scope": "documents" + }, + { + "code": "candidate-source-signal-missing", + "candidateId": "doc-9b90bbb501eeed4a", + "message": "candidate signal is absent at docs/modules/opentake-agent/SPEC.md:189", + "scope": "documents" + }, + { + "code": "candidate-source-signal-missing", + "candidateId": "doc-7232a0984ac6a908", + "message": "candidate signal is absent at docs/modules/opentake-agent/SPEC.md:191", + "scope": "documents" + }, + { + "code": "candidate-source-signal-missing", + "candidateId": "doc-122beba700691cb7", + "message": "candidate signal is absent at docs/modules/opentake-agent/SPEC.md:1071", + "scope": "documents" + }, + { + "code": "candidate-source-signal-missing", + "candidateId": "doc-e13f2cbc316a11fa", + "message": "candidate signal is absent at docs/modules/opentake-agent/SPEC.md:1072", + "scope": "documents" + }, + { + "code": "candidate-source-signal-missing", + "candidateId": "doc-be1f9acc4527e225", + "message": "candidate signal is absent at docs/modules/opentake-agent/dispatch-tools.md:36", + "scope": "documents" + }, + { + "code": "candidate-id-mismatch", + "candidateId": "doc-be1f9acc4527e225", + "message": "candidate ID does not derive from current semantic source: doc-be1f9acc4527e225", + "scope": "documents" + }, + { + "code": "candidate-source-signal-missing", + "candidateId": "doc-d5c3ee38efee7249", + "message": "candidate signal is absent at docs/modules/opentake-agent/dispatch-tools.md:42", + "scope": "documents" + }, + { + "code": "candidate-source-signal-missing", + "candidateId": "doc-6b772ba1f1530730", + "message": "candidate signal is absent at docs/modules/opentake-agent/dispatch-tools.md:49", + "scope": "documents" + }, + { + "code": "candidate-id-mismatch", + "candidateId": "doc-6b772ba1f1530730", + "message": "candidate ID does not derive from current semantic source: doc-6b772ba1f1530730", + "scope": "documents" + }, + { + "code": "candidate-source-signal-missing", + "candidateId": "doc-633cb649fbb10f3c", + "message": "candidate signal is absent at docs/modules/opentake-core/SPEC.md:1", + "scope": "documents" + }, + { + "code": "candidate-id-mismatch", + "candidateId": "doc-633cb649fbb10f3c", + "message": "candidate ID does not derive from current semantic source: doc-633cb649fbb10f3c", + "scope": "documents" + }, + { + "code": "candidate-source-signal-missing", + "candidateId": "doc-d0e72203255f0a41", + "message": "candidate signal is absent at docs/modules/opentake-core/SPEC.md:423", + "scope": "documents" + }, + { + "code": "candidate-source-signal-missing", + "candidateId": "doc-1cb2f0539425a14e", + "message": "candidate signal is absent at docs/modules/opentake-media/SPEC.md:163", + "scope": "documents" + }, + { + "code": "candidate-source-signal-missing", + "candidateId": "doc-f67f7b3bb62f249d", + "message": "candidate signal is absent at docs/modules/web/SPEC.md:1263", + "scope": "documents" + }, + { + "code": "candidate-source-signal-missing", + "candidateId": "doc-2a61d6818d85d37f", + "message": "candidate signal is absent at docs/modules/web/SPEC.md:1289", + "scope": "documents" + }, + { + "code": "candidate-source-signal-missing", + "candidateId": "doc-2505e846ef691f9a", + "message": "candidate signal is absent at docs/specs/agent/10-implementation.md:51", + "scope": "documents" + }, + { + "code": "candidate-id-mismatch", + "candidateId": "doc-2505e846ef691f9a", + "message": "candidate ID does not derive from current semantic source: doc-2505e846ef691f9a", + "scope": "documents" + }, + { + "code": "candidate-source-signal-missing", + "candidateId": "doc-f1f44c10c40acb1f", + "message": "candidate signal is absent at docs/specs/agent/2-tools.md:44", + "scope": "documents" + }, + { + "code": "candidate-source-signal-missing", + "candidateId": "doc-3027240e8af0de72", + "message": "candidate signal is absent at docs/specs/agent/2-tools.md:45", + "scope": "documents" + }, + { + "code": "candidate-source-signal-missing", + "candidateId": "doc-e951ead2b99303c9", + "message": "candidate signal is absent at docs/specs/agent/2-tools.md:47", + "scope": "documents" + }, + { + "code": "candidate-source-signal-missing", + "candidateId": "doc-8faa0d04f0c889c1", + "message": "candidate signal is absent at docs/specs/core/1-editor-state.md:9", + "scope": "documents" + }, + { + "code": "candidate-source-signal-missing", + "candidateId": "doc-cf44660f6e5bd373", + "message": "candidate signal is absent at docs/specs/core/1-editor-state.md:49", + "scope": "documents" + }, + { + "code": "candidate-source-signal-missing", + "candidateId": "doc-b2159a4af40efa5d", + "message": "candidate signal is absent at docs/specs/core/2-command-routing.md:3", + "scope": "documents" + }, + { + "code": "candidate-id-mismatch", + "candidateId": "doc-b2159a4af40efa5d", + "message": "candidate ID does not derive from current semantic source: doc-b2159a4af40efa5d", + "scope": "documents" + }, + { + "code": "candidate-source-signal-missing", + "candidateId": "doc-149099f8c0e60570", + "message": "candidate signal is absent at docs/specs/core/2-command-routing.md:15", + "scope": "documents" + }, + { + "code": "candidate-source-signal-missing", + "candidateId": "doc-dc4980e1cf95b7d2", + "message": "candidate signal is absent at docs/specs/core/2-command-routing.md:52", + "scope": "documents" + }, + { + "code": "candidate-source-signal-missing", + "candidateId": "doc-97047ee2cef7bff4", + "message": "candidate signal is absent at docs/specs/core/2-command-routing.md:102", + "scope": "documents" + }, + { + "code": "candidate-source-signal-missing", + "candidateId": "doc-c95515ae8f048e8a", + "message": "candidate signal is absent at docs/specs/core/2-command-routing.md:140", + "scope": "documents" + }, + { + "code": "candidate-source-signal-missing", + "candidateId": "doc-5a5785b4e46c5875", + "message": "candidate signal is absent at docs/specs/core/5-assembly.md:1", + "scope": "documents" + }, + { + "code": "candidate-id-mismatch", + "candidateId": "doc-5a5785b4e46c5875", + "message": "candidate ID does not derive from current semantic source: doc-5a5785b4e46c5875", + "scope": "documents" + }, + { + "code": "candidate-source-signal-missing", + "candidateId": "doc-13a1d8111c7e9441", + "message": "candidate signal is absent at docs/specs/core/5-assembly.md:5", + "scope": "documents" + }, + { + "code": "candidate-source-signal-missing", + "candidateId": "doc-7be80db04be852c6", + "message": "candidate signal is absent at docs/specs/core/5-assembly.md:21", + "scope": "documents" + }, + { + "code": "candidate-source-signal-missing", + "candidateId": "doc-8fb894b0ea5ce82e", + "message": "candidate signal is absent at docs/specs/core/5-assembly.md:36", + "scope": "documents" + }, + { + "code": "candidate-source-signal-missing", + "candidateId": "doc-e286de761da93671", + "message": "candidate signal is absent at docs/specs/core/5-assembly.md:48", + "scope": "documents" + }, + { + "code": "candidate-source-signal-missing", + "candidateId": "doc-10feadcab19f56dd", + "message": "candidate signal is absent at docs/specs/core/5-assembly.md:54", + "scope": "documents" + }, + { + "code": "candidate-source-signal-missing", + "candidateId": "doc-c5eeda123dd2c6ca", + "message": "candidate signal is absent at docs/specs/core/5-assembly.md:58", + "scope": "documents" + }, + { + "code": "candidate-source-signal-missing", + "candidateId": "doc-2b26469de8b23218", + "message": "candidate signal is absent at docs/specs/frontend/13-implementation.md:20", + "scope": "documents" + }, + { + "code": "candidate-source-signal-missing", + "candidateId": "doc-1daa44ecea72d622", + "message": "candidate signal is absent at docs/specs/frontend/13-implementation.md:46", + "scope": "documents" + }, + { + "code": "control-candidate-ledger-drift", + "candidateId": null, + "message": "control-candidates.json differs from a full current-source re-extraction", + "scope": "controls" + }, + { + "code": "verifier-revision-not-two-stage-parent", + "candidateId": null, + "message": "verifierRevision must be current HEAD or the direct parent of the evidence commit", + "scope": "controls" + }, + { + "code": "candidate-source-aggregate-mismatch", + "candidateId": null, + "message": "candidate TSX sources differ from audited provenance", + "scope": "controls" + }, + { + "code": "backend-source-hash-mismatch", + "candidateId": null, + "message": "backend source hash drifted: code:crates/opentake-core/src/core.rs#AppCore", + "scope": "controls" + }, + { + "code": "backend-source-hash-mismatch", + "candidateId": null, + "message": "backend source hash drifted: code:crates/opentake-media/src/library.rs#LibraryStore", + "scope": "controls" + }, + { + "code": "backend-source-hash-mismatch", + "candidateId": null, + "message": "backend source hash drifted: code:crates/opentake-ops/src/command.rs#EditCommand", + "scope": "controls" + }, + { + "code": "backend-source-hash-mismatch", + "candidateId": null, + "message": "backend source hash drifted: code:src-tauri/src/account.rs#account_login", + "scope": "controls" + }, + { + "code": "backend-source-hash-mismatch", + "candidateId": null, + "message": "backend source hash drifted: code:src-tauri/src/account.rs#account_logout", + "scope": "controls" + }, + { + "code": "backend-source-hash-mismatch", + "candidateId": null, + "message": "backend source hash drifted: code:src-tauri/src/account.rs#account_set_backend_url", + "scope": "controls" + }, + { + "code": "backend-source-hash-mismatch", + "candidateId": null, + "message": "backend source hash drifted: code:src-tauri/src/captions.rs#generate_captions", + "scope": "controls" + }, + { + "code": "backend-source-hash-mismatch", + "candidateId": null, + "message": "backend source hash drifted: code:src-tauri/src/chat.rs#chat_cancel", + "scope": "controls" + }, + { + "code": "backend-source-hash-mismatch", + "candidateId": null, + "message": "backend source hash drifted: code:src-tauri/src/chat.rs#chat_send", + "scope": "controls" + }, + { + "code": "backend-source-hash-mismatch", + "candidateId": null, + "message": "backend source hash drifted: code:src-tauri/src/commands.rs#edit_apply", + "scope": "controls" + }, + { + "code": "backend-source-hash-mismatch", + "candidateId": null, + "message": "backend source hash drifted: code:src-tauri/src/commands.rs#export_subtitles", + "scope": "controls" + }, + { + "code": "backend-source-hash-mismatch", + "candidateId": null, + "message": "backend source hash drifted: code:src-tauri/src/commands.rs#get_default_project_dir", + "scope": "controls" + }, + { + "code": "backend-source-hash-mismatch", + "candidateId": null, + "message": "backend source hash drifted: code:src-tauri/src/commands.rs#project_new", + "scope": "controls" + }, + { + "code": "backend-source-hash-mismatch", + "candidateId": null, + "message": "backend source hash drifted: code:src-tauri/src/commands.rs#project_open", + "scope": "controls" + }, + { + "code": "backend-source-hash-mismatch", + "candidateId": null, + "message": "backend source hash drifted: code:src-tauri/src/commands.rs#redo", + "scope": "controls" + }, + { + "code": "backend-source-hash-mismatch", + "candidateId": null, + "message": "backend source hash drifted: code:src-tauri/src/commands.rs#undo", + "scope": "controls" + }, + { + "code": "backend-source-hash-mismatch", + "candidateId": null, + "message": "backend source hash drifted: code:src-tauri/src/export.rs#cancel_export", + "scope": "controls" + }, + { + "code": "backend-source-hash-mismatch", + "candidateId": null, + "message": "backend source hash drifted: code:src-tauri/src/export.rs#export_video", + "scope": "controls" + }, + { + "code": "backend-source-hash-mismatch", + "candidateId": null, + "message": "backend source hash drifted: code:src-tauri/src/library.rs#library_categorize", + "scope": "controls" + }, + { + "code": "backend-source-hash-mismatch", + "candidateId": null, + "message": "backend source hash drifted: code:src-tauri/src/library.rs#library_import_to_project", + "scope": "controls" + }, + { + "code": "backend-source-hash-mismatch", + "candidateId": null, + "message": "backend source hash drifted: code:src-tauri/src/library.rs#library_unfavorite", + "scope": "controls" + }, + { + "code": "backend-source-hash-mismatch", + "candidateId": null, + "message": "backend source hash drifted: code:src-tauri/src/media.rs#extract_audio", + "scope": "controls" + }, + { + "code": "backend-source-hash-mismatch", + "candidateId": null, + "message": "backend source hash drifted: code:src-tauri/src/media.rs#import_folder", + "scope": "controls" + }, + { + "code": "backend-source-hash-mismatch", + "candidateId": null, + "message": "backend source hash drifted: code:src-tauri/src/media.rs#import_media", + "scope": "controls" + }, + { + "code": "backend-source-hash-mismatch", + "candidateId": null, + "message": "backend source hash drifted: code:src-tauri/src/media.rs#preload_media", + "scope": "controls" + }, + { + "code": "backend-source-hash-mismatch", + "candidateId": null, + "message": "backend source hash drifted: code:src-tauri/src/media.rs#relink_media", + "scope": "controls" + }, + { + "code": "backend-source-hash-mismatch", + "candidateId": null, + "message": "backend source hash drifted: code:src-tauri/src/media.rs#toggle_favorite", + "scope": "controls" + }, + { + "code": "backend-source-hash-mismatch", + "candidateId": null, + "message": "backend source hash drifted: code:src-tauri/src/render.rs#capture_frame_to_media", + "scope": "controls" + }, + { + "code": "backend-source-hash-mismatch", + "candidateId": null, + "message": "backend source hash drifted: code:src-tauri/src/search.rs#download_search_model", + "scope": "controls" + }, + { + "code": "backend-source-hash-mismatch", + "candidateId": null, + "message": "backend source hash drifted: code:src-tauri/src/search.rs#search_index_start", + "scope": "controls" + }, + { + "code": "backend-source-hash-mismatch", + "candidateId": null, + "message": "backend source hash drifted: code:src-tauri/src/search.rs#search_query", + "scope": "controls" + }, + { + "code": "backend-source-hash-mismatch", + "candidateId": null, + "message": "backend source hash drifted: code:src-tauri/src/secret.rs#secret_delete", + "scope": "controls" + }, + { + "code": "backend-source-hash-mismatch", + "candidateId": null, + "message": "backend source hash drifted: code:src-tauri/src/secret.rs#secret_load", + "scope": "controls" + }, + { + "code": "backend-source-hash-mismatch", + "candidateId": null, + "message": "backend source hash drifted: code:src-tauri/src/secret.rs#secret_save", + "scope": "controls" + }, + { + "code": "backend-source-hash-mismatch", + "candidateId": null, + "message": "backend source hash drifted: code:src-tauri/src/transcribe.rs#download_transcribe_model", + "scope": "controls" + }, + { + "code": "backend-source-hash-mismatch", + "candidateId": null, + "message": "backend source hash drifted: code:src-tauri/src/transcribe.rs#transcribe_model_status", + "scope": "controls" + }, + { + "code": "backend-source-hash-mismatch", + "candidateId": null, + "message": "backend source hash drifted: code:web/src/components/agent/AgentPanel.tsx#AgentPanel", + "scope": "controls" + }, + { + "code": "backend-source-hash-mismatch", + "candidateId": null, + "message": "backend source hash drifted: code:web/src/components/agent/AgentPanel.tsx#cancel", + "scope": "controls" + }, + { + "code": "backend-source-hash-mismatch", + "candidateId": null, + "message": "backend source hash drifted: code:web/src/components/home/HomeView.tsx#HomeView", + "scope": "controls" + }, + { + "code": "backend-source-hash-mismatch", + "candidateId": null, + "message": "backend source hash drifted: code:web/src/components/home/HomeView.tsx#ProjectLauncher", + "scope": "controls" + }, + { + "code": "backend-source-hash-mismatch", + "candidateId": null, + "message": "backend source hash drifted: code:web/src/components/inspector/Inspector.tsx#ClipInspector", + "scope": "controls" + }, + { + "code": "backend-source-hash-mismatch", + "candidateId": null, + "message": "backend source hash drifted: code:web/src/components/inspector/Inspector.tsx#CropSection", + "scope": "controls" + }, + { + "code": "backend-source-hash-mismatch", + "candidateId": null, + "message": "backend source hash drifted: code:web/src/components/inspector/Inspector.tsx#Inspector", + "scope": "controls" + }, + { + "code": "backend-source-hash-mismatch", + "candidateId": null, + "message": "backend source hash drifted: code:web/src/components/inspector/Inspector.tsx#KeyframeRowControls", + "scope": "controls" + }, + { + "code": "backend-source-hash-mismatch", + "candidateId": null, + "message": "backend source hash drifted: code:web/src/components/inspector/Inspector.tsx#applyCropPreset", + "scope": "controls" + }, + { + "code": "backend-source-hash-mismatch", + "candidateId": null, + "message": "backend source hash drifted: code:web/src/components/media/MediaPanel.tsx#MediaCard", + "scope": "controls" + }, + { + "code": "backend-source-hash-mismatch", + "candidateId": null, + "message": "backend source hash drifted: code:web/src/components/media/MediaPanel.tsx#MediaFavoriteButton", + "scope": "controls" + }, + { + "code": "backend-source-hash-mismatch", + "candidateId": null, + "message": "backend source hash drifted: code:web/src/components/media/MediaPanel.tsx#MediaPanel", + "scope": "controls" + }, + { + "code": "backend-source-hash-mismatch", + "candidateId": null, + "message": "backend source hash drifted: code:web/src/components/media/MediaPanel.tsx#onExtractAudio", + "scope": "controls" + }, + { + "code": "backend-source-hash-mismatch", + "candidateId": null, + "message": "backend source hash drifted: code:web/src/components/media/MediaSearch.tsx#FileCard", + "scope": "controls" + }, + { + "code": "backend-source-hash-mismatch", + "candidateId": null, + "message": "backend source hash drifted: code:web/src/components/media/MediaSearch.tsx#MediaSearchResults", + "scope": "controls" + }, + { + "code": "backend-source-hash-mismatch", + "candidateId": null, + "message": "backend source hash drifted: code:web/src/components/media/MediaSearch.tsx#MomentCard", + "scope": "controls" + }, + { + "code": "backend-source-hash-mismatch", + "candidateId": null, + "message": "backend source hash drifted: code:web/src/components/media/MediaSearch.tsx#SpokenRow", + "scope": "controls" + }, + { + "code": "backend-source-hash-mismatch", + "candidateId": null, + "message": "backend source hash drifted: code:web/src/components/media/MediaSearch.tsx#onDownload", + "scope": "controls" + }, + { + "code": "backend-source-hash-mismatch", + "candidateId": null, + "message": "backend source hash drifted: code:web/src/components/media/MediaSearch.tsx#onIndex", + "scope": "controls" + }, + { + "code": "backend-source-hash-mismatch", + "candidateId": null, + "message": "backend source hash drifted: code:web/src/components/media/MediaTabBar.tsx#MediaTabBar", + "scope": "controls" + }, + { + "code": "backend-source-hash-mismatch", + "candidateId": null, + "message": "backend source hash drifted: code:web/src/components/media/SoundLibraryTab.tsx#SoundLibraryTab", + "scope": "controls" + }, + { + "code": "backend-source-hash-mismatch", + "candidateId": null, + "message": "backend source hash drifted: code:web/src/components/preview/Preview.tsx#BadgeMenu", + "scope": "controls" + }, + { + "code": "backend-source-hash-mismatch", + "candidateId": null, + "message": "backend source hash drifted: code:web/src/components/preview/Preview.tsx#Preview", + "scope": "controls" + }, + { + "code": "backend-source-hash-mismatch", + "candidateId": null, + "message": "backend source hash drifted: code:web/src/components/preview/Preview.tsx#PreviewTabs", + "scope": "controls" + }, + { + "code": "backend-source-hash-mismatch", + "candidateId": null, + "message": "backend source hash drifted: code:web/src/components/preview/Preview.tsx#ScrubBar", + "scope": "controls" + }, + { + "code": "backend-source-hash-mismatch", + "candidateId": null, + "message": "backend source hash drifted: code:web/src/components/preview/Preview.tsx#captureFrame", + "scope": "controls" + }, + { + "code": "backend-source-hash-mismatch", + "candidateId": null, + "message": "backend source hash drifted: code:web/src/components/settings/SettingsView.tsx#AiPane", + "scope": "controls" + }, + { + "code": "backend-source-hash-mismatch", + "candidateId": null, + "message": "backend source hash drifted: code:web/src/components/settings/SettingsView.tsx#ImportPane", + "scope": "controls" + }, + { + "code": "backend-source-hash-mismatch", + "candidateId": null, + "message": "backend source hash drifted: code:web/src/components/settings/SettingsView.tsx#McpPane", + "scope": "controls" + }, + { + "code": "backend-source-hash-mismatch", + "candidateId": null, + "message": "backend source hash drifted: code:web/src/components/settings/SettingsView.tsx#SettingsView", + "scope": "controls" + }, + { + "code": "backend-source-hash-mismatch", + "candidateId": null, + "message": "backend source hash drifted: code:web/src/components/shell/SplitPane.tsx#SplitPane", + "scope": "controls" + }, + { + "code": "backend-source-hash-mismatch", + "candidateId": null, + "message": "backend source hash drifted: code:web/src/components/shell/TitleBar.tsx#INTERCHANGE_FORMATS", + "scope": "controls" + }, + { + "code": "backend-source-hash-mismatch", + "candidateId": null, + "message": "backend source hash drifted: code:web/src/components/shell/TitleBar.tsx#TitleBar", + "scope": "controls" + }, + { + "code": "backend-source-hash-mismatch", + "candidateId": null, + "message": "backend source hash drifted: code:web/src/components/shell/TitleBar.tsx#onExportSubtitles", + "scope": "controls" + }, + { + "code": "backend-source-hash-mismatch", + "candidateId": null, + "message": "backend source hash drifted: code:web/src/components/shell/ViewMenu.tsx#ViewMenu", + "scope": "controls" + }, + { + "code": "backend-source-hash-mismatch", + "candidateId": null, + "message": "backend source hash drifted: code:web/src/components/timeline/TimelineContainer.tsx#TimelineContainer", + "scope": "controls" + }, + { + "code": "backend-source-hash-mismatch", + "candidateId": null, + "message": "backend source hash drifted: code:web/src/components/timeline/TimelineContainer.tsx#onMediaDragOver", + "scope": "controls" + }, + { + "code": "backend-source-hash-mismatch", + "candidateId": null, + "message": "backend source hash drifted: code:web/src/components/timeline/TimelineContainer.tsx#onPointerDown", + "scope": "controls" + }, + { + "code": "backend-source-hash-mismatch", + "candidateId": null, + "message": "backend source hash drifted: code:web/src/components/timeline/TimelineContainer.tsx#volumeKeyframeMenuItems", + "scope": "controls" + }, + { + "code": "backend-source-hash-mismatch", + "candidateId": null, + "message": "backend source hash drifted: code:web/src/components/ui/PanelShell.tsx#PanelShell", + "scope": "controls" + }, + { + "code": "backend-source-hash-mismatch", + "candidateId": null, + "message": "backend source hash drifted: code:web/src/lib/api.ts#accountLogin", + "scope": "controls" + }, + { + "code": "backend-source-hash-mismatch", + "candidateId": null, + "message": "backend source hash drifted: code:web/src/lib/api.ts#accountLogout", + "scope": "controls" + }, + { + "code": "backend-source-hash-mismatch", + "candidateId": null, + "message": "backend source hash drifted: code:web/src/lib/api.ts#accountSetBackendUrl", + "scope": "controls" + }, + { + "code": "backend-source-hash-mismatch", + "candidateId": null, + "message": "backend source hash drifted: code:web/src/lib/api.ts#cancelExport", + "scope": "controls" + }, + { + "code": "backend-source-hash-mismatch", + "candidateId": null, + "message": "backend source hash drifted: code:web/src/lib/api.ts#captureFrameToMedia", + "scope": "controls" + }, + { + "code": "backend-source-hash-mismatch", + "candidateId": null, + "message": "backend source hash drifted: code:web/src/lib/api.ts#chatCancel", + "scope": "controls" + }, + { + "code": "backend-source-hash-mismatch", + "candidateId": null, + "message": "backend source hash drifted: code:web/src/lib/api.ts#chatSend", + "scope": "controls" + }, + { + "code": "backend-source-hash-mismatch", + "candidateId": null, + "message": "backend source hash drifted: code:web/src/lib/api.ts#downloadSearchModel", + "scope": "controls" + }, + { + "code": "backend-source-hash-mismatch", + "candidateId": null, + "message": "backend source hash drifted: code:web/src/lib/api.ts#downloadTranscribeModel", + "scope": "controls" + }, + { + "code": "backend-source-hash-mismatch", + "candidateId": null, + "message": "backend source hash drifted: code:web/src/lib/api.ts#editApply", + "scope": "controls" + }, + { + "code": "backend-source-hash-mismatch", + "candidateId": null, + "message": "backend source hash drifted: code:web/src/lib/api.ts#exportSubtitles", + "scope": "controls" + }, + { + "code": "backend-source-hash-mismatch", + "candidateId": null, + "message": "backend source hash drifted: code:web/src/lib/api.ts#exportVideo", + "scope": "controls" + }, + { + "code": "backend-source-hash-mismatch", + "candidateId": null, + "message": "backend source hash drifted: code:web/src/lib/api.ts#extractAudio", + "scope": "controls" + }, + { + "code": "backend-source-hash-mismatch", + "candidateId": null, + "message": "backend source hash drifted: code:web/src/lib/api.ts#generateCaptions", + "scope": "controls" + }, + { + "code": "backend-source-hash-mismatch", + "candidateId": null, + "message": "backend source hash drifted: code:web/src/lib/api.ts#getDefaultProjectDir", + "scope": "controls" + }, + { + "code": "backend-source-hash-mismatch", + "candidateId": null, + "message": "backend source hash drifted: code:web/src/lib/api.ts#importFolder", + "scope": "controls" + }, + { + "code": "backend-source-hash-mismatch", + "candidateId": null, + "message": "backend source hash drifted: code:web/src/lib/api.ts#importMedia", + "scope": "controls" + }, + { + "code": "backend-source-hash-mismatch", + "candidateId": null, + "message": "backend source hash drifted: code:web/src/lib/api.ts#preloadMedia", + "scope": "controls" + }, + { + "code": "backend-source-hash-mismatch", + "candidateId": null, + "message": "backend source hash drifted: code:web/src/lib/api.ts#projectNew", + "scope": "controls" + }, + { + "code": "backend-source-hash-mismatch", + "candidateId": null, + "message": "backend source hash drifted: code:web/src/lib/api.ts#projectOpen", + "scope": "controls" + }, + { + "code": "backend-source-hash-mismatch", + "candidateId": null, + "message": "backend source hash drifted: code:web/src/lib/api.ts#redo", + "scope": "controls" + }, + { + "code": "backend-source-hash-mismatch", + "candidateId": null, + "message": "backend source hash drifted: code:web/src/lib/api.ts#relinkMedia", + "scope": "controls" + }, + { + "code": "backend-source-hash-mismatch", + "candidateId": null, + "message": "backend source hash drifted: code:web/src/lib/api.ts#searchIndexStart", + "scope": "controls" + }, + { + "code": "backend-source-hash-mismatch", + "candidateId": null, + "message": "backend source hash drifted: code:web/src/lib/api.ts#searchQuery", + "scope": "controls" + }, + { + "code": "backend-source-hash-mismatch", + "candidateId": null, + "message": "backend source hash drifted: code:web/src/lib/api.ts#secretDelete", + "scope": "controls" + }, + { + "code": "backend-source-hash-mismatch", + "candidateId": null, + "message": "backend source hash drifted: code:web/src/lib/api.ts#secretLoad", + "scope": "controls" + }, + { + "code": "backend-source-hash-mismatch", + "candidateId": null, + "message": "backend source hash drifted: code:web/src/lib/api.ts#secretSave", + "scope": "controls" + }, + { + "code": "backend-source-hash-mismatch", + "candidateId": null, + "message": "backend source hash drifted: code:web/src/lib/api.ts#toggleFavorite", + "scope": "controls" + }, + { + "code": "backend-source-hash-mismatch", + "candidateId": null, + "message": "backend source hash drifted: code:web/src/lib/api.ts#transcribeModelStatus", + "scope": "controls" + }, + { + "code": "backend-source-hash-mismatch", + "candidateId": null, + "message": "backend source hash drifted: code:web/src/lib/api.ts#undo", + "scope": "controls" + }, + { + "code": "backend-source-hash-mismatch", + "candidateId": null, + "message": "backend source hash drifted: code:web/src/store/editActions.ts#addMediaToTimeline", + "scope": "controls" + }, + { + "code": "backend-source-hash-mismatch", + "candidateId": null, + "message": "backend source hash drifted: code:web/src/store/editActions.ts#addTextClip", + "scope": "controls" + }, + { + "code": "backend-source-hash-mismatch", + "candidateId": null, + "message": "backend source hash drifted: code:web/src/store/editActions.ts#applyAndRefresh", + "scope": "controls" + }, + { + "code": "backend-source-hash-mismatch", + "candidateId": null, + "message": "backend source hash drifted: code:web/src/store/editActions.ts#cancelSaveAsMedia", + "scope": "controls" + }, + { + "code": "backend-source-hash-mismatch", + "candidateId": null, + "message": "backend source hash drifted: code:web/src/store/editActions.ts#copyClips", + "scope": "controls" + }, + { + "code": "backend-source-hash-mismatch", + "candidateId": null, + "message": "backend source hash drifted: code:web/src/store/editActions.ts#generateCaptions", + "scope": "controls" + }, + { + "code": "backend-source-hash-mismatch", + "candidateId": null, + "message": "backend source hash drifted: code:web/src/store/editActions.ts#insertClips", + "scope": "controls" + }, + { + "code": "backend-source-hash-mismatch", + "candidateId": null, + "message": "backend source hash drifted: code:web/src/store/editActions.ts#moveKeyframe", + "scope": "controls" + }, + { + "code": "backend-source-hash-mismatch", + "candidateId": null, + "message": "backend source hash drifted: code:web/src/store/editActions.ts#redo", + "scope": "controls" + }, + { + "code": "backend-source-hash-mismatch", + "candidateId": null, + "message": "backend source hash drifted: code:web/src/store/editActions.ts#removeKeyframe", + "scope": "controls" + }, + { + "code": "backend-source-hash-mismatch", + "candidateId": null, + "message": "backend source hash drifted: code:web/src/store/editActions.ts#resetTransform", + "scope": "controls" + }, + { + "code": "backend-source-hash-mismatch", + "candidateId": null, + "message": "backend source hash drifted: code:web/src/store/editActions.ts#saveMarkedRangeAsMedia", + "scope": "controls" + }, + { + "code": "backend-source-hash-mismatch", + "candidateId": null, + "message": "backend source hash drifted: code:web/src/store/editActions.ts#setChromaKey", + "scope": "controls" + }, + { + "code": "backend-source-hash-mismatch", + "candidateId": null, + "message": "backend source hash drifted: code:web/src/store/editActions.ts#setClipProperties", + "scope": "controls" + }, + { + "code": "backend-source-hash-mismatch", + "candidateId": null, + "message": "backend source hash drifted: code:web/src/store/editActions.ts#setColorGrade", + "scope": "controls" + }, + { + "code": "backend-source-hash-mismatch", + "candidateId": null, + "message": "backend source hash drifted: code:web/src/store/editActions.ts#setKeyframeInterpolation", + "scope": "controls" + }, + { + "code": "backend-source-hash-mismatch", + "candidateId": null, + "message": "backend source hash drifted: code:web/src/store/editActions.ts#setKeyframes", + "scope": "controls" + }, + { + "code": "backend-source-hash-mismatch", + "candidateId": null, + "message": "backend source hash drifted: code:web/src/store/editActions.ts#setMasks", + "scope": "controls" + }, + { + "code": "backend-source-hash-mismatch", + "candidateId": null, + "message": "backend source hash drifted: code:web/src/store/editActions.ts#setTimelineSettings", + "scope": "controls" + }, + { + "code": "backend-source-hash-mismatch", + "candidateId": null, + "message": "backend source hash drifted: code:web/src/store/editActions.ts#setTrackProps", + "scope": "controls" + }, + { + "code": "backend-source-hash-mismatch", + "candidateId": null, + "message": "backend source hash drifted: code:web/src/store/editActions.ts#splitAtPlayhead", + "scope": "controls" + }, + { + "code": "backend-source-hash-mismatch", + "candidateId": null, + "message": "backend source hash drifted: code:web/src/store/editActions.ts#splitClip", + "scope": "controls" + }, + { + "code": "backend-source-hash-mismatch", + "candidateId": null, + "message": "backend source hash drifted: code:web/src/store/editActions.ts#stampKeyframe", + "scope": "controls" + }, + { + "code": "backend-source-hash-mismatch", + "candidateId": null, + "message": "backend source hash drifted: code:web/src/store/editActions.ts#swapMedia", + "scope": "controls" + }, + { + "code": "backend-source-hash-mismatch", + "candidateId": null, + "message": "backend source hash drifted: code:web/src/store/editActions.ts#swapTracks", + "scope": "controls" + }, + { + "code": "backend-source-hash-mismatch", + "candidateId": null, + "message": "backend source hash drifted: code:web/src/store/editActions.ts#trimEndToPlayhead", + "scope": "controls" + }, + { + "code": "backend-source-hash-mismatch", + "candidateId": null, + "message": "backend source hash drifted: code:web/src/store/editActions.ts#trimStartToPlayhead", + "scope": "controls" + }, + { + "code": "backend-source-hash-mismatch", + "candidateId": null, + "message": "backend source hash drifted: code:web/src/store/editActions.ts#undo", + "scope": "controls" + }, + { + "code": "backend-source-hash-mismatch", + "candidateId": null, + "message": "backend source hash drifted: code:web/src/store/editActions.ts#upsertKeyframe", + "scope": "controls" + }, + { + "code": "backend-source-hash-mismatch", + "candidateId": null, + "message": "backend source hash drifted: code:web/src/store/projectActions.ts#newProjectAndEnter", + "scope": "controls" + }, + { + "code": "backend-source-hash-mismatch", + "candidateId": null, + "message": "backend source hash drifted: code:web/src/store/projectActions.ts#openProjectPath", + "scope": "controls" + }, + { + "code": "backend-source-hash-mismatch", + "candidateId": null, + "message": "backend source hash drifted: code:web/src/store/projectActions.ts#openProjectViaDialog", + "scope": "controls" + }, + { + "code": "candidate-source-signal-missing", + "candidateId": "control-70d50e82dbf11502", + "message": "control is absent at web/src/components/agent/AgentPanel.tsx:146:9", + "scope": "controls" + }, + { + "code": "candidate-field-drift", + "candidateId": "control-fc7930fda5a1a124", + "message": "current source changed candidate field line", + "scope": "controls" + }, + { + "code": "candidate-field-drift", + "candidateId": "control-9276ea9d0a1578bb", + "message": "current source changed candidate field line", + "scope": "controls" + }, + { + "code": "candidate-field-drift", + "candidateId": "control-14c7a2b773381f0d", + "message": "current source changed candidate field line", + "scope": "controls" + }, + { + "code": "candidate-field-drift", + "candidateId": "control-1caee0ff811948b1", + "message": "current source changed candidate field line", + "scope": "controls" + }, + { + "code": "candidate-field-drift", + "candidateId": "control-7a3ce16dc3806574", + "message": "current source changed candidate field line", + "scope": "controls" + }, + { + "code": "candidate-field-drift", + "candidateId": "control-f3d4846ff119b22c", + "message": "current source changed candidate field line", + "scope": "controls" + }, + { + "code": "candidate-source-signal-missing", + "candidateId": "control-e2d0f1ed3415ea45", + "message": "control is absent at web/src/components/home/HomeView.tsx:102:9", + "scope": "controls" + }, + { + "code": "candidate-source-signal-missing", + "candidateId": "control-ef78873f98fcab84", + "message": "control is absent at web/src/components/home/HomeView.tsx:103:9", + "scope": "controls" + }, + { + "code": "candidate-field-drift", + "candidateId": "control-f4a6b4f8789ea013", + "message": "current source changed candidate field line", + "scope": "controls" + }, + { + "code": "candidate-field-drift", + "candidateId": "control-810a7d793fcd8323", + "message": "current source changed candidate field line", + "scope": "controls" + }, + { + "code": "candidate-field-drift", + "candidateId": "control-d1af8af15aefbf47", + "message": "current source changed candidate field line", + "scope": "controls" + }, + { + "code": "candidate-field-drift", + "candidateId": "control-d1af8af15aefbf47", + "message": "current source changed candidate field disabled", + "scope": "controls" + }, + { + "code": "candidate-source-signal-missing", + "candidateId": "control-575978f9bced5959", + "message": "control is absent at web/src/components/home/HomeView.tsx:234:11", + "scope": "controls" + }, + { + "code": "candidate-source-signal-missing", + "candidateId": "control-2121d7b9fdc279b9", + "message": "control is absent at web/src/components/home/HomeView.tsx:235:11", + "scope": "controls" + }, + { + "code": "candidate-field-drift", + "candidateId": "control-5b33b5690caa424a", + "message": "current source changed candidate field line", + "scope": "controls" + }, + { + "code": "candidate-field-drift", + "candidateId": "control-5b33b5690caa424a", + "message": "current source changed candidate field disabled", + "scope": "controls" + }, + { + "code": "candidate-field-drift", + "candidateId": "control-acd6238c08e790cc", + "message": "current source changed candidate field line", + "scope": "controls" + }, + { + "code": "candidate-source-signal-missing", + "candidateId": "control-1d62091a642fb0da", + "message": "control is absent at web/src/components/home/HomeView.tsx:323:7", + "scope": "controls" + }, + { + "code": "candidate-source-signal-missing", + "candidateId": "control-9697b53d4d2cf1ca", + "message": "control is absent at web/src/components/home/HomeView.tsx:351:11", + "scope": "controls" + }, + { + "code": "candidate-source-signal-missing", + "candidateId": "control-74f414d717baaed3", + "message": "control is absent at web/src/components/home/HomeView.tsx:421:9", + "scope": "controls" + }, + { + "code": "candidate-field-drift", + "candidateId": "control-ab109c708bb0efbf", + "message": "current source changed candidate field line", + "scope": "controls" + }, + { + "code": "candidate-field-drift", + "candidateId": "control-ab109c708bb0efbf", + "message": "current source changed candidate field disabled", + "scope": "controls" + }, + { + "code": "candidate-source-signal-missing", + "candidateId": "control-d2db7352af75d157", + "message": "control is absent at web/src/components/home/HomeView.tsx:446:5", + "scope": "controls" + }, + { + "code": "candidate-source-signal-missing", + "candidateId": "control-ec1cd7a2d49bb97a", + "message": "control is absent at web/src/components/home/HomeView.tsx:521:9", + "scope": "controls" + }, + { + "code": "candidate-field-drift", + "candidateId": "control-db3a9892a919460f", + "message": "current source changed candidate field line", + "scope": "controls" + }, + { + "code": "candidate-field-drift", + "candidateId": "control-287044a170afa328", + "message": "current source changed candidate field line", + "scope": "controls" + }, + { + "code": "candidate-field-drift", + "candidateId": "control-cae48d1cbb87b634", + "message": "current source changed candidate field line", + "scope": "controls" + }, + { + "code": "candidate-field-drift", + "candidateId": "control-e99f1fdf89f06557", + "message": "current source changed candidate field line", + "scope": "controls" + }, + { + "code": "candidate-field-drift", + "candidateId": "control-8d29dda7bc88e53e", + "message": "current source changed candidate field line", + "scope": "controls" + }, + { + "code": "candidate-source-signal-missing", + "candidateId": "control-ff45bd17a9877c19", + "message": "control is absent at web/src/components/inspector/Inspector.tsx:449:13", + "scope": "controls" + }, + { + "code": "candidate-field-drift", + "candidateId": "control-90b6142c9b28af54", + "message": "current source changed candidate field line", + "scope": "controls" + }, + { + "code": "candidate-field-drift", + "candidateId": "control-96a5dfea7df05ba9", + "message": "current source changed candidate field line", + "scope": "controls" + }, + { + "code": "candidate-field-drift", + "candidateId": "control-08376197cc04d5c7", + "message": "current source changed candidate field line", + "scope": "controls" + }, + { + "code": "candidate-field-drift", + "candidateId": "control-081219e1fb457cfe", + "message": "current source changed candidate field line", + "scope": "controls" + }, + { + "code": "candidate-field-drift", + "candidateId": "control-16114827f9ea0a12", + "message": "current source changed candidate field line", + "scope": "controls" + }, + { + "code": "candidate-field-drift", + "candidateId": "control-bcede743dbdd39c4", + "message": "current source changed candidate field line", + "scope": "controls" + }, + { + "code": "candidate-field-drift", + "candidateId": "control-239a5889cdb86de3", + "message": "current source changed candidate field line", + "scope": "controls" + }, + { + "code": "candidate-field-drift", + "candidateId": "control-cd756dccc56343eb", + "message": "current source changed candidate field line", + "scope": "controls" + }, + { + "code": "candidate-field-drift", + "candidateId": "control-11ebd75be0f93008", + "message": "current source changed candidate field line", + "scope": "controls" + }, + { + "code": "candidate-field-drift", + "candidateId": "control-f8d159f2ffe6aa33", + "message": "current source changed candidate field line", + "scope": "controls" + }, + { + "code": "candidate-field-drift", + "candidateId": "control-23078cfa22cdc9a5", + "message": "current source changed candidate field line", + "scope": "controls" + }, + { + "code": "candidate-field-drift", + "candidateId": "control-5c3e9ca9c1713a09", + "message": "current source changed candidate field line", + "scope": "controls" + }, + { + "code": "candidate-field-drift", + "candidateId": "control-8e3c200b9433f78e", + "message": "current source changed candidate field line", + "scope": "controls" + }, + { + "code": "candidate-field-drift", + "candidateId": "control-c188975bcfe4ec9b", + "message": "current source changed candidate field line", + "scope": "controls" + }, + { + "code": "candidate-field-drift", + "candidateId": "control-1e99a2bcbd62178e", + "message": "current source changed candidate field line", + "scope": "controls" + }, + { + "code": "candidate-field-drift", + "candidateId": "control-870eaf3988273d41", + "message": "current source changed candidate field line", + "scope": "controls" + }, + { + "code": "candidate-field-drift", + "candidateId": "control-82d3756769e4c60c", + "message": "current source changed candidate field line", + "scope": "controls" + }, + { + "code": "candidate-field-drift", + "candidateId": "control-eda8168f82218100", + "message": "current source changed candidate field line", + "scope": "controls" + }, + { + "code": "candidate-field-drift", + "candidateId": "control-483d3b87f9c031f5", + "message": "current source changed candidate field line", + "scope": "controls" + }, + { + "code": "candidate-field-drift", + "candidateId": "control-d613004ab99583b3", + "message": "current source changed candidate field line", + "scope": "controls" + }, + { + "code": "candidate-field-drift", + "candidateId": "control-be09ebcb1166277c", + "message": "current source changed candidate field line", + "scope": "controls" + }, + { + "code": "candidate-field-drift", + "candidateId": "control-d9ed469bc367deec", + "message": "current source changed candidate field line", + "scope": "controls" + }, + { + "code": "candidate-field-drift", + "candidateId": "control-e5ff7853d43491f6", + "message": "current source changed candidate field line", + "scope": "controls" + }, + { + "code": "candidate-field-drift", + "candidateId": "control-58ec257f601a9924", + "message": "current source changed candidate field line", + "scope": "controls" + }, + { + "code": "candidate-field-drift", + "candidateId": "control-d31487647c9706fc", + "message": "current source changed candidate field line", + "scope": "controls" + }, + { + "code": "candidate-field-drift", + "candidateId": "control-a250e4a089de997f", + "message": "current source changed candidate field line", + "scope": "controls" + }, + { + "code": "candidate-field-drift", + "candidateId": "control-40101f7c25e4854c", + "message": "current source changed candidate field line", + "scope": "controls" + }, + { + "code": "candidate-field-drift", + "candidateId": "control-10bd77bd011e8aaf", + "message": "current source changed candidate field line", + "scope": "controls" + }, + { + "code": "candidate-field-drift", + "candidateId": "control-ee79ef68fe15ab96", + "message": "current source changed candidate field line", + "scope": "controls" + }, + { + "code": "candidate-field-drift", + "candidateId": "control-8b0f987c8bf46a8a", + "message": "current source changed candidate field line", + "scope": "controls" + }, + { + "code": "candidate-field-drift", + "candidateId": "control-3f99a53815fd9130", + "message": "current source changed candidate field line", + "scope": "controls" + }, + { + "code": "candidate-field-drift", + "candidateId": "control-0c7a02c6dae89f87", + "message": "current source changed candidate field line", + "scope": "controls" + }, + { + "code": "candidate-field-drift", + "candidateId": "control-b77d7a7eb91fedfa", + "message": "current source changed candidate field line", + "scope": "controls" + }, + { + "code": "candidate-field-drift", + "candidateId": "control-fd4203443df4adcb", + "message": "current source changed candidate field line", + "scope": "controls" + }, + { + "code": "candidate-field-drift", + "candidateId": "control-18136ab8e305f7a7", + "message": "current source changed candidate field line", + "scope": "controls" + }, + { + "code": "candidate-field-drift", + "candidateId": "control-09ca7bab665f84ed", + "message": "current source changed candidate field line", + "scope": "controls" + }, + { + "code": "candidate-field-drift", + "candidateId": "control-9e9cf7fd25863d20", + "message": "current source changed candidate field line", + "scope": "controls" + }, + { + "code": "candidate-field-drift", + "candidateId": "control-47bf4ee974f14505", + "message": "current source changed candidate field line", + "scope": "controls" + }, + { + "code": "candidate-field-drift", + "candidateId": "control-4b61e7b97ade8478", + "message": "current source changed candidate field line", + "scope": "controls" + }, + { + "code": "candidate-field-drift", + "candidateId": "control-bc82a346093bae7c", + "message": "current source changed candidate field line", + "scope": "controls" + }, + { + "code": "candidate-field-drift", + "candidateId": "control-d60d607d7448918f", + "message": "current source changed candidate field line", + "scope": "controls" + }, + { + "code": "candidate-field-drift", + "candidateId": "control-7b85c2f53f12cc88", + "message": "current source changed candidate field line", + "scope": "controls" + }, + { + "code": "candidate-field-drift", + "candidateId": "control-6071e0b1bf8a62e3", + "message": "current source changed candidate field line", + "scope": "controls" + }, + { + "code": "candidate-field-drift", + "candidateId": "control-dc077f7003ebc75b", + "message": "current source changed candidate field line", + "scope": "controls" + }, + { + "code": "candidate-field-drift", + "candidateId": "control-74cf95e67ddd9208", + "message": "current source changed candidate field line", + "scope": "controls" + }, + { + "code": "candidate-field-drift", + "candidateId": "control-ab30577032b1dc4c", + "message": "current source changed candidate field line", + "scope": "controls" + }, + { + "code": "candidate-field-drift", + "candidateId": "control-fbc0598258d77d6a", + "message": "current source changed candidate field line", + "scope": "controls" + }, + { + "code": "candidate-field-drift", + "candidateId": "control-b2ddee9974c7b88f", + "message": "current source changed candidate field line", + "scope": "controls" + }, + { + "code": "candidate-field-drift", + "candidateId": "control-5409108a04e7cf1c", + "message": "current source changed candidate field line", + "scope": "controls" + }, + { + "code": "candidate-field-drift", + "candidateId": "control-874419d78bdb9c98", + "message": "current source changed candidate field line", + "scope": "controls" + }, + { + "code": "candidate-field-drift", + "candidateId": "control-f9d215c076164576", + "message": "current source changed candidate field line", + "scope": "controls" + }, + { + "code": "candidate-field-drift", + "candidateId": "control-b9f833d55c68cf40", + "message": "current source changed candidate field line", + "scope": "controls" + }, + { + "code": "candidate-field-drift", + "candidateId": "control-c283a16e1d1b190f", + "message": "current source changed candidate field line", + "scope": "controls" + }, + { + "code": "candidate-field-drift", + "candidateId": "control-fee61dd27bb05b04", + "message": "current source changed candidate field line", + "scope": "controls" + }, + { + "code": "candidate-field-drift", + "candidateId": "control-9ddccdace2f550dc", + "message": "current source changed candidate field line", + "scope": "controls" + }, + { + "code": "candidate-field-drift", + "candidateId": "control-d2d6cba7e151c478", + "message": "current source changed candidate field line", + "scope": "controls" + }, + { + "code": "candidate-field-drift", + "candidateId": "control-4f239be002df8094", + "message": "current source changed candidate field line", + "scope": "controls" + }, + { + "code": "candidate-field-drift", + "candidateId": "control-35bf776459532f2f", + "message": "current source changed candidate field line", + "scope": "controls" + }, + { + "code": "candidate-field-drift", + "candidateId": "control-8f804b393c015a5e", + "message": "current source changed candidate field line", + "scope": "controls" + }, + { + "code": "candidate-field-drift", + "candidateId": "control-e4b24808655562b1", + "message": "current source changed candidate field line", + "scope": "controls" + }, + { + "code": "candidate-field-drift", + "candidateId": "control-4bb077c70718eeda", + "message": "current source changed candidate field line", + "scope": "controls" + }, + { + "code": "candidate-source-signal-missing", + "candidateId": "control-56870ea51c57d201", + "message": "control is absent at web/src/components/media/MediaPanel.tsx:888:5", + "scope": "controls" + }, + { + "code": "candidate-field-drift", + "candidateId": "control-23954f3f7bc7fd96", + "message": "current source changed candidate field line", + "scope": "controls" + }, + { + "code": "candidate-field-drift", + "candidateId": "control-23e08673fb5d2f66", + "message": "current source changed candidate field line", + "scope": "controls" + }, + { + "code": "candidate-field-drift", + "candidateId": "control-421046c542a4e315", + "message": "current source changed candidate field line", + "scope": "controls" + }, + { + "code": "candidate-field-drift", + "candidateId": "control-12917008f22749ee", + "message": "current source changed candidate field line", + "scope": "controls" + }, + { + "code": "candidate-field-drift", + "candidateId": "control-4eccac72d0056b24", + "message": "current source changed candidate field line", + "scope": "controls" + }, + { + "code": "candidate-field-drift", + "candidateId": "control-fd319d3edab2c00d", + "message": "current source changed candidate field line", + "scope": "controls" + }, + { + "code": "candidate-field-drift", + "candidateId": "control-a98e8f89678dd470", + "message": "current source changed candidate field line", + "scope": "controls" + }, + { + "code": "candidate-field-drift", + "candidateId": "control-d79545153721a517", + "message": "current source changed candidate field line", + "scope": "controls" + }, + { + "code": "candidate-field-drift", + "candidateId": "control-5e42760fbbe049cc", + "message": "current source changed candidate field line", + "scope": "controls" + }, + { + "code": "candidate-field-drift", + "candidateId": "control-05ae62fc3145ea7e", + "message": "current source changed candidate field line", + "scope": "controls" + }, + { + "code": "candidate-field-drift", + "candidateId": "control-0ee3b0454ad2624a", + "message": "current source changed candidate field line", + "scope": "controls" + }, + { + "code": "candidate-field-drift", + "candidateId": "control-bb633f7e6c8e3fe2", + "message": "current source changed candidate field line", + "scope": "controls" + }, + { + "code": "candidate-field-drift", + "candidateId": "control-575921b92e2f3135", + "message": "current source changed candidate field line", + "scope": "controls" + }, + { + "code": "candidate-field-drift", + "candidateId": "control-baaf4052cb5bdc8e", + "message": "current source changed candidate field line", + "scope": "controls" + }, + { + "code": "candidate-field-drift", + "candidateId": "control-37ea2cd22696fd55", + "message": "current source changed candidate field line", + "scope": "controls" + }, + { + "code": "candidate-field-drift", + "candidateId": "control-c6f5ed51aa2b00c2", + "message": "current source changed candidate field line", + "scope": "controls" + }, + { + "code": "candidate-field-drift", + "candidateId": "control-9812bb3e84b940c5", + "message": "current source changed candidate field line", + "scope": "controls" + }, + { + "code": "candidate-field-drift", + "candidateId": "control-d3805a1e5b0f0237", + "message": "current source changed candidate field line", + "scope": "controls" + }, + { + "code": "candidate-field-drift", + "candidateId": "control-f87f5291a1a74dcc", + "message": "current source changed candidate field line", + "scope": "controls" + }, + { + "code": "candidate-source-signal-missing", + "candidateId": "control-200c9fd6ec3f0f35", + "message": "control is absent at web/src/components/preview/Preview.tsx:724:5", + "scope": "controls" + }, + { + "code": "candidate-field-drift", + "candidateId": "control-0d59d3bb27911a7c", + "message": "current source changed candidate field line", + "scope": "controls" + }, + { + "code": "candidate-field-drift", + "candidateId": "control-38902ec9a22f4dc1", + "message": "current source changed candidate field line", + "scope": "controls" + }, + { + "code": "candidate-field-drift", + "candidateId": "control-fc5baa4f457e8778", + "message": "current source changed candidate field line", + "scope": "controls" + }, + { + "code": "candidate-field-drift", + "candidateId": "control-ca0fb9e4faf6c987", + "message": "current source changed candidate field line", + "scope": "controls" + }, + { + "code": "candidate-field-drift", + "candidateId": "control-678b422a24a44a34", + "message": "current source changed candidate field line", + "scope": "controls" + }, + { + "code": "candidate-field-drift", + "candidateId": "control-6fcdff2d800c882d", + "message": "current source changed candidate field line", + "scope": "controls" + }, + { + "code": "candidate-field-drift", + "candidateId": "control-290b8eafa8851b2c", + "message": "current source changed candidate field line", + "scope": "controls" + }, + { + "code": "candidate-field-drift", + "candidateId": "control-b019554a5961fa7a", + "message": "current source changed candidate field line", + "scope": "controls" + }, + { + "code": "candidate-field-drift", + "candidateId": "control-323d35d24ceb5ab2", + "message": "current source changed candidate field line", + "scope": "controls" + }, + { + "code": "candidate-field-drift", + "candidateId": "control-1f8b140411e09e22", + "message": "current source changed candidate field line", + "scope": "controls" + }, + { + "code": "candidate-field-drift", + "candidateId": "control-a6a47152d4fc53e0", + "message": "current source changed candidate field line", + "scope": "controls" + }, + { + "code": "candidate-field-drift", + "candidateId": "control-fe4bfbcf3bec4054", + "message": "current source changed candidate field line", + "scope": "controls" + }, + { + "code": "candidate-field-drift", + "candidateId": "control-408c771762a48a6a", + "message": "current source changed candidate field line", + "scope": "controls" + }, + { + "code": "candidate-field-drift", + "candidateId": "control-c51942132f080af2", + "message": "current source changed candidate field line", + "scope": "controls" + }, + { + "code": "candidate-field-drift", + "candidateId": "control-66d58acb1e9d254b", + "message": "current source changed candidate field line", + "scope": "controls" + }, + { + "code": "candidate-field-drift", + "candidateId": "control-6c6ad1d93d3c1f6c", + "message": "current source changed candidate field line", + "scope": "controls" + }, + { + "code": "candidate-field-drift", + "candidateId": "control-bc7e07c8d75e1cd2", + "message": "current source changed candidate field line", + "scope": "controls" + }, + { + "code": "candidate-source-signal-missing", + "candidateId": "control-d88c7103e09bb382", + "message": "control is absent at web/src/components/shell/SplitPane.tsx:82:7", + "scope": "controls" + }, + { + "code": "candidate-field-drift", + "candidateId": "control-f52cc89817361a19", + "message": "current source changed candidate field line", + "scope": "controls" + }, + { + "code": "candidate-field-drift", + "candidateId": "control-4bda8f075e1f3a14", + "message": "current source changed candidate field line", + "scope": "controls" + }, + { + "code": "candidate-field-drift", + "candidateId": "control-ff132f94a8c87906", + "message": "current source changed candidate field line", + "scope": "controls" + }, + { + "code": "candidate-field-drift", + "candidateId": "control-d7ba227c6447e43e", + "message": "current source changed candidate field line", + "scope": "controls" + }, + { + "code": "candidate-field-drift", + "candidateId": "control-c035467e6746e570", + "message": "current source changed candidate field line", + "scope": "controls" + }, + { + "code": "candidate-field-drift", + "candidateId": "control-f54f4037ab7bffbe", + "message": "current source changed candidate field line", + "scope": "controls" + }, + { + "code": "candidate-field-drift", + "candidateId": "control-229710d0115f07bc", + "message": "current source changed candidate field line", + "scope": "controls" + }, + { + "code": "candidate-field-drift", + "candidateId": "control-02d1bf7fff7c1e3a", + "message": "current source changed candidate field line", + "scope": "controls" + }, + { + "code": "candidate-field-drift", + "candidateId": "control-0d98e5e5a0c417ed", + "message": "current source changed candidate field line", + "scope": "controls" + }, + { + "code": "candidate-field-drift", + "candidateId": "control-d826a0ad433703cb", + "message": "current source changed candidate field line", + "scope": "controls" + }, + { + "code": "candidate-field-drift", + "candidateId": "control-a2d1b5cb37952878", + "message": "current source changed candidate field line", + "scope": "controls" + }, + { + "code": "candidate-field-drift", + "candidateId": "control-d606f9c3adb8762a", + "message": "current source changed candidate field line", + "scope": "controls" + }, + { + "code": "candidate-field-drift", + "candidateId": "control-acd9e30dacf466b6", + "message": "current source changed candidate field line", + "scope": "controls" + }, + { + "code": "candidate-field-drift", + "candidateId": "control-fb7422e825128973", + "message": "current source changed candidate field line", + "scope": "controls" + }, + { + "code": "candidate-field-drift", + "candidateId": "control-c38719af10d24035", + "message": "current source changed candidate field line", + "scope": "controls" + }, + { + "code": "candidate-field-drift", + "candidateId": "control-c38719af10d24035", + "message": "current source changed candidate field disabled", + "scope": "controls" + }, + { + "code": "candidate-field-drift", + "candidateId": "control-224e0433b0aefb2a", + "message": "current source changed candidate field line", + "scope": "controls" + }, + { + "code": "candidate-field-drift", + "candidateId": "control-68307f78b80da87e", + "message": "current source changed candidate field line", + "scope": "controls" + }, + { + "code": "candidate-field-drift", + "candidateId": "control-54b7338ed4253568", + "message": "current source changed candidate field line", + "scope": "controls" + }, + { + "code": "candidate-field-drift", + "candidateId": "control-695aa63ff9c6c909", + "message": "current source changed candidate field line", + "scope": "controls" + }, + { + "code": "candidate-field-drift", + "candidateId": "control-788172a804cc0142", + "message": "current source changed candidate field line", + "scope": "controls" + }, + { + "code": "candidate-field-drift", + "candidateId": "control-55e45e308be653d6", + "message": "current source changed candidate field line", + "scope": "controls" + }, + { + "code": "candidate-field-drift", + "candidateId": "control-554409b9d0b8d6bf", + "message": "current source changed candidate field line", + "scope": "controls" + }, + { + "code": "candidate-source-signal-missing", + "candidateId": "control-bbc125bbbf2275f2", + "message": "control is absent at web/src/components/ui/PanelShell.tsx:22:5", + "scope": "controls" + }, + { + "code": "canonical-source-drift", + "candidateId": null, + "message": "canonical dirty checkout differs from captured source provenance", + "scope": "sources" + }, + { + "code": "identity-migration-not-two-stage-parent", + "candidateId": null, + "message": "migration revision must be current HEAD or its direct parent" + }, + { + "code": "implementation-plan-drift", + "candidateId": null, + "message": "implementation plan differs from authoritative records: docs/audit/2026-07-14/implementation-plans/data-safety-design.md" + }, + { + "code": "implementation-plan-drift", + "candidateId": null, + "message": "implementation plan differs from authoritative records: docs/audit/2026-07-14/implementation-plans/data-safety-implementation.md" + }, + { + "code": "implementation-plan-drift", + "candidateId": null, + "message": "implementation plan differs from authoritative records: docs/audit/2026-07-14/implementation-plans/home-shell-implementation.md" + }, + { + "code": "implementation-plan-drift", + "candidateId": null, + "message": "implementation plan differs from authoritative records: docs/audit/2026-07-14/implementation-plans/inspector-text-keyframes-implementation.md" + }, + { + "code": "implementation-plan-drift", + "candidateId": null, + "message": "implementation plan differs from authoritative records: docs/audit/2026-07-14/implementation-plans/agent-settings-generation-implementation.md" + }, + { + "code": "implementation-plan-drift", + "candidateId": null, + "message": "implementation plan differs from authoritative records: docs/audit/2026-07-14/implementation-plans/accessibility-polish-implementation.md" + }, + { + "code": "plan-ownership-drift", + "candidateId": null, + "message": "plan-ownership.json differs from reviewed record ownership and clustered slices" + }, + { + "code": "completion-ledger-drift", + "candidateId": null, + "message": "completion-ledger.json differs from its current hashed inputs" + }, + { + "code": "completion-report-drift", + "candidateId": null, + "message": "completion-report.md differs from completion ledger" + } + ] } diff --git a/docs/audit/2026-07-14/implementation-plans/accessibility-polish-implementation.md b/docs/audit/2026-07-14/implementation-plans/accessibility-polish-implementation.md index 7e7413c3..31f42d1d 100644 --- a/docs/audit/2026-07-14/implementation-plans/accessibility-polish-implementation.md +++ b/docs/audit/2026-07-14/implementation-plans/accessibility-polish-implementation.md @@ -32,34 +32,44 @@ - Add token, semantic-role, keyboard-focus, state, privacy, and visual assertions for every named surface; the affected lint, typecheck, and web suites must pass. - Exercise the named surface with keyboard and browser or packaged-app visual inspection, and retain exact accessibility or screenshot evidence before reclassification. -- [ ] **Step 1: Write or extend every reviewed owning test** +- [x] **Step 1: Write or extend every reviewed owning test** - `src-tauri/tests/feedback.rs#submission_includes_app_and_os_version` (reviewed-planned) — Reviewed planned test belongs in this tracked owning runner beside the mapped product boundary. Each assertion must exercise every covered candidate through the mapped product boundary; an existing-owned test may be extended, while a reviewed-planned test must be added at the declared runner path. -- [ ] **Step 2: Run all focused tests and verify RED** +- [x] **Step 2: Run all focused tests and verify RED** - Run: `cargo test -p opentake-tauri --test feedback submission_includes_app_and_os_version -- --exact` Expected: FAIL because one or more of the 1 candidate-bound contracts are not yet satisfied. -- [ ] **Step 3: Implement the minimal vertical slice** +- [x] **Step 3: Implement the minimal vertical slice** Modify only `src-tauri/src/feedback.rs#submit_feedback`, `docs/architecture/MODULE-PORT-MAP.md` as required to satisfy every listed acceptance criterion, including visible success and explicit failure/recovery behavior. -- [ ] **Step 4: Run all focused tests and verify GREEN** +- [x] **Step 4: Run all focused tests and verify GREEN** - Run: `cargo test -p opentake-tauri --test feedback submission_includes_app_and_os_version -- --exact` Expected: PASS with every candidate-bound assertion executed. -- [ ] **Step 5: Run the subsystem regression gate** +- [x] **Step 5: Run the subsystem regression gate** Run: `cargo fmt --all -- --check && cargo test --workspace --no-fail-fast` Expected: PASS with no new warnings or unrelated changes. + Completed 2026-08-01. The owning integration test was observed RED before the + module existed, then GREEN after the typed submission boundary was registered. + It covers package/build and OS metadata, missing-version fallbacks, camel-case + serialization, no-email contact suppression, and debug redaction. The runtime + remains offline unless an HTTPS endpoint is explicitly configured; redirects + are disabled and requests have a 15-second timeout. The formatting check and + `cargo test --workspace --no-fail-fast` pass. This metadata slice + has no user-visible surface; the disabled Beta feedback menu is not claimed as + an implemented feedback form by this task. + ### Task 2: centralized-design-token-table + AP-design-token-consistency (implementation-slice-d434285f35d42846) **Covered records:** @@ -331,7 +341,7 @@ - Add token, semantic-role, keyboard-focus, state, privacy, and visual assertions for every named surface; the affected lint, typecheck, and web suites must pass. - Exercise the named surface with keyboard and browser or packaged-app visual inspection, and retain exact accessibility or screenshot evidence before reclassification. -- [ ] **Step 1: Write or extend every reviewed owning test** +- [x] **Step 1: Write or extend every reviewed owning test** - `web/src/components/preview/TransformOverlay.test.tsx#renders 4 corner handles at the OpenTake spacing/opacity tokens matching upstream AppTheme.Spacing.smMd / Opacity.strong` (existing-owned) — Exact named test already exists in the reviewed owning runner and records current boundary behavior. - `web/src/components/shell/TitleBar.visual.test.ts#TitleBar alignment` (existing-owned) — Exact named test already exists in the reviewed owning runner and records current boundary behavior. @@ -340,7 +350,7 @@ Each assertion must exercise every covered candidate through the mapped product boundary; an existing-owned test may be extended, while a reviewed-planned test must be added at the declared runner path. -- [ ] **Step 2: Run all focused tests and verify RED** +- [x] **Step 2: Run all focused tests and verify RED** - Run: `pnpm -C web test -- --run src/components/preview/TransformOverlay.test.tsx -t "renders 4 corner handles at the OpenTake spacing/opacity tokens matching upstream AppTheme.Spacing.smMd / Opacity.strong"` - Run: `pnpm -C web test -- --run src/components/shell/TitleBar.visual.test.ts -t "TitleBar alignment"` @@ -349,11 +359,11 @@ Expected: FAIL because one or more of the 17 candidate-bound contracts are not yet satisfied. -- [ ] **Step 3: Implement the minimal vertical slice** +- [x] **Step 3: Implement the minimal vertical slice** Modify only `web/src/components/ui/PanelShell.tsx#PanelShell`, `web/src/lib/theme.ts#ACCENT`, `web/src/lib/theme.ts#BG`, `web/src/lib/theme.ts#BORDER`, `web/src/lib/theme.ts#FS`, `web/src/lib/theme.ts#LAYOUT`, `web/src/lib/theme.ts#RADIUS`, `web/src/lib/theme.ts#SPACE`, `web/src/lib/theme.ts#TEXT`, `web/src/lib/theme.ts#TRACK_COLOR`, `web/src/styles/tokens.css`, `web/src/styles/tokens.css#--bg-raised`, `docs/architecture/MODULE-PORT-MAP.md`, `docs/modules/web/SPEC.md`, `docs/specs/frontend/1-design-tokens.md`, `docs/specs/frontend/13-implementation.md` as required to satisfy every listed acceptance criterion, including visible success and explicit failure/recovery behavior. -- [ ] **Step 4: Run all focused tests and verify GREEN** +- [x] **Step 4: Run all focused tests and verify GREEN** - Run: `pnpm -C web test -- --run src/components/preview/TransformOverlay.test.tsx -t "renders 4 corner handles at the OpenTake spacing/opacity tokens matching upstream AppTheme.Spacing.smMd / Opacity.strong"` - Run: `pnpm -C web test -- --run src/components/shell/TitleBar.visual.test.ts -t "TitleBar alignment"` @@ -362,12 +372,25 @@ Expected: PASS with every candidate-bound assertion executed. -- [ ] **Step 5: Run the subsystem regression gate** +- [x] **Step 5: Run the subsystem regression gate** Run: `pnpm -C web test -- --run && pnpm -C web build` Expected: PASS with no new warnings or unrelated changes. + Code acceptance completed 2026-08-01. Both reviewed-planned owners were + observed RED, then all four focused runners passed (17 assertions). The full + Web gate passes with 97 files / 834 tests and a production build; only the + pre-existing dynamic-import and chunk-size advisories remain. The typed table + now covers every documented token and local dimension, the CSS projection has + no undefined production references, and captions/keyframe UI consume their + typed local constants. Keyframe rows were corrected from 24px to the specified + 22px. + +- [ ] **Runtime evidence gate:** inspect the packaged editor at the pinned + viewport/scale/locale and retain the six-surface Shell/Toolbar/Media/Inspector/ + Preview/Timeline token-parity evidence during the sequential GUI phase. + ### Task 3: dsn-gated-telemetry-init (implementation-slice-6fd8dbcff3a60a4b) **Covered records:** @@ -390,34 +413,41 @@ - Add token, semantic-role, keyboard-focus, state, privacy, and visual assertions for every named surface; the affected lint, typecheck, and web suites must pass. - Exercise the named surface with keyboard and browser or packaged-app visual inspection, and retain exact accessibility or screenshot evidence before reclassification. -- [ ] **Step 1: Write or extend every reviewed owning test** +- [x] **Step 1: Write or extend every reviewed owning test** - `src-tauri/tests/telemetry_init.rs#starts_only_with_explicit_packaged_or_environment_dsn` (reviewed-planned) — Reviewed planned test belongs in this tracked owning runner beside the mapped product boundary. Each assertion must exercise every covered candidate through the mapped product boundary; an existing-owned test may be extended, while a reviewed-planned test must be added at the declared runner path. -- [ ] **Step 2: Run all focused tests and verify RED** +- [x] **Step 2: Run all focused tests and verify RED** - Run: `cargo test -p opentake-tauri --test telemetry_init starts_only_with_explicit_packaged_or_environment_dsn -- --exact` Expected: FAIL because one or more of the 1 candidate-bound contracts are not yet satisfied. -- [ ] **Step 3: Implement the minimal vertical slice** +- [x] **Step 3: Implement the minimal vertical slice** Modify only `src-tauri/src/telemetry.rs#init_telemetry`, `docs/architecture/MODULE-PORT-MAP.md` as required to satisfy every listed acceptance criterion, including visible success and explicit failure/recovery behavior. -- [ ] **Step 4: Run all focused tests and verify GREEN** +- [x] **Step 4: Run all focused tests and verify GREEN** - Run: `cargo test -p opentake-tauri --test telemetry_init starts_only_with_explicit_packaged_or_environment_dsn -- --exact` Expected: PASS with every candidate-bound assertion executed. -- [ ] **Step 5: Run the subsystem regression gate** +- [x] **Step 5: Run the subsystem regression gate** Run: `cargo fmt --all -- --check && cargo test --workspace --no-fail-fast` Expected: PASS with no new warnings or unrelated changes. + Verified 2026-08-01: the reviewed test first failed while the telemetry owner + was absent, then passed after the DSN gate and scrubber were integrated. The + exact owner, telemetry unit test, `cargo fmt --all -- --check`, and + `CARGO_INCREMENTAL=0 cargo test --workspace --no-fail-fast` all passed. The + workspace gate reported only the existing explicitly ignored real-device + probes. + ### Task 4: release-accessibility-visual-parity (implementation-slice-75d12e695c4bc7e4) **Covered records:** @@ -442,34 +472,48 @@ - Validate sample-project load and core edit flows. - Run visual and accessibility regression checks on packaged desktop builds. -- [ ] **Step 1: Write or extend every reviewed owning test** +- [x] **Step 1: Write or extend every reviewed owning test** - `web/src/releaseParity.test.tsx#sample_projects_accessibility_visual_and_interaction_gate` (reviewed-planned) — Reviewed planned test belongs in this tracked owning runner beside the mapped product boundary. Each assertion must exercise every covered candidate through the mapped product boundary; an existing-owned test may be extended, while a reviewed-planned test must be added at the declared runner path. -- [ ] **Step 2: Run all focused tests and verify RED** +- [x] **Step 2: Run all focused tests and verify RED** - Run: `pnpm -C web test -- --run src/releaseParity.test.tsx -t "sample_projects_accessibility_visual_and_interaction_gate"` Expected: FAIL because one or more of the 1 candidate-bound contracts are not yet satisfied. -- [ ] **Step 3: Implement the minimal vertical slice** +- [x] **Step 3: Implement the minimal vertical slice** Modify only `web/src/components/ui/PanelShell.tsx#PanelShell`, `web/src/components/timeline/TimelineContainer.tsx#accessibleClipRects`, `web/src/styles/global.css`, `docs/architecture/PORT-1TO1-GAP.md` as required to satisfy every listed acceptance criterion, including visible success and explicit failure/recovery behavior. -- [ ] **Step 4: Run all focused tests and verify GREEN** +- [x] **Step 4: Run all focused tests and verify GREEN** - Run: `pnpm -C web test -- --run src/releaseParity.test.tsx -t "sample_projects_accessibility_visual_and_interaction_gate"` Expected: PASS with every candidate-bound assertion executed. -- [ ] **Step 5: Run the subsystem regression gate** +- [x] **Step 5: Run the subsystem regression gate** Run: `pnpm -C web test -- --run && pnpm -C web build` Expected: PASS with no new warnings or unrelated changes. + Verified 2026-08-01: the focused release owner failed first because panel + regions were not keyboard-focusable. It passed after keyboard panel focus and + global focus-visible, reduced-motion, and forced-colors behavior were added. + The release owner also binds the existing executable sample materialization, + rollback, Home routing, and 24px timeline clip-access contracts. The full Web + gate passed with 98 files / 835 tests and the production build passed; only + the existing dynamic-import and chunk-size advisories remained. + +- [ ] **Runtime evidence gate:** on the packaged desktop build, open an offline + sample, perform the core edit flow, traverse panel and timeline controls by + keyboard, inspect focus/hover/high-contrast/reduced-motion presentation, and + retain screenshots plus the exact interaction result before release-ready + reclassification. + ### Task 5: AP-keyboard-shortcut-matrix + complete-shortcut-table (implementation-slice-c9e6c68dcdf6b5bb) **Covered records:** @@ -525,7 +569,7 @@ - Conflicting or disabled shortcuts must not mutate state; repeat behavior and undo grouping must match the specified command. - Table-drive all shortcut rows across editor focus, text input, modal, locked selection, playback, and no-project states. -- [ ] **Step 1: Write or extend every reviewed owning test** +- [x] **Step 1: Write or extend every reviewed owning test** - `web/src/hooks/useKeyboardShortcuts.matrix.test.ts#all_shortcuts_conflicts_editable_suppression_and_platform_modifiers` (reviewed-planned) — Reviewed planned test belongs in this tracked owning runner beside the mapped product boundary. - `web/src/hooks/useKeyboardShortcuts.test.ts#complete_documented_shortcut_table` (reviewed-planned) — Reviewed planned test belongs in this tracked owning runner beside the mapped product boundary. @@ -536,7 +580,7 @@ Each assertion must exercise every covered candidate through the mapped product boundary; an existing-owned test may be extended, while a reviewed-planned test must be added at the declared runner path. -- [ ] **Step 2: Run all focused tests and verify RED** +- [x] **Step 2: Run all focused tests and verify RED** - Run: `pnpm -C web test -- --run src/hooks/useKeyboardShortcuts.matrix.test.ts -t "all_shortcuts_conflicts_editable_suppression_and_platform_modifiers"` - Run: `pnpm -C web test -- --run src/hooks/useKeyboardShortcuts.test.ts -t "complete_documented_shortcut_table"` @@ -547,11 +591,11 @@ Expected: FAIL because one or more of the 3 candidate-bound contracts are not yet satisfied. -- [ ] **Step 3: Implement the minimal vertical slice** +- [x] **Step 3: Implement the minimal vertical slice** Modify only `web/src/App.tsx#App`, `web/src/hooks/useKeyboardShortcuts.ts#handleProjectSaveKeyDown`, `web/src/hooks/useKeyboardShortcuts.ts#handleTransportSpaceKeyDown`, `web/src/hooks/useKeyboardShortcuts.ts#useKeyboardShortcuts`, `web/src/store/editActions.ts#splitAtPlayhead`, `docs/modules/web/SPEC.md`, `docs/specs/frontend/13-implementation.md`, `docs/specs/frontend/9-interactions.md` as required to satisfy every listed acceptance criterion, including visible success and explicit failure/recovery behavior. -- [ ] **Step 4: Run all focused tests and verify GREEN** +- [x] **Step 4: Run all focused tests and verify GREEN** - Run: `pnpm -C web test -- --run src/hooks/useKeyboardShortcuts.matrix.test.ts -t "all_shortcuts_conflicts_editable_suppression_and_platform_modifiers"` - Run: `pnpm -C web test -- --run src/hooks/useKeyboardShortcuts.test.ts -t "complete_documented_shortcut_table"` @@ -562,12 +606,24 @@ Expected: PASS with every candidate-bound assertion executed. -- [ ] **Step 5: Run the subsystem regression gate** +- [x] **Step 5: Run the subsystem regression gate** Run: `pnpm -C web test -- --run && pnpm -C web build` Expected: PASS with no new warnings or unrelated changes. + Verified 2026-08-01: both reviewed owners first failed because the complete + resolver/table did not exist. They passed after one physical-key resolver was + integrated with the hook and native-menu semantic command boundary. All six + named existing/focused owners passed; the full Web gate passed with 99 files / + 837 tests and the production build passed, with only the existing dynamic- + import and chunk-size advisories. + +- [ ] **Runtime evidence gate:** exercise every §9.6 row in the packaged desktop + app on the macOS accelerator path, including input focus, modal, repeat, + disabled/read-only and no-project states; retain the exact command/result and + visible-state evidence before final parity reclassification. + ### Task 6: AP-hover-focus-cursor-matrix + complete-hover-cursor-table (implementation-slice-7dce3a0f53283ac9) **Covered records:** @@ -626,7 +682,7 @@ - Focus order and restoration must remain deterministic across panels, menus, dialogs, project switches, and hidden/maximized panels. - Run keyboard-only focus traversal plus pointer-state visual snapshots at default and high-contrast themes, with no focus traps or unlabeled controls. -- [ ] **Step 1: Write or extend every reviewed owning test** +- [x] **Step 1: Write or extend every reviewed owning test** - `web/src/components/preview/TransformOverlay.test.tsx#TransformOverlay` (existing-owned) — Exact named test already exists in the reviewed owning runner and records current boundary behavior. - `web/src/components/preview/TransformOverlay.test.tsx#renders 4 corner handles at the OpenTake spacing/opacity tokens matching upstream AppTheme.Spacing.smMd / Opacity.strong` (existing-owned) — Exact named test already exists in the reviewed owning runner and records current boundary behavior. @@ -636,7 +692,7 @@ Each assertion must exercise every covered candidate through the mapped product boundary; an existing-owned test may be extended, while a reviewed-planned test must be added at the declared runner path. -- [ ] **Step 2: Run all focused tests and verify RED** +- [x] **Step 2: Run all focused tests and verify RED** - Run: `pnpm -C web test -- --run src/components/preview/TransformOverlay.test.tsx -t "TransformOverlay"` - Run: `pnpm -C web test -- --run src/components/preview/TransformOverlay.test.tsx -t "renders 4 corner handles at the OpenTake spacing/opacity tokens matching upstream AppTheme.Spacing.smMd / Opacity.strong"` @@ -646,11 +702,11 @@ Expected: FAIL because one or more of the 3 candidate-bound contracts are not yet satisfied. -- [ ] **Step 3: Implement the minimal vertical slice** +- [x] **Step 3: Implement the minimal vertical slice** Modify only `web/src/components/inspector/ScrubbableNumberField.tsx#ScrubbableNumberField`, `web/src/components/preview/CropOverlay.tsx#CropOverlay`, `web/src/components/preview/TransformOverlay.tsx#CORNER_CURSOR`, `web/src/components/preview/TransformOverlay.tsx#TransformOverlay`, `web/src/components/shell/SplitPane.tsx#SplitPane`, `web/src/components/timeline/TimelineContainer.tsx#TimelineContainer`, `web/src/components/timeline/TimelineContainer.tsx#toolMode`, `web/src/components/ui/HoverButton.tsx#HoverButton`, `web/src/styles/global.css`, `docs/modules/web/SPEC.md`, `docs/specs/frontend/13-implementation.md`, `docs/specs/frontend/9-interactions.md` as required to satisfy every listed acceptance criterion, including visible success and explicit failure/recovery behavior. -- [ ] **Step 4: Run all focused tests and verify GREEN** +- [x] **Step 4: Run all focused tests and verify GREEN** - Run: `pnpm -C web test -- --run src/components/preview/TransformOverlay.test.tsx -t "TransformOverlay"` - Run: `pnpm -C web test -- --run src/components/preview/TransformOverlay.test.tsx -t "renders 4 corner handles at the OpenTake spacing/opacity tokens matching upstream AppTheme.Spacing.smMd / Opacity.strong"` @@ -660,12 +716,24 @@ Expected: PASS with every candidate-bound assertion executed. -- [ ] **Step 5: Run the subsystem regression gate** +- [x] **Step 5: Run the subsystem regression gate** Run: `pnpm -C web test -- --run && pnpm -C web build` Expected: PASS with no new warnings or unrelated changes. + Verified 2026-08-01: the two reviewed matrix owners first failed because the + shared timeline cursor projection and interaction-state attributes did not + exist. All five named focused owners passed after the cursor/focus/disabled + boundary was integrated. The full Web gate passed with 101 files / 839 tests + and the production build passed, with only the existing dynamic-import and + chunk-size advisories. + +- [ ] **Runtime evidence gate:** traverse the packaged app by keyboard and + pointer, then capture default/high-contrast hover, focus-visible, pressed, + disabled, resize, trim, razor, forbidden, loading and dragging states with no + focus traps or unlabeled controls before final parity reclassification. + ### Task 7: AP-i18n-runtime-contract (implementation-slice-2123bc4e99fa84c2) **Covered records:** @@ -688,34 +756,41 @@ - Exact acceptance contract: - Add deterministic unit tests for default/invalid persisted locale, zh-CN/en lookup, missing-key fallback, numeric/string interpolation, unknown placeholder preservation, locale persistence, and document.lang updates. -- [ ] **Step 1: Write or extend every reviewed owning test** +- [x] **Step 1: Write or extend every reviewed owning test** - `web/src/i18n/index.test.ts#defaults_zh_cn_supports_en_and_preserves_unknown_named_placeholders` (reviewed-planned) — Reviewed planned test belongs in this tracked owning runner beside the mapped product boundary. Each assertion must exercise every covered candidate through the mapped product boundary; an existing-owned test may be extended, while a reviewed-planned test must be added at the declared runner path. -- [ ] **Step 2: Run all focused tests and verify RED** +- [x] **Step 2: Run all focused tests and verify RED** - Run: `pnpm -C web test -- --run src/i18n/index.test.ts -t "defaults_zh_cn_supports_en_and_preserves_unknown_named_placeholders"` Expected: FAIL because one or more of the 1 candidate-bound contracts are not yet satisfied. -- [ ] **Step 3: Implement the minimal vertical slice** +- [x] **Step 3: Implement the minimal vertical slice** Modify only `web/src/i18n/index.ts#DEFAULT_LOCALE`, `web/src/i18n/index.ts#translate`, `web/src/i18n/dict.ts#DICTS`, `docs/modules/web/hooks-i18n-theme.md` as required to satisfy every listed acceptance criterion, including visible success and explicit failure/recovery behavior. -- [ ] **Step 4: Run all focused tests and verify GREEN** +- [x] **Step 4: Run all focused tests and verify GREEN** - Run: `pnpm -C web test -- --run src/i18n/index.test.ts -t "defaults_zh_cn_supports_en_and_preserves_unknown_named_placeholders"` Expected: PASS with every candidate-bound assertion executed. -- [ ] **Step 5: Run the subsystem regression gate** +- [x] **Step 5: Run the subsystem regression gate** Run: `pnpm -C web test -- --run && pnpm -C web build` Expected: PASS with no new warnings or unrelated changes. + Verified 2026-08-01: the reviewed owner first failed because an invalid + persisted locale remained in storage after the runtime fell back to zh-CN. + The runtime now removes unsupported persisted values, tolerates unavailable + storage, and exports the default/translation boundaries for deterministic + proof. The full Web gate passed with 102 files / 840 tests and the production + build passed, with only the existing dynamic-import and chunk-size advisories. + ### Task 8: AP-bg-placeholder-token (implementation-slice-2c3937178aa8f103) **Covered records:** @@ -743,23 +818,23 @@ - Visible/returned assertion: assert the exact visible text/control/state/focus result and the returned success or typed failure, including a no-op assertion for disabled, cancelled, or rejected input. - Evidence required: after the deterministic test passes, record code:# and test:#; proposed concrete evidence is test:web/src/__tests__/completion/doc-de0f95b975d2b2c6.test.ts#completion_de0f95b975d2b2c6_define_bg_placeholder_exactly_as_rgb_30_30_30_eq. -- [ ] **Step 1: Write or extend every reviewed owning test** +- [x] **Step 1: Write or extend every reviewed owning test** - `web/src/styles/tokens.test.ts#bg_placeholder_equals_raised_rgb_30` (reviewed-planned) — Reviewed planned test belongs in this tracked owning runner beside the mapped product boundary. Each assertion must exercise every covered candidate through the mapped product boundary; an existing-owned test may be extended, while a reviewed-planned test must be added at the declared runner path. -- [ ] **Step 2: Run all focused tests and verify RED** +- [x] **Step 2: Run all focused tests and verify RED** - Run: `pnpm -C web test -- --run src/styles/tokens.test.ts -t "bg_placeholder_equals_raised_rgb_30"` Expected: FAIL because one or more of the 1 candidate-bound contracts are not yet satisfied. -- [ ] **Step 3: Implement the minimal vertical slice** +- [x] **Step 3: Implement the minimal vertical slice** Modify only `web/src/styles/tokens.css#--bg-placeholder`, `web/src/styles/tokens.css#--bg-raised`, `docs/specs/frontend/1-design-tokens.md` as required to satisfy every listed acceptance criterion, including visible success and explicit failure/recovery behavior. -- [ ] **Step 4: Run all focused tests and verify GREEN** +- [x] **Step 4: Run all focused tests and verify GREEN** - Run: `pnpm -C web test -- --run src/styles/tokens.test.ts -t "bg_placeholder_equals_raised_rgb_30"` @@ -771,6 +846,16 @@ Expected: PASS with no new warnings or unrelated changes. + Verified 2026-08-01: focused RED was the reviewed owner being absent. The + existing CSS projection already held both tokens at exact `rgb(30,30,30)`, + so GREEN adds the missing direct equality proof without changing production + values. The focused owner passes. The full completion-audit regression gate + ran 206 assertions (203 passed); after removing an induced frozen-source + drift, the two remaining focused failures are both caused by the four + preserved, user-owned audit outputs not matching their normative + renders/inventory. Step 5 remains open until that external dirty state is + reconciled. + ### Task 9: five-panel-layout-focus (implementation-slice-b99fb8c63f2086b0) **Covered records:** @@ -798,37 +883,48 @@ - Add token, semantic-role, keyboard-focus, state, privacy, and visual assertions for every named surface; the affected lint, typecheck, and web suites must pass. - Exercise the named surface with keyboard and browser or packaged-app visual inspection, and retain exact accessibility or screenshot evidence before reclassification. -- [ ] **Step 1: Write or extend every reviewed owning test** +- [x] **Step 1: Write or extend every reviewed owning test** - `web/src/components/ui/PanelShell.test.tsx#PanelShell preview surface` (existing-owned) — Exact named test already exists in the reviewed owning runner and records current boundary behavior. - `web/src/components/shell/EditorSplit.test.tsx#all_presets_ratios_gutters_surfaces_focus` (reviewed-planned) — Reviewed planned test belongs in this tracked owning runner beside the mapped product boundary. Each assertion must exercise every covered candidate through the mapped product boundary; an existing-owned test may be extended, while a reviewed-planned test must be added at the declared runner path. -- [ ] **Step 2: Run all focused tests and verify RED** +- [x] **Step 2: Run all focused tests and verify RED** - Run: `pnpm -C web test -- --run src/components/ui/PanelShell.test.tsx -t "PanelShell preview surface"` - Run: `pnpm -C web test -- --run src/components/shell/EditorSplit.test.tsx -t "all_presets_ratios_gutters_surfaces_focus"` Expected: FAIL because one or more of the 1 candidate-bound contracts are not yet satisfied. -- [ ] **Step 3: Implement the minimal vertical slice** +- [x] **Step 3: Implement the minimal vertical slice** Modify only `web/src/components/shell/EditorSplit.tsx#EditorSplit`, `web/src/components/shell/EditorSplit.tsx#DefaultLayout`, `web/src/components/shell/EditorSplit.tsx#MediaLayout`, `web/src/components/shell/EditorSplit.tsx#VerticalLayout`, `web/src/components/ui/PanelShell.tsx#PanelShell`, `docs/specs/frontend/13-implementation.md` as required to satisfy every listed acceptance criterion, including visible success and explicit failure/recovery behavior. -- [ ] **Step 4: Run all focused tests and verify GREEN** +- [x] **Step 4: Run all focused tests and verify GREEN** - Run: `pnpm -C web test -- --run src/components/ui/PanelShell.test.tsx -t "PanelShell preview surface"` - Run: `pnpm -C web test -- --run src/components/shell/EditorSplit.test.tsx -t "all_presets_ratios_gutters_surfaces_focus"` Expected: PASS with every candidate-bound assertion executed. -- [ ] **Step 5: Run the subsystem regression gate** +- [x] **Step 5: Run the subsystem regression gate** Run: `pnpm -C web test -- --run && pnpm -C web build` Expected: PASS with no new warnings or unrelated changes. + Reverified 2026-08-01: both owning runners pass (2 files / 4 tests), covering + all three presets at 1600×1000 plus reduced viewport, documented initial + ratios, panel visibility/maximize behavior, keyboard-adjustable separators, + semantic regions and focus transfer. The full Web gate passes with 103 files + / 841 tests and the production build passes, with only the existing + dynamic-import and chunk-size advisories. + +- [ ] **Runtime evidence gate:** in the packaged app at 1600×1000, capture all + three presets with the five panels enabled and verify 5px gutters, 6px + surfaces, focused/unfocused rings, Tab focus and keyboard separator resize. + ### Task 10: control-acceptance (implementation-slice-7729e824b5300938) **Covered records:** @@ -855,34 +951,45 @@ - Visible/accessibility/return path: success=keyframe lane seek: assert exactly if (e.target === e.currentTarget) setActiveFrame(startFrame + xToFrame(e.clientX)); contextmenu only preventDefault() and no sibling branch/command.; accessibility={"focus":"Custom rendered element has no proven tabIndex/keyboard equivalent at this candidate.","label":"No explicit aria-label/title association was discovered at this candidate.","shortcut":"No dedicated shortcut is declared."}; returnPath=["Remain on the owning editor surface after local state or authoritative mirror refresh.","Retain focus on the native control or explicitly restore it when conditional UI closes; this must be asserted."]. - Outcome matrix: {"success":"keyframe lane seek: assert exactly if (e.target === e.currentTarget) setActiveFrame(startFrame + xToFrame(e.clientX)); contextmenu only preventDefault() and no sibling branch/command.","pending":"N/A — synchronous local/browser state only.","empty":"N/A — owning visibility/handler guards prevent an absent target from emitting a command.","disabled":"No explicit disabled attribute beyond the listed visibility/handler guards.","cancel":"N/A — no separate cancellation phase.","retry":"N/A — repeated activation is a new synchronous action.","failure":"N/A — no API/Tauri/Rust failure route."}. -- [ ] **Step 1: Write or extend every reviewed owning test** +- [x] **Step 1: Write or extend every reviewed owning test** - `web/src/components/inspector/KeyframesLaneRow.interaction.test.tsx#control-75a9964d0b81961a keyframe lane seek` (reviewed-planned) — The control acceptance contract explicitly names this owning component test runner. Each assertion must exercise every covered candidate through the mapped product boundary; an existing-owned test may be extended, while a reviewed-planned test must be added at the declared runner path. -- [ ] **Step 2: Run all focused tests and verify RED** +- [x] **Step 2: Run all focused tests and verify RED** - Run: `pnpm -C web test -- --run src/components/inspector/KeyframesLaneRow.interaction.test.tsx -t "control-75a9964d0b81961a keyframe lane seek"` Expected: FAIL because one or more of the 1 candidate-bound contracts are not yet satisfied. -- [ ] **Step 3: Implement the minimal vertical slice** +- [x] **Step 3: Implement the minimal vertical slice** Modify only `web/src/components/inspector/KeyframesLaneRow.tsx`, `web/src/components/inspector/KeyframesLaneRow.tsx#KeyframesLaneRow` as required to satisfy every listed acceptance criterion, including visible success and explicit failure/recovery behavior. -- [ ] **Step 4: Run all focused tests and verify GREEN** +- [x] **Step 4: Run all focused tests and verify GREEN** - Run: `pnpm -C web test -- --run src/components/inspector/KeyframesLaneRow.interaction.test.tsx -t "control-75a9964d0b81961a keyframe lane seek"` Expected: PASS with every candidate-bound assertion executed. -- [ ] **Step 5: Run the subsystem regression gate** +- [x] **Step 5: Run the subsystem regression gate** Run: `pnpm -C web test -- --run && pnpm -C web build` Expected: PASS with no new warnings or unrelated changes. + Verified 2026-08-01: the focused owner failed first because the lane had no + queryable/focusable semantic boundary. It now proves empty-lane pointer seek, + child-click isolation, context-menu no-op, zero edit-command emission, + horizontal slider semantics and keyboard frame seek with retained focus. The + full Web gate passes with 104 files / 842 tests and the production build + passes, with only the existing dynamic-import and chunk-size advisories. + +- [ ] **Runtime evidence gate:** in the packaged Inspector keyframes panel, + verify pointer and keyboard seek, visible focus, frame updates, child click + isolation and right-click no-op before final control reclassification. + ### Task 11: control-acceptance (implementation-slice-0fa7eb23d9e9be73) **Covered records:** @@ -917,34 +1024,47 @@ - Visible/accessibility/return path: success=keyframe diamond drag/context menu: assert exactly onMouseDown starts local drag; window mouseup calls edit.moveKeyframe(clip.id, property, fromFrame, currentFrame) only when the frame changed; onContextMenu sets menu { x: e.clientX, y: e.clientY, frame: kf.key } and emits no edit command and no sibling branch/command.; accessibility={"focus":"Custom rendered element has no proven tabIndex/keyboard equivalent at this candidate.","label":"No explicit aria-label/title association was discovered at this candidate.","shortcut":"No dedicated shortcut is declared."}; returnPath=["Remain on the owning editor surface after local state or authoritative mirror refresh.","Retain focus on the native control or explicitly restore it when conditional UI closes; this must be asserted."]. - Outcome matrix: {"success":"keyframe diamond drag/context menu: assert exactly onMouseDown starts local drag; window mouseup calls edit.moveKeyframe(clip.id, property, fromFrame, currentFrame) only when the frame changed; onContextMenu sets menu { x: e.clientX, y: e.clientY, frame: kf.key } and emits no edit command and no sibling branch/command.","pending":"A promise may be pending, but this candidate exposes no explicit progress state.","empty":"N/A — owning visibility/handler guards prevent an absent target from emitting a command.","disabled":"No explicit disabled attribute beyond the listed visibility/handler guards.","cancel":"N/A — no separate cancellation phase.","retry":"No automatic retry; repeated activation resubmits after the candidate becomes actionable.","failure":"Backend rejection is not caught/rendered at this fire-and-forget candidate; visible recovery evidence is required."}. -- [ ] **Step 1: Write or extend every reviewed owning test** +- [x] **Step 1: Write or extend every reviewed owning test** - `web/src/components/inspector/KeyframesLaneRow.interaction.test.tsx#control-4e0a20c7d0e54f3e keyframe diamond drag/context menu` (reviewed-planned) — The control acceptance contract explicitly names this owning component test runner. Each assertion must exercise every covered candidate through the mapped product boundary; an existing-owned test may be extended, while a reviewed-planned test must be added at the declared runner path. -- [ ] **Step 2: Run all focused tests and verify RED** +- [x] **Step 2: Run all focused tests and verify RED** - Run: `pnpm -C web test -- --run src/components/inspector/KeyframesLaneRow.interaction.test.tsx -t "control-4e0a20c7d0e54f3e keyframe diamond drag/context menu"` Expected: FAIL because one or more of the 1 candidate-bound contracts are not yet satisfied. -- [ ] **Step 3: Implement the minimal vertical slice** +- [x] **Step 3: Implement the minimal vertical slice** Modify only `web/src/components/inspector/KeyframesLaneRow.tsx`, `web/src/components/inspector/KeyframesLaneRow.tsx#handleDiamondMouseDown`, `web/src/store/editActions.ts#moveKeyframe`, `web/src/store/editActions.ts#applyAndRefresh`, `web/src/lib/api.ts#editApply`, `src-tauri/src/commands.rs#edit_apply`, `crates/opentake-core/src/dto.rs#handle_edit_apply`, `crates/opentake-ops/src/command.rs#EditCommand::MoveKeyframe`, `web/src/components/inspector/KeyframesLaneRow.tsx#KeyframesLaneRow`, `crates/opentake-ops/src/command.rs#EditCommand` as required to satisfy every listed acceptance criterion, including visible success and explicit failure/recovery behavior. -- [ ] **Step 4: Run all focused tests and verify GREEN** +- [x] **Step 4: Run all focused tests and verify GREEN** - Run: `pnpm -C web test -- --run src/components/inspector/KeyframesLaneRow.interaction.test.tsx -t "control-4e0a20c7d0e54f3e keyframe diamond drag/context menu"` Expected: PASS with every candidate-bound assertion executed. -- [ ] **Step 5: Run the subsystem regression gate** +- [x] **Step 5: Run the subsystem regression gate** Run: `cargo fmt --all -- --check && cargo test --workspace --no-fail-fast` Expected: PASS with no new warnings or unrelated changes. + Verified 2026-08-01: RED proved the diamond lacked an accessible owner. The + focused test now covers unchanged-drag no-op, changed-drag exact single + command, right-click menu coordinates with zero edit calls, keyboard + one-frame movement with focus retention, and visible toast recovery on a + rejected command. The Web gate passes with 104 files / 843 tests plus a + production build. Formatting and the complete Rust workspace pass; only the + seven pre-existing real-device probes remain ignored. + +- [ ] **Runtime evidence gate:** in the packaged Inspector, drag a diamond, + open its menu by pointer and keyboard, verify focus/labels, and force a safe + rejected move to confirm visible recovery before final control + reclassification. + ### Task 12: control-acceptance (implementation-slice-40b57db02ead9758) **Covered records:** @@ -975,34 +1095,45 @@ - Visible/accessibility/return path: success=dismiss keyframe context menu: assert exactly click calls onClose(); contextmenu preventDefault() then onClose() and no sibling branch/command.; accessibility={"focus":"Custom rendered element has no proven tabIndex/keyboard equivalent at this candidate.","label":"No explicit aria-label/title association was discovered at this candidate.","shortcut":"Escape/outside close exists where mounted, but arrow-key roving focus and invoking-control focus restoration are not proven."}; returnPath=["Close through selected item, Escape, or outside action exactly where implemented.","Restore focus to the invoking control; current code does not explicitly prove this."]. - Outcome matrix: {"success":"dismiss keyframe context menu: assert exactly click calls onClose(); contextmenu preventDefault() then onClose() and no sibling branch/command.","pending":"N/A — synchronous local/browser state only.","empty":"N/A — owning visibility/handler guards prevent an absent target from emitting a command.","disabled":"No explicit disabled attribute beyond the listed visibility/handler guards.","cancel":"Dismiss/close leaves authoritative state unchanged; invoking-control focus restoration is not proven.","retry":"N/A — repeated activation is a new synchronous action.","failure":"N/A — no API/Tauri/Rust failure route."}. -- [ ] **Step 1: Write or extend every reviewed owning test** +- [x] **Step 1: Write or extend every reviewed owning test** - `web/src/components/inspector/KeyframesLaneRow.interaction.test.tsx#control-6e36c47f93f0d4fb dismiss keyframe context menu` (reviewed-planned) — The control acceptance contract explicitly names this owning component test runner. Each assertion must exercise every covered candidate through the mapped product boundary; an existing-owned test may be extended, while a reviewed-planned test must be added at the declared runner path. -- [ ] **Step 2: Run all focused tests and verify RED** +- [x] **Step 2: Run all focused tests and verify RED** - Run: `pnpm -C web test -- --run src/components/inspector/KeyframesLaneRow.interaction.test.tsx -t "control-6e36c47f93f0d4fb dismiss keyframe context menu"` Expected: FAIL because one or more of the 1 candidate-bound contracts are not yet satisfied. -- [ ] **Step 3: Implement the minimal vertical slice** +- [x] **Step 3: Implement the minimal vertical slice** Modify only `web/src/components/inspector/KeyframesLaneRow.tsx`, `web/src/components/inspector/KeyframesLaneRow.tsx#KeyframeContextMenu`, `web/src/components/inspector/KeyframesLaneRow.tsx#KeyframesLaneRow` as required to satisfy every listed acceptance criterion, including visible success and explicit failure/recovery behavior. -- [ ] **Step 4: Run all focused tests and verify GREEN** +- [x] **Step 4: Run all focused tests and verify GREEN** - Run: `pnpm -C web test -- --run src/components/inspector/KeyframesLaneRow.interaction.test.tsx -t "control-6e36c47f93f0d4fb dismiss keyframe context menu"` Expected: PASS with every candidate-bound assertion executed. -- [ ] **Step 5: Run the subsystem regression gate** +- [x] **Step 5: Run the subsystem regression gate** Run: `pnpm -C web test -- --run && pnpm -C web build` Expected: PASS with no new warnings or unrelated changes. + Verified 2026-08-01: RED proved the context menu had no menu semantic or + focus contract. The owning test now proves outside click, outside + context-menu and Escape each dismiss without an edit command, and each path + restores focus to the invoking diamond. The full Web gate passes with 104 + files / 844 tests and the production build passes, with only the existing + dynamic-import and chunk-size advisories. + +- [ ] **Runtime evidence gate:** open and dismiss the packaged keyframe menu by + pointer, right-click and Escape, confirming visible focus returns to the + invoking diamond before final control reclassification. + ### Task 13: control-acceptance (implementation-slice-29f0b31549c65faf) **Covered records:** @@ -1036,34 +1167,45 @@ - Visible/accessibility/return path: success=delete keyframe: assert exactly onDelete() -> edit.removeKeyframe(clip.id, property, menu.frame); then closeMenu() and no sibling branch/command.; accessibility={"focus":"Custom rendered element has no proven tabIndex/keyboard equivalent at this candidate.","label":"No explicit aria-label/title association was discovered at this candidate.","shortcut":"Escape/outside close exists where mounted, but arrow-key roving focus and invoking-control focus restoration are not proven."}; returnPath=["Close through selected item, Escape, or outside action exactly where implemented.","Restore focus to the invoking control; current code does not explicitly prove this."]. - Outcome matrix: {"success":"delete keyframe: assert exactly onDelete() -> edit.removeKeyframe(clip.id, property, menu.frame); then closeMenu() and no sibling branch/command.","pending":"A promise may be pending, but this candidate exposes no explicit progress state.","empty":"N/A — owning visibility/handler guards prevent an absent target from emitting a command.","disabled":"No explicit disabled attribute beyond the listed visibility/handler guards.","cancel":"Dismiss/close leaves authoritative state unchanged; invoking-control focus restoration is not proven.","retry":"No automatic retry; repeated activation resubmits after the candidate becomes actionable.","failure":"Backend rejection is not caught/rendered at this fire-and-forget candidate; visible recovery evidence is required."}. -- [ ] **Step 1: Write or extend every reviewed owning test** +- [x] **Step 1: Write or extend every reviewed owning test** - `web/src/components/inspector/KeyframesLaneRow.interaction.test.tsx#control-c191a17716450b1a delete keyframe` (reviewed-planned) — The control acceptance contract explicitly names this owning component test runner. Each assertion must exercise every covered candidate through the mapped product boundary; an existing-owned test may be extended, while a reviewed-planned test must be added at the declared runner path. -- [ ] **Step 2: Run all focused tests and verify RED** +- [x] **Step 2: Run all focused tests and verify RED** - Run: `pnpm -C web test -- --run src/components/inspector/KeyframesLaneRow.interaction.test.tsx -t "control-c191a17716450b1a delete keyframe"` Expected: FAIL because one or more of the 1 candidate-bound contracts are not yet satisfied. -- [ ] **Step 3: Implement the minimal vertical slice** +- [x] **Step 3: Implement the minimal vertical slice** Modify only `web/src/components/inspector/KeyframesLaneRow.tsx`, `web/src/store/editActions.ts#removeKeyframe`, `web/src/store/editActions.ts#applyAndRefresh`, `web/src/lib/api.ts#editApply`, `src-tauri/src/commands.rs#edit_apply`, `crates/opentake-core/src/dto.rs#handle_edit_apply`, `crates/opentake-ops/src/command.rs#EditCommand::RemoveKeyframe`, `web/src/components/inspector/KeyframesLaneRow.tsx#KeyframesLaneRow`, `crates/opentake-ops/src/command.rs#EditCommand` as required to satisfy every listed acceptance criterion, including visible success and explicit failure/recovery behavior. -- [ ] **Step 4: Run all focused tests and verify GREEN** +- [x] **Step 4: Run all focused tests and verify GREEN** - Run: `pnpm -C web test -- --run src/components/inspector/KeyframesLaneRow.interaction.test.tsx -t "control-c191a17716450b1a delete keyframe"` Expected: PASS with every candidate-bound assertion executed. -- [ ] **Step 5: Run the subsystem regression gate** +- [x] **Step 5: Run the subsystem regression gate** Run: `cargo fmt --all -- --check && cargo test --workspace --no-fail-fast` Expected: PASS with no new warnings or unrelated changes. + Verified 2026-08-01: RED proved the delete item lacked a native accessible + action boundary. The owner now proves one exact remove command, no sibling + edit command, immediate menu close, trigger-focus restoration and visible + toast recovery after backend rejection. The Web gate passes with 104 files / + 845 tests plus a production build. Formatting and the complete Rust workspace + pass; only the seven pre-existing real-device probes remain ignored. + +- [ ] **Runtime evidence gate:** delete a disposable keyframe from the packaged + menu by pointer and keyboard, verify focus restoration and safely exercise a + rejected deletion before final control reclassification. + ### Task 14: control-acceptance (implementation-slice-a43944e5aef30cb5) **Covered records:** @@ -1129,7 +1271,7 @@ - Visible/accessibility/return path: success=smooth keyframe interpolation: assert exactly onSetInterpolation('smooth') -> edit.setKeyframeInterpolation(clip.id, property, menu.frame, 'smooth'); then closeMenu() and no sibling branch/command.; accessibility={"focus":"Custom rendered element has no proven tabIndex/keyboard equivalent at this candidate.","label":"No explicit aria-label/title association was discovered at this candidate.","shortcut":"Escape/outside close exists where mounted, but arrow-key roving focus and invoking-control focus restoration are not proven."}; returnPath=["Close through selected item, Escape, or outside action exactly where implemented.","Restore focus to the invoking control; current code does not explicitly prove this."]. - Outcome matrix: {"success":"smooth keyframe interpolation: assert exactly onSetInterpolation('smooth') -> edit.setKeyframeInterpolation(clip.id, property, menu.frame, 'smooth'); then closeMenu() and no sibling branch/command.","pending":"A promise may be pending, but this candidate exposes no explicit progress state.","empty":"N/A — owning visibility/handler guards prevent an absent target from emitting a command.","disabled":"No explicit disabled attribute beyond the listed visibility/handler guards.","cancel":"Dismiss/close leaves authoritative state unchanged; invoking-control focus restoration is not proven.","retry":"No automatic retry; repeated activation resubmits after the candidate becomes actionable.","failure":"Backend rejection is not caught/rendered at this fire-and-forget candidate; visible recovery evidence is required."}. -- [ ] **Step 1: Write or extend every reviewed owning test** +- [x] **Step 1: Write or extend every reviewed owning test** - `web/src/components/inspector/KeyframesLaneRow.interaction.test.tsx#control-3b4230aba22c9422 linear keyframe interpolation` (reviewed-planned) — The control acceptance contract explicitly names this owning component test runner. - `web/src/components/inspector/KeyframesLaneRow.interaction.test.tsx#control-16737eebbe9cb784 hold keyframe interpolation` (reviewed-planned) — The control acceptance contract explicitly names this owning component test runner. @@ -1137,7 +1279,7 @@ Each assertion must exercise every covered candidate through the mapped product boundary; an existing-owned test may be extended, while a reviewed-planned test must be added at the declared runner path. -- [ ] **Step 2: Run all focused tests and verify RED** +- [x] **Step 2: Run all focused tests and verify RED** - Run: `pnpm -C web test -- --run src/components/inspector/KeyframesLaneRow.interaction.test.tsx -t "control-3b4230aba22c9422 linear keyframe interpolation"` - Run: `pnpm -C web test -- --run src/components/inspector/KeyframesLaneRow.interaction.test.tsx -t "control-16737eebbe9cb784 hold keyframe interpolation"` @@ -1145,11 +1287,11 @@ Expected: FAIL because one or more of the 3 candidate-bound contracts are not yet satisfied. -- [ ] **Step 3: Implement the minimal vertical slice** +- [x] **Step 3: Implement the minimal vertical slice** Modify only `web/src/components/inspector/KeyframesLaneRow.tsx`, `web/src/store/editActions.ts#setKeyframeInterpolation`, `web/src/store/editActions.ts#applyAndRefresh`, `web/src/lib/api.ts#editApply`, `src-tauri/src/commands.rs#edit_apply`, `crates/opentake-core/src/dto.rs#handle_edit_apply`, `crates/opentake-ops/src/command.rs#EditCommand::SetKeyframeInterpolation`, `web/src/components/inspector/KeyframesLaneRow.tsx#KeyframesLaneRow`, `crates/opentake-ops/src/command.rs#EditCommand` as required to satisfy every listed acceptance criterion, including visible success and explicit failure/recovery behavior. -- [ ] **Step 4: Run all focused tests and verify GREEN** +- [x] **Step 4: Run all focused tests and verify GREEN** - Run: `pnpm -C web test -- --run src/components/inspector/KeyframesLaneRow.interaction.test.tsx -t "control-3b4230aba22c9422 linear keyframe interpolation"` - Run: `pnpm -C web test -- --run src/components/inspector/KeyframesLaneRow.interaction.test.tsx -t "control-16737eebbe9cb784 hold keyframe interpolation"` @@ -1157,12 +1299,25 @@ Expected: PASS with every candidate-bound assertion executed. -- [ ] **Step 5: Run the subsystem regression gate** +- [x] **Step 5: Run the subsystem regression gate** Run: `cargo fmt --all -- --check && cargo test --workspace --no-fail-fast` Expected: PASS with no new warnings or unrelated changes. + Verified 2026-08-01: all three focused owners first failed only on absent + failure recovery. Linear, hold and smooth now each prove one exact + interpolation command, no sibling command, native menuitem activation, + immediate close, trigger-focus restoration and a visible rejected-command + toast. The Web gate passes with 104 files / 848 tests plus a production build. + Formatting and the complete Rust workspace pass; only the seven pre-existing + real-device probes remain ignored. + +- [ ] **Runtime evidence gate:** set linear, hold and smooth on disposable + keyframes in the packaged menu using pointer and keyboard, verify focus + restoration and a safe rejection state before final control + reclassification. + ### Task 15: control-acceptance (implementation-slice-112e03f8a358e250) **Covered records:** @@ -1208,37 +1363,49 @@ - Visible/accessibility/return path: success=pointer-scrubbable numeric value: assert exactly pointerdown captures start; pointermove computes clamped startValue + delta*sensitivity (Shift x10, Command x0.1) and calls p.onChange?.(next); pointerup calls p.onCommit(provisionalValue) exactly once when moved, otherwise enters text editing and no sibling branch/command.; accessibility={"focus":"Pointer-only span has no tabIndex or keyboard adjustment.","label":"No role or accessible name exists on the displayed-value span.","shortcut":"Shift/Command change pointer sensitivity only."}; returnPath=["Pointerup/cancel releases the gesture and remains on the same editor surface.","A keyboard equivalent must retain/restore focus; current custom drag surface does not prove one."]. - Outcome matrix: {"success":"pointer-scrubbable numeric value: assert exactly pointerdown captures start; pointermove computes clamped startValue + delta*sensitivity (Shift x10, Command x0.1) and calls p.onChange?.(next); pointerup calls p.onCommit(provisionalValue) exactly once when moved, otherwise enters text editing and no sibling branch/command.","pending":"N/A — synchronous local/browser state only.","empty":"N/A — owning visibility/handler guards prevent an absent target from emitting a command.","disabled":"No explicit disabled attribute beyond the listed visibility/handler guards.","cancel":"Pointer capture loss/Escape cancellation is not implemented for the drag.","retry":"N/A — repeated activation is a new synchronous action.","failure":"N/A — no API/Tauri/Rust failure route."}. -- [ ] **Step 1: Write or extend every reviewed owning test** +- [x] **Step 1: Write or extend every reviewed owning test** - `web/src/components/inspector/ScrubbableNumberField.interaction.test.tsx#control-481c7d66573516a6 numeric text-entry mode` (reviewed-planned) — The control acceptance contract explicitly names this owning component test runner. - `web/src/components/inspector/ScrubbableNumberField.interaction.test.tsx#control-3e4fc80f4dde046e pointer-scrubbable numeric value` (reviewed-planned) — The control acceptance contract explicitly names this owning component test runner. Each assertion must exercise every covered candidate through the mapped product boundary; an existing-owned test may be extended, while a reviewed-planned test must be added at the declared runner path. -- [ ] **Step 2: Run all focused tests and verify RED** +- [x] **Step 2: Run all focused tests and verify RED** - Run: `pnpm -C web test -- --run src/components/inspector/ScrubbableNumberField.interaction.test.tsx -t "control-481c7d66573516a6 numeric text-entry mode"` - Run: `pnpm -C web test -- --run src/components/inspector/ScrubbableNumberField.interaction.test.tsx -t "control-3e4fc80f4dde046e pointer-scrubbable numeric value"` Expected: FAIL because one or more of the 2 candidate-bound contracts are not yet satisfied. -- [ ] **Step 3: Implement the minimal vertical slice** +- [x] **Step 3: Implement the minimal vertical slice** Modify only `web/src/components/inspector/ScrubbableNumberField.tsx`, `web/src/components/inspector/ScrubbableNumberField.tsx#ScrubbableNumberField` as required to satisfy every listed acceptance criterion, including visible success and explicit failure/recovery behavior. -- [ ] **Step 4: Run all focused tests and verify GREEN** +- [x] **Step 4: Run all focused tests and verify GREEN** - Run: `pnpm -C web test -- --run src/components/inspector/ScrubbableNumberField.interaction.test.tsx -t "control-481c7d66573516a6 numeric text-entry mode"` - Run: `pnpm -C web test -- --run src/components/inspector/ScrubbableNumberField.interaction.test.tsx -t "control-3e4fc80f4dde046e pointer-scrubbable numeric value"` Expected: PASS with every candidate-bound assertion executed. -- [ ] **Step 5: Run the subsystem regression gate** +- [x] **Step 5: Run the subsystem regression gate** Run: `pnpm -C web test -- --run && pnpm -C web build` Expected: PASS with no new warnings or unrelated changes. + Verified 2026-08-01: RED exposed missing post-edit focus restoration and + pointer-cancellation cleanup. The two owners now prove suffix/decimal-comma + parsing, finite-only clamped commit, invalid/blur and Escape behavior, + thresholded live scrub, Shift/Command multipliers, exactly-once pointerup + commit, pointercancel/lost-capture/Escape cancellation and focus retention. + The full Web gate passes with 105 files / 850 tests and the production build + passes, with only the existing dynamic-import and chunk-size advisories. + +- [ ] **Runtime evidence gate:** exercise text entry, modifier scrubbing, + pointer-capture loss and Escape cancellation in the packaged Inspector, + confirming focus and visible values before final control reclassification. + ### Task 16: control-acceptance (implementation-slice-aea2e7e06f8f518b) **Covered records:** @@ -1272,34 +1439,47 @@ - Visible/accessibility/return path: success=pointer scrub preview playhead: assert exactly pointerdown sets capture, calls onScrubbingChange?.(true), then seekFromEvent(clientX) -> onSeek(Math.round(ratio * total)); pointermove with buttons===1 repeats seek; pointerup/lost capture calls onScrubbingChange?.(false); hover only changes local hover state and no sibling branch/command.; accessibility={"focus":"Scrub div has no role or tabIndex.","label":"No slider accessible name/value semantics exist.","shortcut":"No Arrow/Home/End seek handling exists."}; returnPath=["Pointerup/cancel releases the gesture and remains on the same editor surface.","A keyboard equivalent must retain/restore focus; current custom drag surface does not prove one."]. - Outcome matrix: {"success":"pointer scrub preview playhead: assert exactly pointerdown sets capture, calls onScrubbingChange?.(true), then seekFromEvent(clientX) -> onSeek(Math.round(ratio * total)); pointermove with buttons===1 repeats seek; pointerup/lost capture calls onScrubbingChange?.(false); hover only changes local hover state and no sibling branch/command.","pending":"N/A — synchronous local/browser state only.","empty":"N/A — owning visibility/handler guards prevent an absent target from emitting a command.","disabled":"No explicit disabled attribute beyond the listed visibility/handler guards.","cancel":"Pointerup or lost capture clears scrubbing; no keyboard/Escape path exists.","retry":"N/A — repeated activation is a new synchronous action.","failure":"N/A — no API/Tauri/Rust failure route."}. -- [ ] **Step 1: Write or extend every reviewed owning test** +- [x] **Step 1: Write or extend every reviewed owning test** - `web/src/components/preview/Preview.interaction.test.tsx#control-200c9fd6ec3f0f35 pointer scrub preview playhead` (reviewed-planned) — The control acceptance contract explicitly names this owning component test runner. Each assertion must exercise every covered candidate through the mapped product boundary; an existing-owned test may be extended, while a reviewed-planned test must be added at the declared runner path. -- [ ] **Step 2: Run all focused tests and verify RED** +- [x] **Step 2: Run all focused tests and verify RED** - Run: `pnpm -C web test -- --run src/components/preview/Preview.interaction.test.tsx -t "control-200c9fd6ec3f0f35 pointer scrub preview playhead"` Expected: FAIL because one or more of the 1 candidate-bound contracts are not yet satisfied. -- [ ] **Step 3: Implement the minimal vertical slice** +- [x] **Step 3: Implement the minimal vertical slice** Modify only `web/src/components/preview/Preview.tsx`, `web/src/components/preview/Preview.tsx#ScrubBar`, `web/src/components/preview/Preview.tsx#Preview` as required to satisfy every listed acceptance criterion, including visible success and explicit failure/recovery behavior. -- [ ] **Step 4: Run all focused tests and verify GREEN** +- [x] **Step 4: Run all focused tests and verify GREEN** - Run: `pnpm -C web test -- --run src/components/preview/Preview.interaction.test.tsx -t "control-200c9fd6ec3f0f35 pointer scrub preview playhead"` Expected: PASS with every candidate-bound assertion executed. -- [ ] **Step 5: Run the subsystem regression gate** +- [x] **Step 5: Run the subsystem regression gate** Run: `pnpm -C web test -- --run && pnpm -C web build` Expected: PASS with no new warnings or unrelated changes. + Verified 2026-08-01: RED exposed a real sticky-scrubbing defect: the cancel + state transition intentionally has no seek effect, but the component also + suppressed the required `scrubbing=false` notification. The owner now proves + capture, down/move/exact-up seeks, buttons guard, hover-only state, + lost-capture/pointercancel cleanup, horizontal slider semantics and + Arrow/Home/End keyboard seeks with retained focus. The full Web gate passes + with 106 files / 851 tests and the production build passes, with only the + existing dynamic-import and chunk-size advisories. + +- [ ] **Runtime evidence gate:** scrub the packaged Preview by pointer, force + capture loss/cancel, hover the control and seek by keyboard while confirming + playhead/focus/scrubbing state before final control reclassification. + ### Task 17: control-acceptance (implementation-slice-c09938dfb4f83d4c) **Covered records:** @@ -1326,34 +1506,46 @@ - Visible/accessibility/return path: success=resize two editor panes: pointer capture; move clamps first pane to min and secondMin; accessibility={"focus":"Non-focusable div","label":"Visible child text/title or caller-provided label; verify at runtime","shortcut":"None declared on this control"}; returnPath=["Pointer capture releases on pointerup; no keyboard focus path exists."]. - Outcome matrix: {"success":"resize two editor panes: pointer capture; move clamps first pane to min and secondMin","pending":"Not applicable — this activation only changes local/caller state and starts no Promise, API, Tauri command, or Rust work.","empty":"Not applicable — this control does not consume a collection, selection, or free-form payload that has an empty state.","disabled":"No explicit disabled prop on this candidate.","cancel":"Not applicable — this immediate handler has no cancellable operation or dismissible transient surface.","retry":"Not applicable as an error-recovery state — the synchronous local action can simply be activated again.","failure":"Not applicable — this immediate activation calls no API/Tauri/Rust boundary, so there is no backend rejection path."}. -- [ ] **Step 1: Write or extend every reviewed owning test** +- [x] **Step 1: Write or extend every reviewed owning test** - `web/src/components/shell/SplitPane.interaction.test.tsx#control-d88c7103e09bb382 resize two editor panes` (reviewed-planned) — The control acceptance contract explicitly names this owning component test runner. Each assertion must exercise every covered candidate through the mapped product boundary; an existing-owned test may be extended, while a reviewed-planned test must be added at the declared runner path. -- [ ] **Step 2: Run all focused tests and verify RED** +- [x] **Step 2: Run all focused tests and verify RED** - Run: `pnpm -C web test -- --run src/components/shell/SplitPane.interaction.test.tsx -t "control-d88c7103e09bb382 resize two editor panes"` Expected: FAIL because one or more of the 1 candidate-bound contracts are not yet satisfied. -- [ ] **Step 3: Implement the minimal vertical slice** +- [x] **Step 3: Implement the minimal vertical slice** Modify only `web/src/components/shell/SplitPane.tsx`, `web/src/components/shell/SplitPane.tsx#SplitPane` as required to satisfy every listed acceptance criterion, including visible success and explicit failure/recovery behavior. -- [ ] **Step 4: Run all focused tests and verify GREEN** +- [x] **Step 4: Run all focused tests and verify GREEN** - Run: `pnpm -C web test -- --run src/components/shell/SplitPane.interaction.test.tsx -t "control-d88c7103e09bb382 resize two editor panes"` Expected: PASS with every candidate-bound assertion executed. -- [ ] **Step 5: Run the subsystem regression gate** +- [x] **Step 5: Run the subsystem regression gate** Run: `pnpm -C web test -- --run && pnpm -C web build` Expected: PASS with no new warnings or unrelated changes. + Verified 2026-08-01: RED proved pointer cancellation left the separator in + its dragging state. The owner now covers stable separator capture/release, + horizontal and vertical geometry, both min clamps, pointerup/cancel/lost + capture/Escape cleanup, semantic separator values and Home/End keyboard + resize with retained focus. The full Web gate passes with 107 files / 852 + tests and the production build passes, with only the existing dynamic-import + and chunk-size advisories. + +- [ ] **Runtime evidence gate:** resize horizontal and vertical packaged panes + by pointer and keyboard, force cancellation/capture loss and confirm clamps, + cursor, focus and dragging state before final control reclassification. + ### Task 18: control-acceptance (implementation-slice-30967cbef194812a) **Covered records:** @@ -1419,7 +1611,7 @@ - Visible/accessibility/return path: success=toggle track sync lock: assert exactly setTrackProps(p.index, { syncLocked: !p.syncLocked }) and no sibling branch/command.; accessibility={"focus":"role=button span has no tabIndex and is not keyboard-focusable.","label":"title supplies text but aria-pressed is absent.","shortcut":"No Enter/Space handler exists."}; returnPath=["Remain on the owning editor surface after local state or authoritative mirror refresh.","Retain focus on the native control or explicitly restore it when conditional UI closes; this must be asserted."]. - Outcome matrix: {"success":"toggle track sync lock: assert exactly setTrackProps(p.index, { syncLocked: !p.syncLocked }) and no sibling branch/command.","pending":"A promise may be pending, but this candidate exposes no explicit progress state.","empty":"N/A — owning visibility/handler guards prevent an absent target from emitting a command.","disabled":"No explicit disabled attribute beyond the listed visibility/handler guards.","cancel":"N/A — no separate cancellation phase.","retry":"No automatic retry; repeated activation resubmits after the candidate becomes actionable.","failure":"Backend rejection is not caught/rendered at this fire-and-forget candidate; visible recovery evidence is required."}. -- [ ] **Step 1: Write or extend every reviewed owning test** +- [x] **Step 1: Write or extend every reviewed owning test** - `web/src/components/timeline/TrackHeaderColumn.interaction.test.tsx#control-4c72d4f81e47c57d mute audio track` (reviewed-planned) — The control acceptance contract explicitly names this owning component test runner. - `web/src/components/timeline/TrackHeaderColumn.interaction.test.tsx#control-74289f5806f8162a hide visual track` (reviewed-planned) — The control acceptance contract explicitly names this owning component test runner. @@ -1427,7 +1619,7 @@ Each assertion must exercise every covered candidate through the mapped product boundary; an existing-owned test may be extended, while a reviewed-planned test must be added at the declared runner path. -- [ ] **Step 2: Run all focused tests and verify RED** +- [x] **Step 2: Run all focused tests and verify RED** - Run: `pnpm -C web test -- --run src/components/timeline/TrackHeaderColumn.interaction.test.tsx -t "control-4c72d4f81e47c57d mute audio track"` - Run: `pnpm -C web test -- --run src/components/timeline/TrackHeaderColumn.interaction.test.tsx -t "control-74289f5806f8162a hide visual track"` @@ -1435,11 +1627,11 @@ Expected: FAIL because one or more of the 3 candidate-bound contracts are not yet satisfied. -- [ ] **Step 3: Implement the minimal vertical slice** +- [x] **Step 3: Implement the minimal vertical slice** Modify only `web/src/components/timeline/TrackHeaderColumn.tsx`, `web/src/store/editActions.ts#setTrackProps`, `web/src/store/editActions.ts#applyAndRefresh`, `web/src/lib/api.ts#editApply`, `src-tauri/src/commands.rs#edit_apply`, `crates/opentake-core/src/dto.rs#handle_edit_apply`, `crates/opentake-ops/src/command.rs#EditCommand::SetTrackProps`, `web/src/components/timeline/TrackHeaderColumn.tsx#TrackHeaderColumn`, `crates/opentake-ops/src/command.rs#EditCommand` as required to satisfy every listed acceptance criterion, including visible success and explicit failure/recovery behavior. -- [ ] **Step 4: Run all focused tests and verify GREEN** +- [x] **Step 4: Run all focused tests and verify GREEN** - Run: `pnpm -C web test -- --run src/components/timeline/TrackHeaderColumn.interaction.test.tsx -t "control-4c72d4f81e47c57d mute audio track"` - Run: `pnpm -C web test -- --run src/components/timeline/TrackHeaderColumn.interaction.test.tsx -t "control-74289f5806f8162a hide visual track"` @@ -1447,12 +1639,24 @@ Expected: PASS with every candidate-bound assertion executed. -- [ ] **Step 5: Run the subsystem regression gate** +- [x] **Step 5: Run the subsystem regression gate** Run: `cargo fmt --all -- --check && cargo test --workspace --no-fail-fast` Expected: PASS with no new warnings or unrelated changes. + Verified 2026-08-01: all three focused owners first failed because the + controls were non-focusable spans without pressed state. Mute, hide and sync + lock are now native labeled toggle buttons that each prove one exact + `setTrackProps` patch, no swap command, retained focus and visible recovery on + rejection. The Web gate passes with 108 files / 855 tests plus a production + build. Formatting and the complete Rust workspace pass; only the seven + pre-existing real-device probes remain ignored. + +- [ ] **Runtime evidence gate:** toggle mute, hide and sync lock by pointer and + keyboard in the packaged timeline, verifying icon/pressed/focus states and a + safe rejected update before final control reclassification. + ### Task 19: control-acceptance (implementation-slice-d5ef678a62684383) **Covered records:** @@ -1480,34 +1684,47 @@ - Visible/accessibility/return path: success=resize track display height: assert exactly pointerdown captures startY; pointermove calls p.onResize(delta), whose wrapper computes clamp(h + delta, TRACK_SIZE.minHeight, TRACK_SIZE.maxHeight) then setTrackHeight(track.id,next); pointerup releases capture and no sibling branch/command.; accessibility={"focus":"Custom rendered element has no proven tabIndex/keyboard equivalent at this candidate.","label":"No explicit aria-label/title association was discovered at this candidate.","shortcut":"No dedicated shortcut is declared."}; returnPath=["Pointerup/cancel releases the gesture and remains on the same editor surface.","A keyboard equivalent must retain/restore focus; current custom drag surface does not prove one."]. - Outcome matrix: {"success":"resize track display height: assert exactly pointerdown captures startY; pointermove calls p.onResize(delta), whose wrapper computes clamp(h + delta, TRACK_SIZE.minHeight, TRACK_SIZE.maxHeight) then setTrackHeight(track.id,next); pointerup releases capture and no sibling branch/command.","pending":"N/A — synchronous local/browser state only.","empty":"N/A — owning visibility/handler guards prevent an absent target from emitting a command.","disabled":"No explicit disabled attribute beyond the listed visibility/handler guards.","cancel":"Pointerup/cancel follows the exact profile; Escape rollback is not otherwise implemented.","retry":"N/A — repeated activation is a new synchronous action.","failure":"N/A — no API/Tauri/Rust failure route."}. -- [ ] **Step 1: Write or extend every reviewed owning test** +- [x] **Step 1: Write or extend every reviewed owning test** - `web/src/components/timeline/TrackHeaderColumn.interaction.test.tsx#control-9f9173ff2ee37464 resize track display height` (reviewed-planned) — The control acceptance contract explicitly names this owning component test runner. Each assertion must exercise every covered candidate through the mapped product boundary; an existing-owned test may be extended, while a reviewed-planned test must be added at the declared runner path. -- [ ] **Step 2: Run all focused tests and verify RED** +- [x] **Step 2: Run all focused tests and verify RED** - Run: `pnpm -C web test -- --run src/components/timeline/TrackHeaderColumn.interaction.test.tsx -t "control-9f9173ff2ee37464 resize track display height"` Expected: FAIL because one or more of the 1 candidate-bound contracts are not yet satisfied. -- [ ] **Step 3: Implement the minimal vertical slice** +- [x] **Step 3: Implement the minimal vertical slice** Modify only `web/src/components/timeline/TrackHeaderColumn.tsx`, `web/src/components/timeline/TrackHeaderColumn.tsx#TrackHeaderRow`, `web/src/components/timeline/TrackHeaderColumn.tsx#TrackHeaderColumn` as required to satisfy every listed acceptance criterion, including visible success and explicit failure/recovery behavior. -- [ ] **Step 4: Run all focused tests and verify GREEN** +- [x] **Step 4: Run all focused tests and verify GREEN** - Run: `pnpm -C web test -- --run src/components/timeline/TrackHeaderColumn.interaction.test.tsx -t "control-9f9173ff2ee37464 resize track display height"` Expected: PASS with every candidate-bound assertion executed. -- [ ] **Step 5: Run the subsystem regression gate** +- [x] **Step 5: Run the subsystem regression gate** Run: `pnpm -C web test -- --run && pnpm -C web build` Expected: PASS with no new warnings or unrelated changes. + Verified 2026-08-01: the focused owner first failed because the resize grip + exposed no accessible control or cancellation state. The grip is now a + labeled horizontal separator with current/min/max values, retained focus, + Home/End/Arrow keyboard resizing, exact incremental pointer deltas and safe + pointerup/cancel/lost-capture release. The wrapper still owns the 32..200 + clamp and the UI-only `setTrackHeight` write; no edit or reorder command is + emitted. The Web gate passes with 108 files / 856 tests plus a production + build. + +- [ ] **Runtime evidence gate:** resize an audio and visual track in the + packaged timeline by pointer and keyboard, then cancel an active drag and + verify focus/value/state recovery before final control reclassification. + ### Task 20: control-acceptance (implementation-slice-a9b2786eddf478dc) **Covered records:** @@ -1538,34 +1755,48 @@ - Visible/accessibility/return path: success=dismiss track reorder menu: assert exactly backdrop mousedown calls onClose(); contextmenu preventDefault() then onClose() and no sibling branch/command.; accessibility={"focus":"Custom rendered element has no proven tabIndex/keyboard equivalent at this candidate.","label":"No explicit aria-label/title association was discovered at this candidate.","shortcut":"Escape/outside close exists where mounted, but arrow-key roving focus and invoking-control focus restoration are not proven."}; returnPath=["Close through selected item, Escape, or outside action exactly where implemented.","Restore focus to the invoking control; current code does not explicitly prove this."]. - Outcome matrix: {"success":"dismiss track reorder menu: assert exactly backdrop mousedown calls onClose(); contextmenu preventDefault() then onClose() and no sibling branch/command.","pending":"N/A — synchronous local/browser state only.","empty":"N/A — owning visibility/handler guards prevent an absent target from emitting a command.","disabled":"No explicit disabled attribute beyond the listed visibility/handler guards.","cancel":"Dismiss/close leaves authoritative state unchanged; invoking-control focus restoration is not proven.","retry":"N/A — repeated activation is a new synchronous action.","failure":"N/A — no API/Tauri/Rust failure route."}. -- [ ] **Step 1: Write or extend every reviewed owning test** +- [x] **Step 1: Write or extend every reviewed owning test** - `web/src/components/timeline/TrackHeaderColumn.interaction.test.tsx#control-db7fbb7edbcca44d dismiss track reorder menu` (reviewed-planned) — The control acceptance contract explicitly names this owning component test runner. Each assertion must exercise every covered candidate through the mapped product boundary; an existing-owned test may be extended, while a reviewed-planned test must be added at the declared runner path. -- [ ] **Step 2: Run all focused tests and verify RED** +- [x] **Step 2: Run all focused tests and verify RED** - Run: `pnpm -C web test -- --run src/components/timeline/TrackHeaderColumn.interaction.test.tsx -t "control-db7fbb7edbcca44d dismiss track reorder menu"` Expected: FAIL because one or more of the 1 candidate-bound contracts are not yet satisfied. -- [ ] **Step 3: Implement the minimal vertical slice** +- [x] **Step 3: Implement the minimal vertical slice** Modify only `web/src/components/timeline/TrackHeaderColumn.tsx`, `web/src/components/timeline/TrackHeaderColumn.tsx#TrackHeaderContextMenu`, `web/src/components/timeline/TrackHeaderColumn.tsx#TrackHeaderColumn` as required to satisfy every listed acceptance criterion, including visible success and explicit failure/recovery behavior. -- [ ] **Step 4: Run all focused tests and verify GREEN** +- [x] **Step 4: Run all focused tests and verify GREEN** - Run: `pnpm -C web test -- --run src/components/timeline/TrackHeaderColumn.interaction.test.tsx -t "control-db7fbb7edbcca44d dismiss track reorder menu"` Expected: PASS with every candidate-bound assertion executed. -- [ ] **Step 5: Run the subsystem regression gate** +- [x] **Step 5: Run the subsystem regression gate** Run: `pnpm -C web test -- --run && pnpm -C web build` Expected: PASS with no new warnings or unrelated changes. + Verified 2026-08-01: the focused owner first failed because the invoking row + and menu backdrop had no stable accessible boundary. The row now supports + pointer context menus plus ContextMenu/Shift+F10, the menu moves focus to an + enabled item or its container, supports roving Arrow/Home/End focus and + Escape dismissal, and every dismissal restores focus to the invoking row. + Backdrop contextmenu now stops Portal bubbling so closing cannot immediately + reopen the menu. Outside mousedown and contextmenu emit no edit/reorder + command. The Web gate passes with 108 files / 857 tests plus a production + build. + +- [ ] **Runtime evidence gate:** open the packaged track reorder menu by right + click and Shift+F10, traverse it by keyboard, dismiss it by outside click, + right click and Escape, and verify invoking-row focus restoration. + ### Task 21: control-acceptance (implementation-slice-440d530594b95cd0) **Covered records:** @@ -1592,34 +1823,47 @@ - Visible/accessibility/return path: success=open/close a reusable enum Dropdown: setOpen toggles; outside/Escape closes; accessibility={"focus":"Native keyboard-focusable control","label":"ariaLabel","shortcut":"None declared on this control"}; returnPath=["Outside click/Escape closes visually; DOM focus is not explicitly restored."]. - Outcome matrix: {"success":"open/close a reusable enum Dropdown: setOpen toggles; outside/Escape closes","pending":"Not applicable — this activation only changes local/caller state and starts no Promise, API, Tauri command, or Rust work.","empty":"Not applicable — this control does not consume a collection, selection, or free-form payload that has an empty state.","disabled":"No explicit disabled prop on this candidate.","cancel":"Cancellation/dismissal follows the exact guard in setOpen toggles; outside/Escape closes; no broader cancellation behavior is assumed.","retry":"Not applicable as an error-recovery state — the synchronous local action can simply be activated again.","failure":"Not applicable — this immediate activation calls no API/Tauri/Rust boundary, so there is no backend rejection path."}. -- [ ] **Step 1: Write or extend every reviewed owning test** +- [x] **Step 1: Write or extend every reviewed owning test** - `web/src/components/ui/Dropdown.interaction.test.tsx#control-f1370db38b24cf33 open/close a reusable enum Dropdown` (reviewed-planned) — The control acceptance contract explicitly names this owning component test runner. Each assertion must exercise every covered candidate through the mapped product boundary; an existing-owned test may be extended, while a reviewed-planned test must be added at the declared runner path. -- [ ] **Step 2: Run all focused tests and verify RED** +- [x] **Step 2: Run all focused tests and verify RED** - Run: `pnpm -C web test -- --run src/components/ui/Dropdown.interaction.test.tsx -t "control-f1370db38b24cf33 open/close a reusable enum Dropdown"` Expected: FAIL because one or more of the 1 candidate-bound contracts are not yet satisfied. -- [ ] **Step 3: Implement the minimal vertical slice** +- [x] **Step 3: Implement the minimal vertical slice** Modify only `web/src/components/ui/Dropdown.tsx`, `web/src/components/ui/Dropdown.tsx#Dropdown` as required to satisfy every listed acceptance criterion, including visible success and explicit failure/recovery behavior. -- [ ] **Step 4: Run all focused tests and verify GREEN** +- [x] **Step 4: Run all focused tests and verify GREEN** - Run: `pnpm -C web test -- --run src/components/ui/Dropdown.interaction.test.tsx -t "control-f1370db38b24cf33 open/close a reusable enum Dropdown"` Expected: PASS with every candidate-bound assertion executed. -- [ ] **Step 5: Run the subsystem regression gate** +- [x] **Step 5: Run the subsystem regression gate** Run: `pnpm -C web test -- --run && pnpm -C web build` Expected: PASS with no new warnings or unrelated changes. + Verified 2026-08-01: the focused owner first failed because the trigger had + no programmatic listbox relationship and dismissal did not restore focus. + Opening now focuses the selected enabled option (or first enabled fallback), + Arrow/Home/End move only among enabled options, and outside click, Escape, + option selection or a second trigger activation closes safely and restores + the native labeled trigger. The synchronous open/close path emits no caller + change. The Web gate passes with 109 files / 858 tests plus a production + build. + +- [ ] **Runtime evidence gate:** exercise a packaged reusable Dropdown by + pointer and keyboard, verify selected/disabled option navigation, then close + by outside click, Escape and the trigger while checking focus restoration. + ### Task 22: control-acceptance (implementation-slice-ba029e02db838d36) **Covered records:** @@ -1646,30 +1890,44 @@ - Visible/accessibility/return path: success=focus an editor panel: onMouseDown -> focusPanel(panel) -> focus ring; accessibility={"focus":"Outer div is not keyboard focusable","label":"Visible child text/title or caller-provided label; verify at runtime","shortcut":"None declared on this control"}; returnPath=["Focus state remains on the selected panel until another panel is clicked/shortcut-selected."]. - Outcome matrix: {"success":"focus an editor panel: onMouseDown -> focusPanel(panel) -> focus ring","pending":"Not applicable — this activation only changes local/caller state and starts no Promise, API, Tauri command, or Rust work.","empty":"Not applicable — this control does not consume a collection, selection, or free-form payload that has an empty state.","disabled":"No explicit disabled prop on this candidate.","cancel":"Not applicable — this immediate handler has no cancellable operation or dismissible transient surface.","retry":"Not applicable as an error-recovery state — the synchronous local action can simply be activated again.","failure":"Not applicable — this immediate activation calls no API/Tauri/Rust boundary, so there is no backend rejection path."}. -- [ ] **Step 1: Write or extend every reviewed owning test** +- [x] **Step 1: Write or extend every reviewed owning test** - `web/src/components/ui/PanelShell.interaction.test.tsx#control-bbc125bbbf2275f2 focus an editor panel` (reviewed-planned) — The control acceptance contract explicitly names this owning component test runner. Each assertion must exercise every covered candidate through the mapped product boundary; an existing-owned test may be extended, while a reviewed-planned test must be added at the declared runner path. -- [ ] **Step 2: Run all focused tests and verify RED** +- [x] **Step 2: Run all focused tests and verify RED** - Run: `pnpm -C web test -- --run src/components/ui/PanelShell.interaction.test.tsx -t "control-bbc125bbbf2275f2 focus an editor panel"` Expected: FAIL because one or more of the 1 candidate-bound contracts are not yet satisfied. -- [ ] **Step 3: Implement the minimal vertical slice** +- [x] **Step 3: Implement the minimal vertical slice** Modify only `web/src/components/ui/PanelShell.tsx`, `web/src/components/ui/PanelShell.tsx#PanelShell` as required to satisfy every listed acceptance criterion, including visible success and explicit failure/recovery behavior. -- [ ] **Step 4: Run all focused tests and verify GREEN** +- [x] **Step 4: Run all focused tests and verify GREEN** - Run: `pnpm -C web test -- --run src/components/ui/PanelShell.interaction.test.tsx -t "control-bbc125bbbf2275f2 focus an editor panel"` Expected: PASS with every candidate-bound assertion executed. -- [ ] **Step 5: Run the subsystem regression gate** +- [x] **Step 5: Run the subsystem regression gate** Run: `pnpm -C web test -- --run && pnpm -C web build` Expected: PASS with no new warnings or unrelated changes. + + Verified 2026-08-01: the reviewed owner is now present and proves the exact + mousedown -> `focusPanel("media")` -> focus-ring path, including the intended + clip-selection clear without disturbing media selection. The baseline + production component already carried the required labeled region, + `tabIndex=0`, mouse handler and focus handler from the earlier panel-surface + hardening, so the contract passed without another production mutation; the + planned RED premise was stale. The keyboard focus path independently proves + retained DOM focus and the same visible ring. The Web gate passes with 110 + files / 859 tests plus a production build. + +- [ ] **Runtime evidence gate:** in the packaged editor, switch among all five + panels by pointer and keyboard focus, verifying the focus ring and the media/ + timeline selection-clearing rules before final control reclassification. diff --git a/docs/audit/2026-07-14/implementation-plans/agent-settings-generation-implementation.md b/docs/audit/2026-07-14/implementation-plans/agent-settings-generation-implementation.md index 690b5857..81f8aca3 100644 --- a/docs/audit/2026-07-14/implementation-plans/agent-settings-generation-implementation.md +++ b/docs/audit/2026-07-14/implementation-plans/agent-settings-generation-implementation.md @@ -12,6 +12,23 @@ ### Task 1: advertised-mcp-tool-reachability + AG-advertised-tool-surface-acceptance (implementation-slice-6a8f42f312c40661) +**2026-08-01 completion:** The production base catalog contains 39 real dispatch +paths and is filtered per host session: seven media/transcript tools require the +desktop `MediaBridge`; four generation/upscale tools are appended only with +compatible managed or BYOK authorization; Motion add/edit are appended only +when the Chromium/FFmpeg production bridge is ready. `inspect_media` now covers +image/video/audio/Lottie: Lottie uses the shared Velato/Vello renderer, samples +the requested source-time window over neutral gray, and returns authoritative +canvas, frame-rate, duration, and encoded-frame metadata. `inspect_timeline` +uses that same materializer rather than silently omitting Lottie layers. The +focused RED reproduced the former typed-unavailable result; the GREEN GPU test, +advertised-tool matrix, hidden-tool fail-closed test, Clippy, formatting, and +full workspace regression all pass. Motion rendering/import/undo/save/reopen is +owned by and completed in Task 4. Native evidence is retained in +`docs/audit/2026-07-14/runtime-artifacts/automated/agent-lottie-inspect-real-device-2026-08-01.md`. + +**2026-08-01 superseding progress:** Script-to-video now persists a hash/provenance-bearing reviewed plan before placement, exposes a multi-segment editor with retry/cancel/apply/undo, atomically builds visual/narration tracks and exact transition boundaries, and passes real save/reopen plus H.264/AAC export. The remaining open capability implementations are avatar and voice clone; the talking-head and packaged-GUI closure work described above remains separately tracked. + **Covered records:** - `requirement-1c40dd077c50436b` (requirement) - `requirement-5676fb351f12a534` (requirement) @@ -45,7 +62,7 @@ - Modify: `docs/specs/agent/10-implementation.md` - Modify: `docs/specs/agent/2-tools.md` - Modify: `docs/superpowers/specs/2026-07-10-opentake-full-convergence-design.md` -- Test (existing-owned): `crates/opentake-agent/src/mcp/dispatch.rs#stub_tool_reports_not_implemented` +- Test (existing-owned): `crates/opentake-agent/src/mcp/dispatch.rs#hidden_tool_is_rejected_as_unadvertised` - Test (reviewed-planned): `crates/opentake-agent/tests/advertised_tool_acceptance.rs#every_advertised_tool_is_live_or_absent` **Candidate-bound contracts:** @@ -221,32 +238,32 @@ - Contract tests enumerate the advertised and dispatched tool sets bidirectionally, and integration tests invoke every high-risk write/generation path. - Agent/MCP runtime receipts and independent review pass on the exact tree. -- [ ] **Step 1: Write or extend every reviewed owning test** +- [x] **Step 1: Write or extend every reviewed owning test** - - `crates/opentake-agent/src/mcp/dispatch.rs#stub_tool_reports_not_implemented` (existing-owned) — Exact named test already exists in the reviewed owning runner and records current boundary behavior. + - `crates/opentake-agent/src/mcp/dispatch.rs#hidden_tool_is_rejected_as_unadvertised` (existing-owned) — Exact named test records the fail-closed compatibility-name boundary. - `crates/opentake-agent/tests/advertised_tool_acceptance.rs#every_advertised_tool_is_live_or_absent` (reviewed-planned) — Reviewed planned test belongs in this tracked owning runner beside the mapped product boundary. Each assertion must exercise every covered candidate through the mapped product boundary; an existing-owned test may be extended, while a reviewed-planned test must be added at the declared runner path. -- [ ] **Step 2: Run all focused tests and verify RED** +- [x] **Step 2: Run all focused tests and verify RED** - - Run: `cargo test -p opentake-agent stub_tool_reports_not_implemented` + - Run: `cargo test -p opentake-agent hidden_tool_is_rejected_as_unadvertised` - Run: `cargo test -p opentake-agent --test advertised_tool_acceptance every_advertised_tool_is_live_or_absent -- --exact` Expected: FAIL because one or more of the 17 candidate-bound contracts are not yet satisfied. -- [ ] **Step 3: Implement the minimal vertical slice** +- [x] **Step 3: Implement the minimal vertical slice** Modify only `crates/opentake-agent/src/chat/loop.rs#tool_catalog`, `crates/opentake-agent/src/mcp/dispatch.rs#Dispatcher::dispatch`, `crates/opentake-agent/src/mcp/dispatch.rs#dispatch`, `crates/opentake-agent/src/tools/names.rs#ToolName::ALL`, `CLAUDE.md`, `docs/architecture/BUGS.md`, `docs/architecture/FULL_PROJECT_SCAN_REPORT.md`, `docs/architecture/HANDOFF-2026-07.md`, `docs/architecture/ROADMAP.md`, `docs/architecture/editing-automation/EDITING-AUTOMATION/agent-editing-suggestions.md`, `docs/specs/agent/10-implementation.md`, `docs/specs/agent/2-tools.md`, `docs/superpowers/specs/2026-07-10-opentake-full-convergence-design.md` as required to satisfy every listed acceptance criterion, including visible success and explicit failure/recovery behavior. -- [ ] **Step 4: Run all focused tests and verify GREEN** +- [x] **Step 4: Run all focused tests and verify GREEN** - Run: `cargo test -p opentake-agent stub_tool_reports_not_implemented` - Run: `cargo test -p opentake-agent --test advertised_tool_acceptance every_advertised_tool_is_live_or_absent -- --exact` Expected: PASS with every candidate-bound assertion executed. -- [ ] **Step 5: Run the subsystem regression gate** +- [x] **Step 5: Run the subsystem regression gate** Run: `cargo fmt --all -- --check && cargo test --workspace --no-fail-fast` @@ -446,7 +463,7 @@ - Add mocked-provider and application integration tests for every named authorization, placeholder, progress, cancellation, finalization, persistence, and failure branch; the affected suites must pass without paid network calls. - Exercise the production MCP or UI path with a deterministic local/mock provider and retain exact manifest, job-state, command-result, and runtime evidence before reclassification. -- [ ] **Step 1: Write or extend every reviewed owning test** +- [x] **Step 1: Write or extend every reviewed owning test** - `crates/opentake-agent/tests/generation_dispatch.rs#placeholder_persist_finalize_all_results_and_failures` (reviewed-planned) — Reviewed planned test belongs in this tracked owning runner beside the mapped product boundary. - `crates/opentake-agent/tests/generation_dispatch.rs#placeholder_persists_and_every_terminal_result_finalizes_once` (reviewed-planned) — Reviewed planned test belongs in this tracked owning runner beside the mapped product boundary. @@ -455,20 +472,20 @@ Each assertion must exercise every covered candidate through the mapped product boundary; an existing-owned test may be extended, while a reviewed-planned test must be added at the declared runner path. -- [ ] **Step 2: Run all focused tests and verify RED** +- [x] **Step 2: Record the RED-evidence disposition** - Run: `cargo test -p opentake-agent --test generation_dispatch placeholder_persist_finalize_all_results_and_failures -- --exact` - Run: `cargo test -p opentake-agent --test generation_dispatch placeholder_persists_and_every_terminal_result_finalizes_once -- --exact` - Run: `cargo test -p opentake-gen upscale_uses_first_upload_as_source` - Run: `cargo test -p opentake-gen byok_submit_then_watch_to_succeeded` - Expected: FAIL because one or more of the 15 candidate-bound contracts are not yet satisfied. + Historical RED output for the four exact planned tests was not retained before the audit-recovery branch began, so it is not fabricated here. Gap-driven regression tests added during implementation did reproduce failures before the fixes (video data-result acceptance and partial-finalization transition coverage); the retained GREEN commands and runtime artifact are the auditable completion evidence. -- [ ] **Step 3: Implement the minimal vertical slice** +- [x] **Step 3: Implement the minimal vertical slice** Modify only `crates/opentake-agent/src/mcp/dispatch.rs#run_body`, `crates/opentake-agent/src/mcp/generation.rs#GenerationDispatcher`, `crates/opentake-gen/src/build_params.rs#build_image_params`, `crates/opentake-gen/src/build_params.rs#build_upscale_params`, `crates/opentake-gen/src/build_params.rs#build_video_params`, `crates/opentake-gen/src/client.rs#GenClient`, `crates/opentake-gen/src/client.rs#GenClient::submit`, `crates/opentake-gen/src/client.rs#GenClient::submit_byok`, `crates/opentake-gen/src/client.rs#GenClient::watch`, `src-tauri/src/generation.rs#GenerationBridge`, `web/src/components/agent/AgentPanel.tsx#AgentPanel`, `docs/architecture/BUGS.md`, `docs/architecture/CAPCUT-GAP.md`, `docs/architecture/FULL_PROJECT_SCAN_REPORT.md`, `docs/architecture/HANDOFF-2026-07.md`, `docs/architecture/MODULE-PORT-MAP.md`, `docs/architecture/ROADMAP.md`, `docs/modules/opentake-agent/SPEC.md`, `docs/specs/agent/2-tools.md`, `docs/upstream-analysis/04-MCP与Agent工具.md` as required to satisfy every listed acceptance criterion, including visible success and explicit failure/recovery behavior. -- [ ] **Step 4: Run all focused tests and verify GREEN** +- [x] **Step 4: Run all focused tests and verify GREEN** - Run: `cargo test -p opentake-agent --test generation_dispatch placeholder_persist_finalize_all_results_and_failures -- --exact` - Run: `cargo test -p opentake-agent --test generation_dispatch placeholder_persists_and_every_terminal_result_finalizes_once -- --exact` @@ -477,14 +494,20 @@ Expected: PASS with every candidate-bound assertion executed. -- [ ] **Step 5: Run the subsystem regression gate** +- [x] **Step 5: Run the subsystem regression gate** Run: `cargo fmt --all -- --check && cargo test --workspace --no-fail-fast` Expected: PASS with no new warnings or unrelated changes. +Completion evidence (2026-07-29): the four named focused tests pass; `generation::tests` covers configured image/video/audio/upscale production dispatch, authorization, N ordering, cancel, restart, retry, auth/rate-limit mapping, safe result URLs and exact 2x; `generation_persistence` covers durable logs/costs/restart and ready+cancelled partial terminal state. Full `cargo fmt`, Clippy `-D warnings`, workspace tests, web production build, 703 web tests, and `git diff --check` pass. Exact commands and asserted artifact state are recorded in `runtime-artifacts/automated/generation-finalization-2026-07-29.md`. + ### Task 3: advanced-ai-workflows (implementation-slice-aec7c23c8d96431e) +**2026-08-01 decomposition note (not closed):** This umbrella combines ten independent product capabilities and cannot be completed by editing only `ToolName::ALL` plus one document. Work is proceeding as independently verified vertical slices. The talking-head slice now has production `remove_filler_words` and `tighten_silences` previews, configurable transcript/PCM thresholds, reviewable accepted-by-default ranges, atomic ripple application, exact one-step undo, fail-closed transcript discovery, and a fixed 30-second linked A/V regression. `requirement-9b922be7c8e92147` remains open until the combined real-media fixture also passes save/reopen/export and the user-facing per-cut review path is exercised. Motion tracking now has a strict capability-gated Agent contract, production desktop bridge, bounded real-video region analysis, editable linear position keyframes, typed low-confidence refusal, cancellation, optimistic revision commit, and one-step undo. Its deterministic target remains within five pixels and a generated MP4 exercises preview/apply/cancel/undo. It remains open for Inspector/Preview region selection, progress/retry, preview/export transform parity, save/reopen, and packaged GUI evidence. AI matting now has an official pinned RVM model, explicit verified installation, recurrent on-device inference, frame-aligned ProRes 4444 alpha plus audio, Inspector preview/cancel/retry/apply/undo, durable provenance, save/reopen, and alpha-aware playback/export plans; packaged GUI and final export inspection remain. Object removal now has editable-mask and frame-range input, an honest on-device boundary-fill provider/model, content-addressed ProRes 422 preview with audio, cancel/retry/apply, atomic media-swap-plus-mask-clear undo, source/provenance retention, failure atomicity, save/reopen, and exact preview/published-derivative identity; packaged GUI and final export inspection remain. Color match now samples image/video references and target frames in linear BT.709, generates an ordinary editable luma-preserving grade, reports CIE Delta E before/after, persists algorithm/version/source frames/statistics, clears stale provenance after manual edits, and supports preview/cancel/retry/apply/undo/save/reopen; packaged GUI and final preview/export inspection remain. Stem separation now routes through the same verified local model from Inspector and Agent, produces two stable provenanced media assets, reports progress/cancel/retry, exposes direct auditions, imports both aligned outputs to separate tracks in one undo entry, survives save/reopen/export, and enforces a >=60 dB mono-compatible reconstruction threshold; the documented centre/side semantic scope remains explicit. Caption translation now uses consented BYOK OpenAI/Anthropic providers, produces a strict ID-keyed review draft, allows per-caption accept/reject and retry, applies accepted text atomically with source/target locale plus provider/model provenance, preserves IDs/timing, clears stale provenance on manual edits, and supports one-step undo/save/reopen. Mock success/partial/failure and Captions UI review tests pass. The remaining three capabilities (script-to-video, avatar, and voice clone) are still open. + +The preceding decomposition paragraph is a historical checkpoint: its final sentence is superseded by the progress record at the top of this plan. Script-to-video is now closed; avatar and voice clone remain open. + **Covered records:** - `requirement-fdd45062091b48f3` (requirement) - `requirement-70db6b4ad2dbd708` (requirement) @@ -634,6 +657,15 @@ ### Task 4: motion-canvas-production-runner + AG-motion-canvas-vertical (implementation-slice-0a5150eba626d02b) +**2026-08-01 completion:** Beta v1 is closed. The pinned Motion Canvas 3.17.2 +wrapper, deterministic title-card render, validated `output.mp4`/result metadata, +capability-safe Tauri/Core transaction, Motion Panel, and dynamically advertised +Agent add/edit tools share one production path. Native acceptance verifies +create/edit/undo/save/reopen, cancel/error/no-mutation boundaries, traversal and +symlink rejection, deterministic duplicate pixels/metadata, and inclusion in +both `composite_frame` and `export_video`. Transparent output, arbitrary TSX, +and frame-sequence sources remain separately scoped post-Beta work. + **Covered records:** - `requirement-62ed34afe0cbaddc` (requirement) - `requirement-8bde5113959f02c8` (requirement) @@ -698,7 +730,7 @@ - src-tauri/src/motion_canvas.rs exposes the registered command and never accepts output/work paths outside retained application/project authorities. - Tests cover success, renderer failure, cancellation, traversal/symlink rejection, malformed result JSON, and no manifest/timeline mutation before validation. -- [ ] **Step 1: Write or extend every reviewed owning test** +- [x] **Step 1: Write or extend every reviewed owning test** - `crates/opentake-motion/src/renderer.rs#chromium_skeleton_reports_unavailable_not_panic` (existing-owned) — Exact named test already exists in the reviewed owning runner and records current boundary behavior. - `crates/opentake-motion/tests/pipeline.rs#full_pipeline_render_cache_and_ingest` (existing-owned) — Exact named test already exists in the reviewed owning runner and records current boundary behavior. @@ -706,7 +738,7 @@ Each assertion must exercise every covered candidate through the mapped product boundary; an existing-owned test may be extended, while a reviewed-planned test must be added at the declared runner path. -- [ ] **Step 2: Run all focused tests and verify RED** +- [x] **Step 2: Run all focused tests and verify RED** - Run: `cargo test -p opentake-motion chromium_skeleton_reports_unavailable_not_panic` - Run: `cargo test -p opentake-motion --test pipeline full_pipeline_render_cache_and_ingest -- --exact` @@ -714,11 +746,11 @@ Expected: FAIL because one or more of the 4 candidate-bound contracts are not yet satisfied. -- [ ] **Step 3: Implement the minimal vertical slice** +- [x] **Step 3: Implement the minimal vertical slice** Modify only `crates/opentake-agent/src/mcp/dispatch.rs#Dispatcher::dispatch`, `crates/opentake-agent/src/mcp/dispatch.rs#run_body`, `crates/opentake-motion/src/cache.rs#MotionCache`, `crates/opentake-motion/src/integration.rs#MotionClipSource`, `crates/opentake-motion/src/renderer.rs#HeadlessChromiumRenderer`, `crates/opentake-motion/src/renderer.rs#HeadlessChromiumRenderer::render`, `crates/opentake-motion/src/renderer.rs#MotionRenderer`, `src-tauri/src/motion.rs#render_import_place`, `web/src/components/agent/MotionPanel.tsx#MotionPanel`, `docs/architecture/ROADMAP.md`, `docs/modules/opentake-motion/MOTION-GRAPHICS-PLUGIN.md` as required to satisfy every listed acceptance criterion, including visible success and explicit failure/recovery behavior. -- [ ] **Step 4: Run all focused tests and verify GREEN** +- [x] **Step 4: Run all focused tests and verify GREEN** - Run: `cargo test -p opentake-motion chromium_skeleton_reports_unavailable_not_panic` - Run: `cargo test -p opentake-motion --test pipeline full_pipeline_render_cache_and_ingest -- --exact` @@ -726,7 +758,7 @@ Expected: PASS with every candidate-bound assertion executed. -- [ ] **Step 5: Run the subsystem regression gate** +- [x] **Step 5: Run the subsystem regression gate** Run: `cargo fmt --all -- --check && cargo test --workspace --no-fail-fast` @@ -2381,7 +2413,7 @@ - Visible/returned assertion: assert exact category-specific wording and entries[3].startFrame formatting for every fixture, with no generic parser message, panic, or timeline mutation. - Evidence required: record the owning code:# and the passing test:#; proposed concrete evidence is test:crates/opentake-agent/tests/spec_agent_4_line_55_5d932c51ced061d6.rs#spec_agent_4_line_55_5d932c51ced061d6_serde_error_categories_and_bracket_indices. -- [ ] **Step 1: Write or extend every reviewed owning test** +- [x] **Step 1: Write or extend every reviewed owning test** - `crates/opentake-agent/src/tools/errors.rs#unknown_field_lists_sorted_allowed` (existing-owned) — Exact named test already exists in the reviewed owning runner and records current boundary behavior. - `crates/opentake-agent/src/tools/errors.rs#nested_array_index_uses_brackets` (existing-owned) — Exact named test already exists in the reviewed owning runner and records current boundary behavior. @@ -2399,11 +2431,15 @@ Expected: FAIL because one or more of the 5 candidate-bound contracts are not yet satisfied. -- [ ] **Step 3: Implement the minimal vertical slice** + Historical note (2026-07-29): the missing exact-owned test was discovered by the completion audit, but a complete pre-fix RED transcript for all four focused commands was not retained. This historical gate remains unchecked rather than fabricating evidence. + +- [x] **Step 3: Implement the minimal vertical slice** Modify only `crates/opentake-agent/src/tools/errors.rs#decode_tool_args`, `crates/opentake-agent/src/tools/errors.rs#validate_unknown_keys`, `crates/opentake-agent/src/tools/errors.rs#ToolArgs`, `docs/specs/agent/10-implementation.md`, `docs/specs/agent/4-execution-shell.md` as required to satisfy every listed acceptance criterion, including visible success and explicit failure/recovery behavior. -- [ ] **Step 4: Run all focused tests and verify GREEN** + Rust boundary note (2026-07-29): standard `serde_json` rejects or loses the precise path for raw `NaN`/`Infinity` and exponent overflow before `decode_tool_args`. The minimal production slice therefore also owns `crates/opentake-agent/src/mcp/server.rs#finite_number_guard`; it scans the already size-bounded request, returns the exact safe path message, reconstructs the body for rmcp, and never dispatches rejected input. + +- [x] **Step 4: Run all focused tests and verify GREEN** - Run: `cargo test -p opentake-agent unknown_field_lists_sorted_allowed` - Run: `cargo test -p opentake-agent nested_array_index_uses_brackets` @@ -2412,12 +2448,14 @@ Expected: PASS with every candidate-bound assertion executed. -- [ ] **Step 5: Run the subsystem regression gate** +- [x] **Step 5: Run the subsystem regression gate** Run: `cargo fmt --all -- --check && cargo test --workspace --no-fail-fast` Expected: PASS with no new warnings or unrelated changes. + Verified 2026-07-29: all focused tests passed, `cargo clippy --workspace --all-targets -- -D warnings` passed, and `cargo test --workspace --no-fail-fast` passed. The three export and four playback probes explicitly marked `real-device probe` remain reserved for the real-machine phase. + ### Task 19: AG-timeline-tool-schema-dispatch (implementation-slice-cb53b36cf984d605) **Covered records:** diff --git a/docs/audit/2026-07-14/implementation-plans/command-contracts-implementation.md b/docs/audit/2026-07-14/implementation-plans/command-contracts-implementation.md index f15a5877..16f93eba 100644 --- a/docs/audit/2026-07-14/implementation-plans/command-contracts-implementation.md +++ b/docs/audit/2026-07-14/implementation-plans/command-contracts-implementation.md @@ -34,34 +34,56 @@ - Keep explicitly nearest-frame UI interactions documented and separate so the conversion policy cannot drift between call sites. - Add editActions cases for positive fractional values 0.49, 0.5, 0.99, and 1.01 frames at 24/30 fps plus negative/invalid inputs; assert exact parity with Rust conversion vectors. -- [ ] **Step 1: Write or extend every reviewed owning test** +- [x] **Step 1: Write or extend every reviewed owning test** - `web/src/store/editActions.test.ts#seconds_to_frame_truncates_fractional_boundaries` (reviewed-planned) — Reviewed planned test belongs in this tracked owning runner beside the mapped product boundary. Each assertion must exercise every covered candidate through the mapped product boundary; an existing-owned test may be extended, while a reviewed-planned test must be added at the declared runner path. -- [ ] **Step 2: Run all focused tests and verify RED** + Result: The exact owner covers 24/30 fps at sub-frame, half-frame, + near-next-frame, and multi-frame fractional boundaries. It also exercises the + media-drop, ripple-insert, search-moment, and default-text call sites plus + negative, NaN, and infinite inputs. + +- [x] **Step 2: Run all focused tests and verify RED** - Run: `pnpm -C web test -- --run src/store/editActions.test.ts -t "seconds_to_frame_truncates_fractional_boundaries"` Expected: FAIL because one or more of the 1 candidate-bound contracts are not yet satisfied. -- [ ] **Step 3: Implement the minimal vertical slice** + Result: RED reproduced. A 10.5-frame media duration returned 11 rather than + the Rust-compatible truncated value 10; Vitest reported 1 failed test and a + nonzero pnpm exit. + +- [x] **Step 3: Implement the minimal vertical slice** Modify only `web/src/store/editActions.ts#mediaDurationFrames`, `web/src/store/editActions.ts#momentDurationFrames`, `web/src/lib/timelineInsert.ts#buildInsertPlan`, `docs/architecture/BUGS.md` as required to satisfy every listed acceptance criterion, including visible success and explicit failure/recovery behavior. -- [ ] **Step 4: Run all focused tests and verify GREEN** + Result: Duration and offset conversions now truncate toward zero with finite + fallbacks. Media placement, moment trim start/length, ripple insertion, and + default text duration share the Rust conversion policy. Explicit + nearest-frame playhead interactions retain `Math.round` and are documented as + a separate UI policy. + +- [x] **Step 4: Run all focused tests and verify GREEN** - Run: `pnpm -C web test -- --run src/store/editActions.test.ts -t "seconds_to_frame_truncates_fractional_boundaries"` Expected: PASS with every candidate-bound assertion executed. -- [ ] **Step 5: Run the subsystem regression gate** + Result: GREEN; the exact owner passed and the command executed the complete + 82-file Web suite: 775 tests passed, 0 failed. + +- [x] **Step 5: Run the subsystem regression gate** Run: `pnpm -C web test -- --run && pnpm -C web build` Expected: PASS with no new warnings or unrelated changes. + Result: `pnpm -C web test -- --run` passed 82 files / 775 tests and + `pnpm -C web build` passed. Vite reported only the existing dynamic-import + and bundle-size advisory warnings. + ### Task 2: CC-first-video-settings (implementation-slice-699b13ae9742edd8) **Covered records:** @@ -87,37 +109,66 @@ - Add request/response tests for every named success, boundary, rejection, validation, and secrecy rule; the affected Rust and TypeScript suites must pass. - Exercise the production IPC, MCP, or browser entry point end to end and record the exact command payload, result, and test names before reclassification. -- [ ] **Step 1: Write or extend every reviewed owning test** +- [x] **Step 1: Write or extend every reviewed owning test** - `crates/opentake-ops/src/command.rs#set_timeline_settings_is_undoable` (existing-owned) — Exact named test already exists in the reviewed owning runner and records current boundary behavior. - `web/src/lib/projectSettings.test.ts#first_video_auto_configures_and_only_configured_empty_mismatch_prompts` (reviewed-planned) — Reviewed planned test belongs in this tracked owning runner beside the mapped product boundary. Each assertion must exercise every covered candidate through the mapped product boundary; an existing-owned test may be extended, while a reviewed-planned test must be added at the declared runner path. -- [ ] **Step 2: Run all focused tests and verify RED** + Result: The exact reviewed owner now covers no-video, fresh-project, + configured-nonempty, configured-empty matching/mismatching, and incomplete + metadata branches. `editActions.test.ts` additionally proves the production + placement command order and both mismatch choices; the dialog DOM owner + verifies its accessible role, labels, focus, values, Match response, and + Escape/Keep response. The Rust DTO owner asserts the `sourceFps` wire field. + +- [x] **Step 2: Run all focused tests and verify RED** - Run: `cargo test -p opentake-ops set_timeline_settings_is_undoable` - Run: `pnpm -C web test -- --run src/lib/projectSettings.test.ts -t "first_video_auto_configures_and_only_configured_empty_mismatch_prompts"` Expected: FAIL because one or more of the 1 candidate-bound contracts are not yet satisfied. -- [ ] **Step 3: Implement the minimal vertical slice** + Result: RED reproduced before implementation: Vitest could not resolve + `./projectSettings` from the declared exact owner because the decision module + and its production integration did not exist. + +- [x] **Step 3: Implement the minimal vertical slice** Modify only `web/src/store/editActions.ts#addMediaToTimelineAt`, `crates/opentake-ops/src/command.rs#EditCommand`, `crates/opentake-ops/src/ops/settings.rs#set_timeline_settings`, `docs/architecture/MODULE-PORT-MAP.md` as required to satisfy every listed acceptance criterion, including visible success and explicit failure/recovery behavior. -- [ ] **Step 4: Run all focused tests and verify GREEN** + Result: Added the pure four-branch decision, projected probed source FPS + through the Tauri DTO, and reconciled settings on both append and positioned + media entry points before placement. Fresh projects apply the existing + undoable `setTimelineSettings` command first. Configured-empty mismatches wait + for a globally mounted, keyboard-accessible dialog; keeping proceeds without + mutation and matching applies the same command before placement. Pending + choices are safely resolved when project runtime state resets. + +- [x] **Step 4: Run all focused tests and verify GREEN** - Run: `cargo test -p opentake-ops set_timeline_settings_is_undoable` - Run: `pnpm -C web test -- --run src/lib/projectSettings.test.ts -t "first_video_auto_configures_and_only_configured_empty_mismatch_prompts"` Expected: PASS with every candidate-bound assertion executed. -- [ ] **Step 5: Run the subsystem regression gate** + Result: GREEN. The three focused Web owners passed 33/33 tests, including the + exact named owner and production command/dialog integration. The exact Rust + undo owner passed 1/1, and the Tauri media DTO owner passed 1/1. + +- [x] **Step 5: Run the subsystem regression gate** Run: `cargo fmt --all -- --check && cargo test --workspace --no-fail-fast` Expected: PASS with no new warnings or unrelated changes. + Result: `cargo fmt --all -- --check`, `cargo test --workspace --no-fail-fast`, + `pnpm -C web test -- --run`, and `pnpm -C web build` passed. The Web gate + executed 84 files / 779 tests; Cargo's seven explicitly ignored real-device + probes remain owned by the later packaged-app verification phase. Vite + reported only the pre-existing dynamic-import and chunk-size advisories. + ### Task 3: CC-automation-composite-headings (implementation-slice-7167819f4cff454a) **Covered records:** @@ -238,34 +289,62 @@ - Visible/returned assertion: assert the exact returned success/error and the observable state described by “Minimum Local Verification”, including deterministic no-op behavior when the operation is rejected. - Evidence required: after the deterministic test passes, record code:# and test:#; proposed concrete evidence is test:tools/completion-tests/doc-6b9188efd87853b7.test.mjs#completion_6b9188efd87853b7_the_named_automation_checks_have_matching_source. -- [ ] **Step 1: Write or extend every reviewed owning test** +- [x] **Step 1: Write or extend every reviewed owning test** - `crates/opentake-agent/tests/editing_automation_acceptance.rs#automation_children_are_atomic_reviewable_and_command_routed` (reviewed-planned) — Reviewed planned test belongs in this tracked owning runner beside the mapped product boundary. Each assertion must exercise every covered candidate through the mapped product boundary; an existing-owned test may be extended, while a reviewed-planned test must be added at the declared runner path. -- [ ] **Step 2: Run all focused tests and verify RED** + Result: Added the exact integration owner plus all seven candidate-bound Node + completion owners. The integration fixture exercises deterministic beat and + autocrop analysis, review-only MCP beat/silence results, typed smart-reframe + unavailability, single-command reframe/beat plans, linked-A/V intent flags, + rejected-plan no-op behavior, command-routed mutation, and exact undo. + +- [x] **Step 2: Run all focused tests and verify RED** - Run: `cargo test -p opentake-agent --test editing_automation_acceptance automation_children_are_atomic_reviewable_and_command_routed -- --exact` Expected: FAIL because one or more of the 7 candidate-bound contracts are not yet satisfied. -- [ ] **Step 3: Implement the minimal vertical slice** + Result: RED reproduced: Cargo reported that no + `editing_automation_acceptance` test target existed, so the reviewed owner + could not execute. + +- [x] **Step 3: Implement the minimal vertical slice** Modify only `crates/opentake-agent/src/mcp/dispatch.rs#Dispatcher`, `crates/opentake-media/src/analysis/autocrop.rs#detect_autocrop`, `crates/opentake-ops/src/intent.rs#plan_smart_reframe`, `crates/opentake-media/src/analysis/beat.rs#detect_beats`, `docs/architecture/editing-automation/EDITING-AUTOMATION-DOS.md`, `docs/architecture/editing-automation/EDITING-AUTOMATION/acceptance-tests.md` as required to satisfy every listed acceptance criterion, including visible success and explicit failure/recovery behavior. -- [ ] **Step 4: Run all focused tests and verify GREEN** + Result: Review confirmed the mapped production children already satisfy this + shared umbrella: pure detectors are deterministic/fail closed; Dispatcher + preview tools do not apply; smart reframe returns a typed unavailable result; + intent planners emit exactly one existing `EditCommand`; and the command layer + owns mutation/undo. Added the missing cross-boundary acceptance suite and + source-specific completion adapters, then recorded the concrete evidence in + both source documents. No redundant production branch was introduced. + +- [x] **Step 4: Run all focused tests and verify GREEN** - Run: `cargo test -p opentake-agent --test editing_automation_acceptance automation_children_are_atomic_reviewable_and_command_routed -- --exact` Expected: PASS with every candidate-bound assertion executed. -- [ ] **Step 5: Run the subsystem regression gate** + Result: GREEN; the exact Rust owner passed 1/1 with `--exact`. All seven Node + completion owners passed 7/7 and each verifies that Cargo executed exactly one + owning test. + +- [x] **Step 5: Run the subsystem regression gate** Run: `cargo fmt --all -- --check && cargo test --workspace --no-fail-fast` Expected: PASS with no new warnings or unrelated changes. + Result: `cargo fmt --all -- --check`, `cargo clippy -p opentake-agent --tests + -- -D warnings`, `cargo test --workspace --no-fail-fast`, the exact Rust + owner, all seven source owners, and the local relative Markdown-link gate + passed. The workspace retained only its seven explicitly ignored real-device + probes, which belong to packaged-app verification. + ### Task 4: CC-beat-auto-cut (implementation-slice-d9ac8c92f3d41fea) **Covered records:** @@ -357,7 +436,7 @@ - Visible/returned assertion: assert the exact returned success/error and the observable state described by “Acceptance Hooks”, including deterministic no-op behavior when the operation is rejected. - Evidence required: after the deterministic test passes, record code:# and test:#; proposed concrete evidence is test:tools/completion-tests/doc-e7610731e7410ad4.test.mjs#completion_e7610731e7410ad4_beat_detection_inputs_produce_bounded_reviewable. -- [ ] **Step 1: Write or extend every reviewed owning test** +- [x] **Step 1: Write or extend every reviewed owning test** - `crates/opentake-media/src/analysis/beat.rs#pulse_audio_detects_beat_frame_with_strength` (existing-owned) — Exact named test already exists in the reviewed owning runner and records current boundary behavior. - `crates/opentake-agent/src/mcp/dispatch.rs#auto_cut_to_beats_write_false_is_read_only` (reviewed-planned) — Reviewed planned test belongs in this tracked owning runner beside the mapped product boundary. @@ -366,7 +445,12 @@ Each assertion must exercise every covered candidate through the mapped product boundary; an existing-owned test may be extended, while a reviewed-planned test must be added at the declared runner path. -- [ ] **Step 2: Run all focused tests and verify RED** + Result: Added both exact Dispatcher owners and the low-energy detector owner, + retained the pulse owner, and added a frontend atomic-adapter regression. Five + source-bound Node owners execute all mapped boundaries and reject zero-test + filters. + +- [x] **Step 2: Run all focused tests and verify RED** - Run: `cargo test -p opentake-media pulse_audio_detects_beat_frame_with_strength` - Run: `cargo test -p opentake-agent auto_cut_to_beats_write_false_is_read_only` @@ -375,11 +459,23 @@ Expected: FAIL because one or more of the 5 candidate-bound contracts are not yet satisfied. -- [ ] **Step 3: Implement the minimal vertical slice** + Result: RED reproduced in two independent failures. Quiet alternating speech + energy generated false beats, and both Dispatcher owners rejected `write` as + an unknown field before any auto-cut write path existed. + +- [x] **Step 3: Implement the minimal vertical slice** Modify only `crates/opentake-media/src/analysis/beat.rs#detect_beats`, `crates/opentake-agent/src/mcp/dispatch.rs#Dispatcher`, `web/src/store/editActions.ts#applyAutomationCommands`, `docs/architecture/editing-automation/EDITING-AUTOMATION/beat-sync-auto-cut.md` as required to satisfy every listed acceptance criterion, including visible success and explicit failure/recovery behavior. -- [ ] **Step 4: Run all focused tests and verify GREEN** + Result: Added an absolute onset-energy floor before relative normalization. + `auto_cut_to_beats` now accepts explicit `write` (default false), validates + visual roots and beat capacity, expands linked A/V members with one shared + delta, and applies exactly one existing `MoveClips` command. Preview remains + read-only. The frontend automation adapter now refuses multi-request + pseudo-transactions instead of serially committing partial edits; tool schema, + descriptions, and DOS documents reflect the production behavior. + +- [x] **Step 4: Run all focused tests and verify GREEN** - Run: `cargo test -p opentake-media pulse_audio_detects_beat_frame_with_strength` - Run: `cargo test -p opentake-agent auto_cut_to_beats_write_false_is_read_only` @@ -388,12 +484,21 @@ Expected: PASS with every candidate-bound assertion executed. -- [ ] **Step 5: Run the subsystem regression gate** + Result: GREEN. Each of the four exact Rust owners passed 1/1, the frontend + atomic owner passed 1/1, and all five source-bound owners passed 5/5. + +- [x] **Step 5: Run the subsystem regression gate** Run: `cargo fmt --all -- --check && cargo test --workspace --no-fail-fast` Expected: PASS with no new warnings or unrelated changes. + Result: PASS. `cargo fmt --all -- --check`, the complete Web suite (84 + files / 780 tests), production Web build, and + `cargo test --workspace --no-fail-fast` all completed successfully. The only + Web build diagnostics were the repository's existing dynamic-import and + chunk-size warnings; `git diff --check` was clean. + ### Task 5: CC-event-forwarding-complete (implementation-slice-c35c845cbc3492dd) **Covered records:** @@ -405,6 +510,8 @@ - Modify: `docs/modules/src-tauri/setup-lib.md` - Test (existing-owned): `crates/opentake-core/src/events.rs#core_event_serializes_with_kind_tag` - Test (existing-owned): `crates/opentake-core/src/events.rs#media_changed_serializes_with_kind_tag` +- Test (reviewed-planned): `src-tauri/src/lib.rs#core_event_forwarding_maps_every_name_and_tagged_payload` +- Test (reviewed-planned): `src-tauri/src/lib.rs#core_event_forwarding_swallows_emit_failure_and_delivery_continues` **Candidate-bound contracts:** @@ -417,37 +524,64 @@ - Extract or inject an event emitter boundary that can be exercised without a live Tauri window. - Focused tests assert every CoreEvent maps to the expected event name and tagged payload, and an emit failure is swallowed without panicking or affecting the core session. -- [ ] **Step 1: Write or extend every reviewed owning test** +- [x] **Step 1: Write or extend every reviewed owning test** - `crates/opentake-core/src/events.rs#core_event_serializes_with_kind_tag` (existing-owned) — Exact named test already exists in the reviewed owning runner and records current boundary behavior. - `crates/opentake-core/src/events.rs#media_changed_serializes_with_kind_tag` (existing-owned) — Exact named test already exists in the reviewed owning runner and records current boundary behavior. + - `src-tauri/src/lib.rs#core_event_forwarding_maps_every_name_and_tagged_payload` (reviewed-planned) — The emitted name and unchanged tagged payload for every variant are owned at the shell boundary. + - `src-tauri/src/lib.rs#core_event_forwarding_swallows_emit_failure_and_delivery_continues` (reviewed-planned) — The best-effort failure policy is owned at the shell boundary without a live WebView. Each assertion must exercise every covered candidate through the mapped product boundary; an existing-owned test may be extended, while a reviewed-planned test must be added at the declared runner path. -- [ ] **Step 2: Run all focused tests and verify RED** + Result: Extended the exact core serialization owner across all four variants. + Added Tauri-boundary owners + `core_event_forwarding_maps_every_name_and_tagged_payload` and + `core_event_forwarding_swallows_emit_failure_and_delivery_continues`, using + no live window or `AppHandle`. + +- [x] **Step 2: Run all focused tests and verify RED** - Run: `cargo test -p opentake-core core_event_serializes_with_kind_tag` - Run: `cargo test -p opentake-core media_changed_serializes_with_kind_tag` Expected: FAIL because one or more of the 1 candidate-bound contracts are not yet satisfied. -- [ ] **Step 3: Implement the minimal vertical slice** + Result: RED reproduced at the actual missing seam: the new Tauri owner failed + to compile because `forward_core_event` did not exist. The two pre-existing + core serialization tests alone could not exercise the WebView emit-failure + policy. + +- [x] **Step 3: Implement the minimal vertical slice** Modify only `crates/opentake-core/src/events.rs#CoreEvent`, `src-tauri/src/lib.rs#forward_event`, `docs/modules/src-tauri/setup-lib.md` as required to satisfy every listed acceptance criterion, including visible success and explicit failure/recovery behavior. -- [ ] **Step 4: Run all focused tests and verify GREEN** + Result: Extracted a typed `forward_core_event` seam that exhaustively maps + every variant, forwards the unchanged tagged event, and consumes emitter + errors. `forward_event` retains the session side effects and delegates only + emission; module documentation now records that boundary. + +- [x] **Step 4: Run all focused tests and verify GREEN** - Run: `cargo test -p opentake-core core_event_serializes_with_kind_tag` - Run: `cargo test -p opentake-core media_changed_serializes_with_kind_tag` Expected: PASS with every candidate-bound assertion executed. -- [ ] **Step 5: Run the subsystem regression gate** + Result: GREEN. Both exact core owners and both Tauri boundary owners passed + 1/1. The failure owner also proved a later `EventBus` observer still receives + the event after a simulated WebView teardown error. + +- [x] **Step 5: Run the subsystem regression gate** Run: `cargo fmt --all -- --check && cargo test --workspace --no-fail-fast` Expected: PASS with no new warnings or unrelated changes. + Result: PASS. `cargo fmt --all -- --check` and + `cargo test --workspace --no-fail-fast` completed successfully. The only + diagnostic was the repository's existing future-incompatibility notice for + transitive `block v0.1.6`. + ### Task 6: CC-edit-gesture-parity (implementation-slice-6d2ce8b116a3ccd9) **Covered records:** @@ -463,6 +597,8 @@ - Modify: `docs/specs/frontend/13-implementation.md` - Test (existing-owned): `web/src/store/editActions.test.ts#forwards swapTracks for whole-track reordering` - Test (reviewed-planned): `web/src/store/commandRouting.test.ts#every_edit_action_emits_exact_edit_request` +- Test (reviewed-planned): `web/src/lib/api.editApply.test.ts#edit_apply_forwards_exact_command_envelope` +- Test (reviewed-planned): `src-tauri/src/commands.rs#every_frontend_edit_request_deserializes_to_intended_command` **Candidate-bound contracts:** @@ -486,37 +622,63 @@ - Add request/response tests for every named success, boundary, rejection, validation, and secrecy rule; the affected Rust and TypeScript suites must pass. - Exercise the production IPC, MCP, or browser entry point end to end and record the exact command payload, result, and test names before reclassification. -- [ ] **Step 1: Write or extend every reviewed owning test** +- [x] **Step 1: Write or extend every reviewed owning test** - `web/src/store/editActions.test.ts#forwards swapTracks for whole-track reordering` (existing-owned) — Exact named test already exists in the reviewed owning runner and records current boundary behavior. - `web/src/store/commandRouting.test.ts#every_edit_action_emits_exact_edit_request` (reviewed-planned) — Reviewed planned test belongs in this tracked owning runner beside the mapped product boundary. + - `web/src/lib/api.editApply.test.ts#edit_apply_forwards_exact_command_envelope` (reviewed-planned) — Production Tauri invocation must retain the exact command wrapper. + - `src-tauri/src/commands.rs#every_frontend_edit_request_deserializes_to_intended_command` (reviewed-planned) — Native serde and command routing must cover the same exhaustive request set. Each assertion must exercise every covered candidate through the mapped product boundary; an existing-owned test may be extended, while a reviewed-planned test must be added at the declared runner path. -- [ ] **Step 2: Run all focused tests and verify RED** + Result: Added the exact planned Web owner plus production IPC and native + routing owners. The Web owner executes all 41 typed action routes, exact DTO + shapes, representative no-ops, and a static direct-mutation guard. + +- [x] **Step 2: Run all focused tests and verify RED** - Run: `pnpm -C web test -- --run src/store/editActions.test.ts -t "forwards swapTracks for whole-track reordering"` - Run: `pnpm -C web test -- --run src/store/commandRouting.test.ts -t "every_edit_action_emits_exact_edit_request"` Expected: FAIL because one or more of the 2 candidate-bound contracts are not yet satisfied. -- [ ] **Step 3: Implement the minimal vertical slice** + Result: RED reproduced: the planned owner loaded an undefined + `EDIT_GESTURE_COMMAND_MATRIX`, proving no exhaustive production inventory + existed. Audit also found `addTexts` and `removeTracks` request variants with + no shared action wrapper. + +- [x] **Step 3: Implement the minimal vertical slice** Modify only `web/src/components/timeline/TimelineContainer.tsx#TimelineContainer`, `web/src/store/editActions.ts#buildMediaInsertPlan`, `web/src/lib/api.ts#editApply`, `src-tauri/src/commands.rs#EditRequest`, `docs/modules/web/SPEC.md`, `docs/specs/frontend/13-implementation.md` as required to satisfy every listed acceptance criterion, including visible success and explicit failure/recovery behavior. -- [ ] **Step 4: Run all focused tests and verify GREEN** + Result: Added an exhaustively typed 41-row gesture/action/request/backend + matrix and the missing direct action wrappers. Every low-level action emits + exactly one `EditRequest`, empty/same-target boundaries emit none, and the + obsolete sequential `editApplyMany` pseudo-transaction was removed. The + production IPC envelope and every Rust serde route now have focused owners; + both acceptance documents record the concrete evidence. + +- [x] **Step 4: Run all focused tests and verify GREEN** - Run: `pnpm -C web test -- --run src/store/editActions.test.ts -t "forwards swapTracks for whole-track reordering"` - Run: `pnpm -C web test -- --run src/store/commandRouting.test.ts -t "every_edit_action_emits_exact_edit_request"` Expected: PASS with every candidate-bound assertion executed. -- [ ] **Step 5: Run the subsystem regression gate** + Result: GREEN. The existing swap owner, exhaustive Web owner, production IPC + owner, and 41-case native route owner each passed exactly once. + +- [x] **Step 5: Run the subsystem regression gate** Run: `cargo fmt --all -- --check && cargo test --workspace --no-fail-fast` Expected: PASS with no new warnings or unrelated changes. + Result: PASS. Rust formatting, the complete Web suite (86 files / 782 + tests), production Web build, `cargo test --workspace --no-fail-fast`, and + `git diff --check` all completed successfully. Only the repository's existing + dynamic-import/chunk-size and transitive `block v0.1.6` diagnostics remained. + ### Task 7: CC-readonly-versioned-mirror (implementation-slice-dc651cd267aea077) **Covered records:** @@ -532,6 +694,9 @@ - Modify: `docs/specs/frontend/13-implementation.md` - Test (existing-owned): `web/src/store/sync.test.ts#does not let a late old snapshot replace a newer project` - Test (reviewed-planned): `web/src/store/commandRouting.test.ts#project_store_has_no_timeline_mutator_and_refreshes_only_from_native_events` +- Test (reviewed-planned): `web/src/store/sync.test.ts#refetches when an event-promised version is newer than the first snapshot` +- Test (reviewed-planned): `web/src/store/sync.test.ts#converges to N+2 when N+1 and N+2 event refreshes complete out of order` +- Test (reviewed-planned): `web/src/store/sync.test.ts#never publishes a stale snapshot when catch-up retries are exhausted` **Candidate-bound contracts:** @@ -565,37 +730,65 @@ - Add request/response tests for every named success, boundary, rejection, validation, and secrecy rule; the affected Rust and TypeScript suites must pass. - Exercise the production IPC, MCP, or browser entry point end to end and record the exact command payload, result, and test names before reclassification. -- [ ] **Step 1: Write or extend every reviewed owning test** +- [x] **Step 1: Write or extend every reviewed owning test** - `web/src/store/sync.test.ts#does not let a late old snapshot replace a newer project` (existing-owned) — Exact named test already exists in the reviewed owning runner and records current boundary behavior. - `web/src/store/commandRouting.test.ts#project_store_has_no_timeline_mutator_and_refreshes_only_from_native_events` (reviewed-planned) — Reviewed planned test belongs in this tracked owning runner beside the mapped product boundary. + - `web/src/store/sync.test.ts#refetches when an event-promised version is newer than the first snapshot` (reviewed-planned) — An event's version floor must force a stale first response to retry. + - `web/src/store/sync.test.ts#converges to N+2 when N+1 and N+2 event refreshes complete out of order` (reviewed-planned) — Concurrent edit event responses must converge to the newest authority. + - `web/src/store/sync.test.ts#never publishes a stale snapshot when catch-up retries are exhausted` (reviewed-planned) — Bounded retry failure must remain a deterministic no-op. Each assertion must exercise every covered candidate through the mapped product boundary; an existing-owned test may be extended, while a reviewed-planned test must be added at the declared runner path. -- [ ] **Step 2: Run all focused tests and verify RED** + Result: Added the exact planned store owner and three event-floor/concurrency + owners. Existing project-switch/history tests remain in the same runner; + schema and project-action suites now use only authoritative full snapshots. + +- [x] **Step 2: Run all focused tests and verify RED** - Run: `pnpm -C web test -- --run src/store/sync.test.ts -t "does not let a late old snapshot replace a newer project"` - Run: `pnpm -C web test -- --run src/store/commandRouting.test.ts -t "project_store_has_no_timeline_mutator_and_refreshes_only_from_native_events"` Expected: FAIL because one or more of the 3 candidate-bound contracts are not yet satisfied. -- [ ] **Step 3: Implement the minimal vertical slice** + Result: RED reproduced twice. The project Store still exposed `setMirror`, + and a `timeline_changed` promise for version 2 committed the first fetched + version-1 snapshot without retrying. + +- [x] **Step 3: Implement the minimal vertical slice** Modify only `src-tauri/src/lib.rs#forward_event`, `web/src/store/sync.ts#startSync`, `web/src/store/projectStore.ts#useProjectStore`, `docs/specs/core/4-frontend-sync.md`, `docs/specs/frontend/13-implementation.md` as required to satisfy every listed acceptance criterion, including visible success and explicit failure/recovery behavior. -- [ ] **Step 4: Run all focused tests and verify GREEN** + Result: Removed the bypass mutator. Full native/fallback snapshots now clone + and recursively freeze on acceptance, reject older epochs and same-project + versions, and preserve project identity atomically. Event-driven refreshes + carry an epoch/version floor, retry stale responses up to a bounded limit, + and publish nothing if the floor remains unmet; refresh generations make + concurrent N+1/N+2 responses converge only to N+2. + +- [x] **Step 4: Run all focused tests and verify GREEN** - Run: `pnpm -C web test -- --run src/store/sync.test.ts -t "does not let a late old snapshot replace a newer project"` - Run: `pnpm -C web test -- --run src/store/commandRouting.test.ts -t "project_store_has_no_timeline_mutator_and_refreshes_only_from_native_events"` Expected: PASS with every candidate-bound assertion executed. -- [ ] **Step 5: Run the subsystem regression gate** + Result: GREEN. Both originally named owners and all three explicit event + concurrency/failure owners passed. The project schema, lifecycle, and edit + action regression suites also passed after moving fixtures to the production + full-snapshot boundary. + +- [x] **Step 5: Run the subsystem regression gate** Run: `cargo fmt --all -- --check && cargo test --workspace --no-fail-fast` Expected: PASS with no new warnings or unrelated changes. + Result: PASS. Rust formatting, the complete Web suite (86 files / 786 + tests), production Web build, `cargo test --workspace --no-fail-fast`, and + `git diff --check` all completed successfully. Only the pre-existing build + diagnostics remained. + ### Task 8: CC-tauri-command-contract (implementation-slice-00bdb59d43b2ad0c) **Covered records:** @@ -712,7 +905,7 @@ - Unknown fields, invalid frames/IDs/paths, unavailable capability, and cancellation must return typed errors without partial mutation. - Table-drive invoke registration/schema parity plus success/failure/undo for each mutating command and packaged desktop smoke the command surface. -- [ ] **Step 1: Write or extend every reviewed owning test** +- [x] **Step 1: Write or extend every reviewed owning test** - `src-tauri/src/commands.rs#deserializes_camelcase_multiword_commands` (existing-owned) — Exact named test already exists in the reviewed owning runner and records current boundary behavior. - `src-tauri/src/commands.rs#deserializes_add_captions_camelcase_and_maps_to_command` (existing-owned) — Exact named test already exists in the reviewed owning runner and records current boundary behavior. @@ -723,7 +916,7 @@ Each assertion must exercise every covered candidate through the mapped product boundary; an existing-owned test may be extended, while a reviewed-planned test must be added at the declared runner path. -- [ ] **Step 2: Run all focused tests and verify RED** +- [x] **Step 2: Run all focused tests and reconcile the historical baseline** - Run: `cargo test -p opentake-tauri deserializes_camelcase_multiword_commands` - Run: `cargo test -p opentake-tauri deserializes_add_captions_camelcase_and_maps_to_command` @@ -734,11 +927,11 @@ Expected: FAIL because one or more of the 7 candidate-bound contracts are not yet satisfied. -- [ ] **Step 3: Implement the minimal vertical slice** +- [x] **Step 3: Implement the minimal vertical slice** Modify only `web/src/lib/api.ts#editApply`, `src-tauri/src/commands.rs#EditRequest`, `src-tauri/src/commands.rs#edit_apply`, `docs/specs/core/6-tauri-commands.md`, `docs/specs/frontend/11-tauri.md` as required to satisfy every listed acceptance criterion, including visible success and explicit failure/recovery behavior. -- [ ] **Step 4: Run all focused tests and verify GREEN** +- [x] **Step 4: Run all focused tests and verify GREEN** - Run: `cargo test -p opentake-tauri deserializes_camelcase_multiword_commands` - Run: `cargo test -p opentake-tauri deserializes_add_captions_camelcase_and_maps_to_command` @@ -749,12 +942,22 @@ Expected: PASS with every candidate-bound assertion executed. -- [ ] **Step 5: Run the subsystem regression gate** +- [x] **Step 5: Run the subsystem regression gate** Run: `cargo fmt --all -- --check && cargo test --workspace --no-fail-fast` Expected: PASS with no new warnings or unrelated changes. + Reconciled 2026-08-01 as an already-integrated baseline. Git history proves + the exhaustive Rust mapping owner, frontend invoke/handler parity owner, + typed error boundary, strict DTO updates, and both specification corrections + landed together in `f2805fc`; its parent contains neither planned owner, so + manufacturing a RED state would be dishonest. All six declared focused + owners pass on the current descendant, as do the four Node documentation + completion tests and the Agent-side Rust completion test. Rustfmt and the + full workspace gate pass, while the current full Web suite/build passed in + the immediately preceding control slice. No production change was required. + ### Task 9: CC-authority-persistence-mixed (implementation-slice-9296b54263dc8038) **Covered records:** @@ -801,34 +1004,44 @@ - Exclude timeline, clips, media, credentials, transient drag/playback/dialog state, and clear or migrate malformed/old records safely. - Restart-test each approved key plus corrupt JSON, old schema, invalid bounds, project switch, logout, and secret scan. -- [ ] **Step 1: Write or extend every reviewed owning test** +- [x] **Step 1: Write or extend every reviewed owning test** - `web/src/store/commandRouting.test.ts#rust_authority_and_ui_persistence_are_independently_owned` (reviewed-planned) — Reviewed planned test belongs in this tracked owning runner beside the mapped product boundary. Each assertion must exercise every covered candidate through the mapped product boundary; an existing-owned test may be extended, while a reviewed-planned test must be added at the declared runner path. -- [ ] **Step 2: Run all focused tests and verify RED** +- [x] **Step 2: Run all focused tests and reconcile the historical baseline** - Run: `pnpm -C web test -- --run src/store/commandRouting.test.ts -t "rust_authority_and_ui_persistence_are_independently_owned"` Expected: FAIL because one or more of the 3 candidate-bound contracts are not yet satisfied. -- [ ] **Step 3: Implement the minimal vertical slice** +- [x] **Step 3: Implement the minimal vertical slice** Modify only `web/src/store/sync.ts#startSync`, `web/src/store/projectStore.ts#useProjectStore`, `web/src/store/uiStore.ts#useEditorUiStore`, `docs/specs/frontend/10-state.md` as required to satisfy every listed acceptance criterion, including visible success and explicit failure/recovery behavior. -- [ ] **Step 4: Run all focused tests and verify GREEN** +- [x] **Step 4: Run all focused tests and verify GREEN** - Run: `pnpm -C web test -- --run src/store/commandRouting.test.ts -t "rust_authority_and_ui_persistence_are_independently_owned"` Expected: PASS with every candidate-bound assertion executed. -- [ ] **Step 5: Run the subsystem regression gate** +- [x] **Step 5: Run the subsystem regression gate** Run: `pnpm -C web test -- --run && pnpm -C web build` Expected: PASS with no new warnings or unrelated changes. + Reconciled 2026-08-01 as an already-integrated baseline. The exact composite + owner passes and proves the schema-versioned persistence allowlist, absence of + project identifiers from persisted values, restart defaults for transient + selection/playback/preview state, stale snapshot rejection, concurrent and + failed edit isolation, and project-boundary UI reset. Git history shows the + owner, persistence hardening, and corrected specification landed together in + `8da75f9`; the parent lacks the owner, so there is no honest historical RED to + replay. The current full Web suite (95 files / 832 tests) and production build + passed in the immediately preceding slice. No production change was required. + ### Task 10: control-acceptance (implementation-slice-251a11401ef969e1) **Covered records:** @@ -858,34 +1071,43 @@ - Visible/accessibility/return path: success=undo the last edit: edit.undo -> backend undo -> refresh mirror/canUndo/canRedo; accessibility={"focus":"Custom HoverButton focus behavior depends on its implementation","label":"t(\"toolbar.undo\")","shortcut":"None declared on this control"}; returnPath=["Focus remains on the toolbar control; edit commands update the editor mirror/selection."]. - Outcome matrix: {"success":"undo the last edit: edit.undo -> backend undo -> refresh mirror/canUndo/canRedo","pending":"Pending behavior is the concrete busy/phase/disabled state in web/src/components/toolbar/Toolbar.tsx:104; no additional state is inferred beyond the source.","empty":"Required empty/no-selection behavior is the render/handler guard in web/src/components/toolbar/Toolbar.tsx:104; the candidate-specific interaction test must assert that exact guard.","disabled":"Disabled when {!canUndo}.","cancel":"Not applicable — this immediate handler has no cancellable operation or dismissible transient surface.","retry":"Retry is another activation after the source-defined pending guard clears; no separate retry command exists unless named in edit.undo -> backend undo -> refresh mirror/canUndo/canRedo.","failure":"Failure behavior is limited to the catch/void-call behavior visible in web/src/components/toolbar/Toolbar.tsx:104; the missing DOM test must prove whether it is surfaced or silent."}. -- [ ] **Step 1: Write or extend every reviewed owning test** +- [x] **Step 1: Write or extend every reviewed owning test** - `web/src/components/toolbar/Toolbar.interaction.test.tsx#control-3be71cae61006e08 undo the last edit` (reviewed-planned) — The control acceptance contract explicitly names this owning component test runner. Each assertion must exercise every covered candidate through the mapped product boundary; an existing-owned test may be extended, while a reviewed-planned test must be added at the declared runner path. -- [ ] **Step 2: Run all focused tests and verify RED** +- [x] **Step 2: Run all focused tests and reconcile the historical baseline** - Run: `pnpm -C web test -- --run src/components/toolbar/Toolbar.interaction.test.tsx -t "control-3be71cae61006e08 undo the last edit"` Expected: FAIL because one or more of the 1 candidate-bound contracts are not yet satisfied. -- [ ] **Step 3: Implement the minimal vertical slice** +- [x] **Step 3: Implement the minimal vertical slice** Modify only `web/src/components/toolbar/Toolbar.tsx`, `web/src/store/editActions.ts#undo`, `web/src/lib/api.ts#undo`, `src-tauri/src/commands.rs#undo`, `web/src/components/toolbar/Toolbar.tsx#Toolbar` as required to satisfy every listed acceptance criterion, including visible success and explicit failure/recovery behavior. -- [ ] **Step 4: Run all focused tests and verify GREEN** +- [x] **Step 4: Run all focused tests and verify GREEN** - Run: `pnpm -C web test -- --run src/components/toolbar/Toolbar.interaction.test.tsx -t "control-3be71cae61006e08 undo the last edit"` Expected: PASS with every candidate-bound assertion executed. -- [ ] **Step 5: Run the subsystem regression gate** +- [x] **Step 5: Run the subsystem regression gate** Run: `cargo fmt --all -- --check && cargo test --workspace --no-fail-fast` Expected: PASS with no new warnings or unrelated changes. + Reconciled 2026-08-01 as an already-integrated baseline. The exact DOM owner + passes and exercises disabled no-op, one-shot pending behavior, focus + retention, successful completion, rejection feedback, retry readiness, and + the source chain through `edit.undo`, the typed API wrapper, and Rust + `handle_undo`. Git history shows the owner and recoverability fix landed + together in `99661b7`; the parent has no owner to replay as RED. The current + full Web/build and Rust workspace gates already pass. No production change + was required. + ### Task 11: control-acceptance (implementation-slice-a085393217648051) **Covered records:** @@ -915,34 +1137,42 @@ - Visible/accessibility/return path: success=redo the last undone edit: edit.redo -> backend redo -> refresh mirror/canUndo/canRedo; accessibility={"focus":"Custom HoverButton focus behavior depends on its implementation","label":"t(\"toolbar.redo\")","shortcut":"None declared on this control"}; returnPath=["Focus remains on the toolbar control; edit commands update the editor mirror/selection."]. - Outcome matrix: {"success":"redo the last undone edit: edit.redo -> backend redo -> refresh mirror/canUndo/canRedo","pending":"Pending behavior is the concrete busy/phase/disabled state in web/src/components/toolbar/Toolbar.tsx:107; no additional state is inferred beyond the source.","empty":"Required empty/no-selection behavior is the render/handler guard in web/src/components/toolbar/Toolbar.tsx:107; the candidate-specific interaction test must assert that exact guard.","disabled":"Disabled when {!canRedo}.","cancel":"Not applicable — this immediate handler has no cancellable operation or dismissible transient surface.","retry":"Retry is another activation after the source-defined pending guard clears; no separate retry command exists unless named in edit.redo -> backend redo -> refresh mirror/canUndo/canRedo.","failure":"Failure behavior is limited to the catch/void-call behavior visible in web/src/components/toolbar/Toolbar.tsx:107; the missing DOM test must prove whether it is surfaced or silent."}. -- [ ] **Step 1: Write or extend every reviewed owning test** +- [x] **Step 1: Write or extend every reviewed owning test** - `web/src/components/toolbar/Toolbar.interaction.test.tsx#control-b001ac6b21c97ad0 redo the last undone edit` (reviewed-planned) — The control acceptance contract explicitly names this owning component test runner. Each assertion must exercise every covered candidate through the mapped product boundary; an existing-owned test may be extended, while a reviewed-planned test must be added at the declared runner path. -- [ ] **Step 2: Run all focused tests and verify RED** +- [x] **Step 2: Run all focused tests and reconcile the historical baseline** - Run: `pnpm -C web test -- --run src/components/toolbar/Toolbar.interaction.test.tsx -t "control-b001ac6b21c97ad0 redo the last undone edit"` Expected: FAIL because one or more of the 1 candidate-bound contracts are not yet satisfied. -- [ ] **Step 3: Implement the minimal vertical slice** +- [x] **Step 3: Implement the minimal vertical slice** Modify only `web/src/components/toolbar/Toolbar.tsx`, `web/src/store/editActions.ts#redo`, `web/src/lib/api.ts#redo`, `src-tauri/src/commands.rs#redo`, `web/src/components/toolbar/Toolbar.tsx#Toolbar` as required to satisfy every listed acceptance criterion, including visible success and explicit failure/recovery behavior. -- [ ] **Step 4: Run all focused tests and verify GREEN** +- [x] **Step 4: Run all focused tests and verify GREEN** - Run: `pnpm -C web test -- --run src/components/toolbar/Toolbar.interaction.test.tsx -t "control-b001ac6b21c97ad0 redo the last undone edit"` Expected: PASS with every candidate-bound assertion executed. -- [ ] **Step 5: Run the subsystem regression gate** +- [x] **Step 5: Run the subsystem regression gate** Run: `cargo fmt --all -- --check && cargo test --workspace --no-fail-fast` Expected: PASS with no new warnings or unrelated changes. + Reconciled 2026-08-01 as an already-integrated baseline. The exact Redo DOM + owner passes across disabled no-op, pending duplicate suppression, focus, + success, failure feedback and retry while statically binding the frontend and + Rust command chain. The owner and recoverability fix landed together in + `436a30e`, and its parent lacks the owner; no artificial RED was introduced. + The current complete Web/build and Rust workspace gates pass. No production + change was required. + ### Task 12: control-acceptance (implementation-slice-dc2e1dbf0bbdec19) **Covered records:** @@ -1001,7 +1231,7 @@ - Visible/accessibility/return path: success=change timeline zoom: logarithmic slider -> setZoomScale; accessibility={"focus":"Native keyboard-focusable control","label":"t(\"toolbar.zoom\")","shortcut":"None declared on this control"}; returnPath=["Focus remains on the toolbar control; edit commands update the editor mirror/selection."]. - Outcome matrix: {"success":"change timeline zoom: logarithmic slider -> setZoomScale","pending":"Not applicable — this activation only changes local/caller state and starts no Promise, API, Tauri command, or Rust work.","empty":"Not applicable — this control does not consume a collection, selection, or free-form payload that has an empty state.","disabled":"No explicit disabled prop on this candidate.","cancel":"Not applicable — this immediate handler has no cancellable operation or dismissible transient surface.","retry":"Not applicable as an error-recovery state — the synchronous local action can simply be activated again.","failure":"Not applicable — this immediate activation calls no API/Tauri/Rust boundary, so there is no backend rejection path."}. -- [ ] **Step 1: Write or extend every reviewed owning test** +- [x] **Step 1: Write or extend every reviewed owning test** - `web/src/components/toolbar/Toolbar.interaction.test.tsx#control-9d69468ce3479312 switch to Pointer tool` (reviewed-planned) — The control acceptance contract explicitly names this owning component test runner. - `web/src/components/toolbar/Toolbar.interaction.test.tsx#control-8105812f9d07bc93 switch to Razor tool` (reviewed-planned) — The control acceptance contract explicitly names this owning component test runner. @@ -1009,7 +1239,7 @@ Each assertion must exercise every covered candidate through the mapped product boundary; an existing-owned test may be extended, while a reviewed-planned test must be added at the declared runner path. -- [ ] **Step 2: Run all focused tests and verify RED** +- [x] **Step 2: Run all focused tests and reconcile the historical baseline** - Run: `pnpm -C web test -- --run src/components/toolbar/Toolbar.interaction.test.tsx -t "control-9d69468ce3479312 switch to Pointer tool"` - Run: `pnpm -C web test -- --run src/components/toolbar/Toolbar.interaction.test.tsx -t "control-8105812f9d07bc93 switch to Razor tool"` @@ -1017,11 +1247,11 @@ Expected: FAIL because one or more of the 3 candidate-bound contracts are not yet satisfied. -- [ ] **Step 3: Implement the minimal vertical slice** +- [x] **Step 3: Implement the minimal vertical slice** Modify only `web/src/components/toolbar/Toolbar.tsx`, `web/src/components/toolbar/Toolbar.tsx#Toolbar` as required to satisfy every listed acceptance criterion, including visible success and explicit failure/recovery behavior. -- [ ] **Step 4: Run all focused tests and verify GREEN** +- [x] **Step 4: Run all focused tests and verify GREEN** - Run: `pnpm -C web test -- --run src/components/toolbar/Toolbar.interaction.test.tsx -t "control-9d69468ce3479312 switch to Pointer tool"` - Run: `pnpm -C web test -- --run src/components/toolbar/Toolbar.interaction.test.tsx -t "control-8105812f9d07bc93 switch to Razor tool"` @@ -1029,12 +1259,20 @@ Expected: PASS with every candidate-bound assertion executed. -- [ ] **Step 5: Run the subsystem regression gate** +- [x] **Step 5: Run the subsystem regression gate** Run: `pnpm -C web test -- --run && pnpm -C web build` Expected: PASS with no new warnings or unrelated changes. + Reconciled 2026-08-01 as an already-integrated baseline. All three exact DOM + owners pass and prove keyboard/click tool switching, selected-state feedback, + focus behavior, and logarithmic zoom mapping with bounded store updates. The + owners were added over the existing production implementation in `186f377`; + its parent lacks the tests but not the behavior, so the initial honest result + was GREEN. The current full Web suite and production build pass. No production + change was required. + ### Task 13: control-acceptance (implementation-slice-964535d9ff93a4e8) **Covered records:** @@ -1065,34 +1303,41 @@ - Visible/accessibility/return path: success=split selected clips at playhead: edit.splitAtPlayhead; accessibility={"focus":"Custom HoverButton focus behavior depends on its implementation","label":"t(\"toolbar.split\")","shortcut":"None declared on this control"}; returnPath=["Focus remains on the toolbar control; edit commands update the editor mirror/selection."]. - Outcome matrix: {"success":"split selected clips at playhead: edit.splitAtPlayhead","pending":"Pending behavior is the concrete busy/phase/disabled state in web/src/components/toolbar/Toolbar.tsx:136; no additional state is inferred beyond the source.","empty":"Required empty/no-selection behavior is the render/handler guard in web/src/components/toolbar/Toolbar.tsx:136; the candidate-specific interaction test must assert that exact guard.","disabled":"No explicit disabled prop on this candidate.","cancel":"Not applicable — this immediate handler has no cancellable operation or dismissible transient surface.","retry":"Retry is another activation after the source-defined pending guard clears; no separate retry command exists unless named in edit.splitAtPlayhead.","failure":"Failure behavior is limited to the catch/void-call behavior visible in web/src/components/toolbar/Toolbar.tsx:136; the missing DOM test must prove whether it is surfaced or silent."}. -- [ ] **Step 1: Write or extend every reviewed owning test** +- [x] **Step 1: Write or extend every reviewed owning test** - `web/src/components/toolbar/Toolbar.interaction.test.tsx#control-c96abe84259649a3 split selected clips at playhead` (reviewed-planned) — The control acceptance contract explicitly names this owning component test runner. Each assertion must exercise every covered candidate through the mapped product boundary; an existing-owned test may be extended, while a reviewed-planned test must be added at the declared runner path. -- [ ] **Step 2: Run all focused tests and verify RED** +- [x] **Step 2: Run all focused tests and reconcile the historical baseline** - Run: `pnpm -C web test -- --run src/components/toolbar/Toolbar.interaction.test.tsx -t "control-c96abe84259649a3 split selected clips at playhead"` Expected: FAIL because one or more of the 1 candidate-bound contracts are not yet satisfied. -- [ ] **Step 3: Implement the minimal vertical slice** +- [x] **Step 3: Implement the minimal vertical slice** Modify only `web/src/components/toolbar/Toolbar.tsx`, `web/src/store/editActions.ts#splitAtPlayhead`, `web/src/lib/api.ts#editApply`, `src-tauri/src/commands.rs#edit_apply`, `crates/opentake-ops/src/command.rs`, `web/src/components/toolbar/Toolbar.tsx#Toolbar` as required to satisfy every listed acceptance criterion, including visible success and explicit failure/recovery behavior. -- [ ] **Step 4: Run all focused tests and verify GREEN** +- [x] **Step 4: Run all focused tests and verify GREEN** - Run: `pnpm -C web test -- --run src/components/toolbar/Toolbar.interaction.test.tsx -t "control-c96abe84259649a3 split selected clips at playhead"` Expected: PASS with every candidate-bound assertion executed. -- [ ] **Step 5: Run the subsystem regression gate** +- [x] **Step 5: Run the subsystem regression gate** Run: `cargo fmt --all -- --check && cargo test --workspace --no-fail-fast` Expected: PASS with no new warnings or unrelated changes. + Reconciled 2026-08-01 as an already-integrated baseline. The exact Split DOM + owner passes across selected and playhead-intersecting targets, no-target + no-op, pending duplicate suppression, error feedback, retry and the typed + command chain. The owner and toolbar guard fix landed together in `be4f186`; + its parent has no owner to replay as RED. Current Web/build and Rust workspace + gates pass. No production change was required. + ### Task 14: control-acceptance (implementation-slice-922fa77f774ce4f6) **Covered records:** @@ -1123,34 +1368,41 @@ - Visible/accessibility/return path: success=trim selected clip starts to playhead: edit.trimStartToPlayhead computes left-edge trim edits for selected/intersecting clips; accessibility={"focus":"Custom GlyphButton focus behavior depends on its implementation","label":"t(\"toolbar.trimStart\")","shortcut":"None declared on this control"}; returnPath=["Focus remains on the toolbar control; edit commands update the editor mirror/selection."]. - Outcome matrix: {"success":"trim selected clip starts to playhead: edit.trimStartToPlayhead computes left-edge trim edits for selected/intersecting clips","pending":"Pending behavior is the concrete busy/phase/disabled state in web/src/components/toolbar/Toolbar.tsx:139; no additional state is inferred beyond the source.","empty":"Required empty/no-selection behavior is the render/handler guard in web/src/components/toolbar/Toolbar.tsx:139; the candidate-specific interaction test must assert that exact guard.","disabled":"No explicit disabled prop on this candidate.","cancel":"Not applicable — this immediate handler has no cancellable operation or dismissible transient surface.","retry":"Retry is another activation after the source-defined pending guard clears; no separate retry command exists unless named in edit.trimStartToPlayhead computes left-edge trim edits for selected/intersecting clips.","failure":"Failure behavior is limited to the catch/void-call behavior visible in web/src/components/toolbar/Toolbar.tsx:139; the missing DOM test must prove whether it is surfaced or silent."}. -- [ ] **Step 1: Write or extend every reviewed owning test** +- [x] **Step 1: Write or extend every reviewed owning test** - `web/src/components/toolbar/Toolbar.interaction.test.tsx#control-f38c30bc83d65d2e trim selected clip starts to playhead` (reviewed-planned) — The control acceptance contract explicitly names this owning component test runner. Each assertion must exercise every covered candidate through the mapped product boundary; an existing-owned test may be extended, while a reviewed-planned test must be added at the declared runner path. -- [ ] **Step 2: Run all focused tests and verify RED** +- [x] **Step 2: Run all focused tests and reconcile the historical baseline** - Run: `pnpm -C web test -- --run src/components/toolbar/Toolbar.interaction.test.tsx -t "control-f38c30bc83d65d2e trim selected clip starts to playhead"` Expected: FAIL because one or more of the 1 candidate-bound contracts are not yet satisfied. -- [ ] **Step 3: Implement the minimal vertical slice** +- [x] **Step 3: Implement the minimal vertical slice** Modify only `web/src/components/toolbar/Toolbar.tsx`, `web/src/store/editActions.ts#trimStartToPlayhead`, `web/src/lib/api.ts#editApply`, `src-tauri/src/commands.rs#edit_apply`, `crates/opentake-ops/src/command.rs`, `web/src/components/toolbar/Toolbar.tsx#Toolbar` as required to satisfy every listed acceptance criterion, including visible success and explicit failure/recovery behavior. -- [ ] **Step 4: Run all focused tests and verify GREEN** +- [x] **Step 4: Run all focused tests and verify GREEN** - Run: `pnpm -C web test -- --run src/components/toolbar/Toolbar.interaction.test.tsx -t "control-f38c30bc83d65d2e trim selected clip starts to playhead"` Expected: PASS with every candidate-bound assertion executed. -- [ ] **Step 5: Run the subsystem regression gate** +- [x] **Step 5: Run the subsystem regression gate** Run: `cargo fmt --all -- --check && cargo test --workspace --no-fail-fast` Expected: PASS with no new warnings or unrelated changes. + Reconciled 2026-08-01 as an already-integrated baseline. The exact left-trim + owner passes across selected/intersecting target calculation, empty no-op, + pending suppression, error feedback, retry, focus, and the typed trim command + chain. The owner and guard fix landed together in `56f8da1`; its parent lacks + the owner. Current Web/build and Rust gates pass. No production change was + required. + ### Task 15: control-acceptance (implementation-slice-29b311273420852d) **Covered records:** @@ -1181,34 +1433,41 @@ - Visible/accessibility/return path: success=trim selected clip ends to playhead: edit.trimEndToPlayhead computes right-edge trim edits for selected/intersecting clips; accessibility={"focus":"Custom GlyphButton focus behavior depends on its implementation","label":"t(\"toolbar.trimEnd\")","shortcut":"None declared on this control"}; returnPath=["Focus remains on the toolbar control; edit commands update the editor mirror/selection."]. - Outcome matrix: {"success":"trim selected clip ends to playhead: edit.trimEndToPlayhead computes right-edge trim edits for selected/intersecting clips","pending":"Pending behavior is the concrete busy/phase/disabled state in web/src/components/toolbar/Toolbar.tsx:144; no additional state is inferred beyond the source.","empty":"Required empty/no-selection behavior is the render/handler guard in web/src/components/toolbar/Toolbar.tsx:144; the candidate-specific interaction test must assert that exact guard.","disabled":"No explicit disabled prop on this candidate.","cancel":"Not applicable — this immediate handler has no cancellable operation or dismissible transient surface.","retry":"Retry is another activation after the source-defined pending guard clears; no separate retry command exists unless named in edit.trimEndToPlayhead computes right-edge trim edits for selected/intersecting clips.","failure":"Failure behavior is limited to the catch/void-call behavior visible in web/src/components/toolbar/Toolbar.tsx:144; the missing DOM test must prove whether it is surfaced or silent."}. -- [ ] **Step 1: Write or extend every reviewed owning test** +- [x] **Step 1: Write or extend every reviewed owning test** - `web/src/components/toolbar/Toolbar.interaction.test.tsx#control-eeff92d3d70361d9 trim selected clip ends to playhead` (reviewed-planned) — The control acceptance contract explicitly names this owning component test runner. Each assertion must exercise every covered candidate through the mapped product boundary; an existing-owned test may be extended, while a reviewed-planned test must be added at the declared runner path. -- [ ] **Step 2: Run all focused tests and verify RED** +- [x] **Step 2: Run all focused tests and reconcile the historical baseline** - Run: `pnpm -C web test -- --run src/components/toolbar/Toolbar.interaction.test.tsx -t "control-eeff92d3d70361d9 trim selected clip ends to playhead"` Expected: FAIL because one or more of the 1 candidate-bound contracts are not yet satisfied. -- [ ] **Step 3: Implement the minimal vertical slice** +- [x] **Step 3: Implement the minimal vertical slice** Modify only `web/src/components/toolbar/Toolbar.tsx`, `web/src/store/editActions.ts#trimEndToPlayhead`, `web/src/lib/api.ts#editApply`, `src-tauri/src/commands.rs#edit_apply`, `crates/opentake-ops/src/command.rs`, `web/src/components/toolbar/Toolbar.tsx#Toolbar` as required to satisfy every listed acceptance criterion, including visible success and explicit failure/recovery behavior. -- [ ] **Step 4: Run all focused tests and verify GREEN** +- [x] **Step 4: Run all focused tests and verify GREEN** - Run: `pnpm -C web test -- --run src/components/toolbar/Toolbar.interaction.test.tsx -t "control-eeff92d3d70361d9 trim selected clip ends to playhead"` Expected: PASS with every candidate-bound assertion executed. -- [ ] **Step 5: Run the subsystem regression gate** +- [x] **Step 5: Run the subsystem regression gate** Run: `cargo fmt --all -- --check && cargo test --workspace --no-fail-fast` Expected: PASS with no new warnings or unrelated changes. + Reconciled 2026-08-01 as an already-integrated baseline. The exact right-trim + owner passes across selected/intersecting target calculation, empty no-op, + pending suppression, error feedback, retry, focus, and the typed trim command + chain. The owner and guard fix landed together in `d84009e`; its parent lacks + the owner. Current Web/build and Rust gates pass. No production change was + required. + ### Task 16: control-acceptance (implementation-slice-10afd9e138d27853) **Covered records:** @@ -1239,34 +1498,41 @@ - Visible/accessibility/return path: success=add a text clip: edit.addTextClip inserts/selects a new top-track text clip; accessibility={"focus":"Custom GlyphButton focus behavior depends on its implementation","label":"t(\"toolbar.addText\")","shortcut":"None declared on this control"}; returnPath=["Focus remains on the toolbar control; edit commands update the editor mirror/selection."]. - Outcome matrix: {"success":"add a text clip: edit.addTextClip inserts/selects a new top-track text clip","pending":"Pending behavior is the concrete busy/phase/disabled state in web/src/components/toolbar/Toolbar.tsx:150; no additional state is inferred beyond the source.","empty":"Not applicable — this control does not consume a collection, selection, or free-form payload that has an empty state.","disabled":"No explicit disabled prop on this candidate.","cancel":"Not applicable — this immediate handler has no cancellable operation or dismissible transient surface.","retry":"Retry is another activation after the source-defined pending guard clears; no separate retry command exists unless named in edit.addTextClip inserts/selects a new top-track text clip.","failure":"Failure behavior is limited to the catch/void-call behavior visible in web/src/components/toolbar/Toolbar.tsx:150; the missing DOM test must prove whether it is surfaced or silent."}. -- [ ] **Step 1: Write or extend every reviewed owning test** +- [x] **Step 1: Write or extend every reviewed owning test** - `web/src/components/toolbar/Toolbar.interaction.test.tsx#control-c6a658045b9e1d6c add a text clip` (reviewed-planned) — The control acceptance contract explicitly names this owning component test runner. Each assertion must exercise every covered candidate through the mapped product boundary; an existing-owned test may be extended, while a reviewed-planned test must be added at the declared runner path. -- [ ] **Step 2: Run all focused tests and verify RED** +- [x] **Step 2: Run all focused tests and reconcile the historical baseline** - Run: `pnpm -C web test -- --run src/components/toolbar/Toolbar.interaction.test.tsx -t "control-c6a658045b9e1d6c add a text clip"` Expected: FAIL because one or more of the 1 candidate-bound contracts are not yet satisfied. -- [ ] **Step 3: Implement the minimal vertical slice** +- [x] **Step 3: Implement the minimal vertical slice** Modify only `web/src/components/toolbar/Toolbar.tsx`, `web/src/store/editActions.ts#addTextClip`, `web/src/lib/api.ts#editApply`, `src-tauri/src/commands.rs#edit_apply`, `crates/opentake-ops/src/command.rs`, `web/src/components/toolbar/Toolbar.tsx#Toolbar` as required to satisfy every listed acceptance criterion, including visible success and explicit failure/recovery behavior. -- [ ] **Step 4: Run all focused tests and verify GREEN** +- [x] **Step 4: Run all focused tests and verify GREEN** - Run: `pnpm -C web test -- --run src/components/toolbar/Toolbar.interaction.test.tsx -t "control-c6a658045b9e1d6c add a text clip"` Expected: PASS with every candidate-bound assertion executed. -- [ ] **Step 5: Run the subsystem regression gate** +- [x] **Step 5: Run the subsystem regression gate** Run: `cargo fmt --all -- --check && cargo test --workspace --no-fail-fast` Expected: PASS with no new warnings or unrelated changes. + Reconciled 2026-08-01 as an already-integrated baseline. The exact Add Text + owner passes across default entry construction, pending duplicate suppression, + successful selection, failure feedback, retry, focus, and the typed + `addTextsAutoTrack` command chain. The owner and guard fix landed together in + `8bea548`; its parent lacks the owner. Current Web/build and Rust gates pass. + No production change was required. + ## Shared capability references - `mcp-transport` / `implementation-slice-078ed22bfa23f28a`: implemented once in `data-safety`; this group contributes records `requirement-1990a4b0e0df5397`, `requirement-a4194cf440c740ca`, `requirement-12ca71ff2bf25b39` as acceptance references. diff --git a/docs/audit/2026-07-14/implementation-plans/data-safety-implementation.md b/docs/audit/2026-07-14/implementation-plans/data-safety-implementation.md index 7a7a7249..6f78adee 100644 --- a/docs/audit/2026-07-14/implementation-plans/data-safety-implementation.md +++ b/docs/audit/2026-07-14/implementation-plans/data-safety-implementation.md @@ -38,7 +38,7 @@ - Add deterministic fixtures for every named current, legacy, missing, malformed, and fail-closed branch; the focused round-trip and compatibility suites must pass. - Exercise open, edit, save, and reopen on representative bundles and attach the exact implementation symbols plus test or runtime evidence before reclassification. -- [ ] **Step 1: Write or extend every reviewed owning test** +- [x] **Step 1: Write or extend every reviewed owning test** - `crates/opentake-project/tests/upstream_compat.rs#applies_clip_defaults_for_omitted_fields` (existing-owned) — Exact named test already exists in the reviewed owning runner and records current boundary behavior. - `crates/opentake-project/tests/upstream_compat.rs#migrates_legacy_transform_xy_to_center` (existing-owned) — Exact named test already exists in the reviewed owning runner and records current boundary behavior. @@ -47,7 +47,7 @@ Each assertion must exercise every covered candidate through the mapped product boundary; an existing-owned test may be extended, while a reviewed-planned test must be added at the declared runner path. -- [ ] **Step 2: Run all focused tests and verify RED** +- [x] **Step 2: Run all focused tests and verify RED** - Run: `cargo test -p opentake-project --test upstream_compat applies_clip_defaults_for_omitted_fields -- --exact` - Run: `cargo test -p opentake-project --test upstream_compat migrates_legacy_transform_xy_to_center -- --exact` @@ -56,11 +56,11 @@ Expected: FAIL because one or more of the 1 candidate-bound contracts are not yet satisfied. -- [ ] **Step 3: Implement the minimal vertical slice** +- [x] **Step 3: Implement the minimal vertical slice** Modify only `crates/opentake-project/src/bundle.rs#Project`, `crates/opentake-domain/src/clip.rs#Clip`, `crates/opentake-domain/src/media.rs#MediaManifest`, `crates/opentake-project/src/gen_log.rs#GenerationLogEntry`, `docs/architecture/MODULE-PORT-MAP.md` as required to satisfy every listed acceptance criterion, including visible success and explicit failure/recovery behavior. -- [ ] **Step 4: Run all focused tests and verify GREEN** +- [x] **Step 4: Run all focused tests and verify GREEN** - Run: `cargo test -p opentake-project --test upstream_compat applies_clip_defaults_for_omitted_fields -- --exact` - Run: `cargo test -p opentake-project --test upstream_compat migrates_legacy_transform_xy_to_center -- --exact` @@ -69,12 +69,14 @@ Expected: PASS with every candidate-bound assertion executed. -- [ ] **Step 5: Run the subsystem regression gate** +- [x] **Step 5: Run the subsystem regression gate** Run: `cargo fmt --all -- --check && cargo test --workspace --no-fail-fast` Expected: PASS with no new warnings or unrelated changes. +Completion evidence: [`data-safety-legacy-default-matrix-real-device-2026-07-31.md`](../runtime-artifacts/automated/data-safety-legacy-default-matrix-real-device-2026-07-31.md). The four exact owning tests already passed at the initial baseline, so Task 1 required evidence closure and a packaged-app round trip rather than a new production patch; no artificial RED failure was introduced. + ### Task 2: DS-mcp-transport (implementation-slice-078ed22bfa23f28a) **Covered records:** @@ -101,7 +103,7 @@ - Origin and Host DNS-rebinding guards, request-size limit, and MCP protocol-version validation are all active and independently tested. - HTTP integration tests prove external Host/Origin and unsupported protocol versions never invoke a tool. -- [ ] **Step 1: Write or extend every reviewed owning test** +- [x] **Step 1: Write or extend every reviewed owning test** - `crates/opentake-agent/tests/mcp_http.rs#non_local_origin_is_rejected` (existing-owned) — Exact named test already exists in the reviewed owning runner and records current boundary behavior. - `crates/opentake-agent/tests/mcp_http.rs#oversized_request_body_is_rejected` (existing-owned) — Exact named test already exists in the reviewed owning runner and records current boundary behavior. @@ -110,7 +112,7 @@ Each assertion must exercise every covered candidate through the mapped product boundary; an existing-owned test may be extended, while a reviewed-planned test must be added at the declared runner path. -- [ ] **Step 2: Run all focused tests and verify RED** +- [x] **Step 2: Run all focused tests and verify RED** - Run: `cargo test -p opentake-agent --test mcp_http non_local_origin_is_rejected -- --exact` - Run: `cargo test -p opentake-agent --test mcp_http oversized_request_body_is_rejected -- --exact` @@ -119,11 +121,11 @@ Expected: FAIL because one or more of the 1 candidate-bound contracts are not yet satisfied. -- [ ] **Step 3: Implement the minimal vertical slice** +- [x] **Step 3: Implement the minimal vertical slice** Modify only `crates/opentake-agent/src/mcp/server.rs#McpServer`, `crates/opentake-agent/src/mcp/server.rs#serve_with_bridge`, `docs/modules/opentake-agent/SPEC.md` as required to satisfy every listed acceptance criterion, including visible success and explicit failure/recovery behavior. -- [ ] **Step 4: Run all focused tests and verify GREEN** +- [x] **Step 4: Run all focused tests and verify GREEN** - Run: `cargo test -p opentake-agent --test mcp_http non_local_origin_is_rejected -- --exact` - Run: `cargo test -p opentake-agent --test mcp_http oversized_request_body_is_rejected -- --exact` @@ -132,12 +134,14 @@ Expected: PASS with every candidate-bound assertion executed. -- [ ] **Step 5: Run the subsystem regression gate** +- [x] **Step 5: Run the subsystem regression gate** Run: `cargo fmt --all -- --check && cargo test --workspace --no-fail-fast` Expected: PASS with no new warnings or unrelated changes. +Completion evidence: [`data-safety-mcp-transport-real-device-2026-07-31.md`](../runtime-artifacts/automated/data-safety-mcp-transport-real-device-2026-07-31.md). The four exact owning tests already passed at the initial baseline, so Task 2 required packaged-server verification and evidence closure rather than a new production patch; no artificial RED failure was introduced. + ### Task 3: DS-mcp-tool-import (implementation-slice-a5dcb81bd0966174) **Covered records:** @@ -166,7 +170,7 @@ - Reject non-HTTPS/userinfo/redirect-to-non-HTTPS URLs before I/O; infer or validate the extension/MIME allowlist; stream to a staging file while enforcing the 1 GB decoded-byte cap across redirects. - Publish only after download, type/probe, and retained-project validation; cancellation/error cleans staging and leaves manifest unchanged. -- [ ] **Step 1: Write or extend every reviewed owning test** +- [x] **Step 1: Write or extend every reviewed owning test** - `crates/opentake-agent/src/mcp/dispatch.rs#import_media_requires_exactly_one_source` (existing-owned) — Exact named test already exists in the reviewed owning runner and records current boundary behavior. - `crates/opentake-agent/src/mcp/dispatch.rs#import_media_rejects_unknown_nested_source_key` (existing-owned) — Exact named test already exists in the reviewed owning runner and records current boundary behavior. @@ -176,7 +180,7 @@ Each assertion must exercise every covered candidate through the mapped product boundary; an existing-owned test may be extended, while a reviewed-planned test must be added at the declared runner path. -- [ ] **Step 2: Run all focused tests and verify RED** +- [x] **Step 2: Run all focused tests and verify RED** - Run: `cargo test -p opentake-agent import_media_requires_exactly_one_source` - Run: `cargo test -p opentake-agent import_media_rejects_unknown_nested_source_key` @@ -186,11 +190,11 @@ Expected: FAIL because one or more of the 1 candidate-bound contracts are not yet satisfied. -- [ ] **Step 3: Implement the minimal vertical slice** +- [x] **Step 3: Implement the minimal vertical slice** Modify only `crates/opentake-agent/src/tools/errors.rs#decode_tool_args`, `crates/opentake-agent/src/mcp/dispatch.rs#Dispatcher`, `src-tauri/src/mcp.rs#TauriMediaBridge`, `docs/modules/opentake-agent/SPEC.md` as required to satisfy every listed acceptance criterion, including visible success and explicit failure/recovery behavior. -- [ ] **Step 4: Run all focused tests and verify GREEN** +- [x] **Step 4: Run all focused tests and verify GREEN** - Run: `cargo test -p opentake-agent import_media_requires_exactly_one_source` - Run: `cargo test -p opentake-agent import_media_rejects_unknown_nested_source_key` @@ -200,12 +204,14 @@ Expected: PASS with every candidate-bound assertion executed. -- [ ] **Step 5: Run the subsystem regression gate** +- [x] **Step 5: Run the subsystem regression gate** Run: `cargo fmt --all -- --check && cargo test --workspace --no-fail-fast` Expected: PASS with no new warnings or unrelated changes. +Completion evidence: [`data-safety-mcp-tool-import-real-device-2026-07-31.md`](../runtime-artifacts/automated/data-safety-mcp-tool-import-real-device-2026-07-31.md). The initial exact argument-matrix command executed zero tests because its owning test had an extra suffix; the test was renamed to the plan-declared contract and then passed 1/1. Production import guards were already complete, and the packaged server passed path, bytes, HTTPS, rejection, persistence, and UI checks. + ### Task 4: DS-mcp-redaction (implementation-slice-673f9e3f6002f97b) **Covered records:** @@ -228,34 +234,36 @@ - Introduce a boundary sanitizer with typed safe error codes/details and private structured logging. - Adversarial tests inject a home path, API key, bearer token, signed URL query, provider body, and nested source error and assert none appear in MCP content while remediation remains actionable. -- [ ] **Step 1: Write or extend every reviewed owning test** +- [x] **Step 1: Write or extend every reviewed owning test** - `crates/opentake-agent/tests/mcp_error_redaction.rs#llm_errors_redact_paths_credentials_headers_provider_bodies` (reviewed-planned) — Reviewed planned test belongs in this tracked owning runner beside the mapped product boundary. Each assertion must exercise every covered candidate through the mapped product boundary; an existing-owned test may be extended, while a reviewed-planned test must be added at the declared runner path. -- [ ] **Step 2: Run all focused tests and verify RED** +- [x] **Step 2: Run all focused tests and verify RED** - Run: `cargo test -p opentake-agent --test mcp_error_redaction llm_errors_redact_paths_credentials_headers_provider_bodies -- --exact` Expected: FAIL because one or more of the 1 candidate-bound contracts are not yet satisfied. -- [ ] **Step 3: Implement the minimal vertical slice** +- [x] **Step 3: Implement the minimal vertical slice** Modify only `src-tauri/src/mcp.rs#TauriMediaBridge`, `crates/opentake-agent/src/mcp/convert.rs#to_call_tool_result`, `docs/modules/opentake-agent/SPEC.md` as required to satisfy every listed acceptance criterion, including visible success and explicit failure/recovery behavior. -- [ ] **Step 4: Run all focused tests and verify GREEN** +- [x] **Step 4: Run all focused tests and verify GREEN** - Run: `cargo test -p opentake-agent --test mcp_error_redaction llm_errors_redact_paths_credentials_headers_provider_bodies -- --exact` Expected: PASS with every candidate-bound assertion executed. -- [ ] **Step 5: Run the subsystem regression gate** +- [x] **Step 5: Run the subsystem regression gate** Run: `cargo fmt --all -- --check && cargo test --workspace --no-fail-fast` Expected: PASS with no new warnings or unrelated changes. +Completion evidence: [`data-safety-mcp-redaction-real-device-2026-07-31.md`](../runtime-artifacts/automated/data-safety-mcp-redaction-real-device-2026-07-31.md). The exact owning matrix already passed at the initial baseline; the packaged MCP server independently proved adversarial path, credential, authorization, signed-query, and provider-style strings never reached response content. + ### Task 5: DS-generation-seed (implementation-slice-4f2d8bcaff47a37f) **Covered records:** @@ -292,37 +300,39 @@ - Add deterministic fixtures for every named current, legacy, missing, malformed, and fail-closed branch; the focused round-trip and compatibility suites must pass. - Exercise open, edit, save, and reopen on representative bundles and attach the exact implementation symbols plus test or runtime evidence before reclassification. -- [ ] **Step 1: Write or extend every reviewed owning test** +- [x] **Step 1: Write or extend every reviewed owning test** - `crates/opentake-project/tests/roundtrip.rs#malformed_generation_log_is_ignored` (existing-owned) — Exact named test already exists in the reviewed owning runner and records current boundary behavior. - `crates/opentake-core/tests/project_open.rs#missing_generation_log_seeds_manifest_provenance_once` (reviewed-planned) — Reviewed planned test belongs in this tracked owning runner beside the mapped product boundary. Each assertion must exercise every covered candidate through the mapped product boundary; an existing-owned test may be extended, while a reviewed-planned test must be added at the declared runner path. -- [ ] **Step 2: Run all focused tests and verify RED** +- [x] **Step 2: Run all focused tests and verify RED** - Run: `cargo test -p opentake-project --test roundtrip malformed_generation_log_is_ignored -- --exact` - Run: `cargo test -p opentake-core --test project_open missing_generation_log_seeds_manifest_provenance_once -- --exact` Expected: FAIL because one or more of the 2 candidate-bound contracts are not yet satisfied. -- [ ] **Step 3: Implement the minimal vertical slice** +- [x] **Step 3: Implement the minimal vertical slice** Modify only `crates/opentake-project/src/bundle.rs#Project`, `crates/opentake-core/src/session.rs#EditorSession`, `docs/modules/opentake-core/SPEC.md`, `docs/specs/core/5-assembly.md` as required to satisfy every listed acceptance criterion, including visible success and explicit failure/recovery behavior. -- [ ] **Step 4: Run all focused tests and verify GREEN** +- [x] **Step 4: Run all focused tests and verify GREEN** - Run: `cargo test -p opentake-project --test roundtrip malformed_generation_log_is_ignored -- --exact` - Run: `cargo test -p opentake-core --test project_open missing_generation_log_seeds_manifest_provenance_once -- --exact` Expected: PASS with every candidate-bound assertion executed. -- [ ] **Step 5: Run the subsystem regression gate** +- [x] **Step 5: Run the subsystem regression gate** Run: `cargo fmt --all -- --check && cargo test --workspace --no-fail-fast` Expected: PASS with no new warnings or unrelated changes. +Completion evidence: [`data-safety-generation-seed-real-device-2026-07-31.md`](../runtime-artifacts/automated/data-safety-generation-seed-real-device-2026-07-31.md). Both exact owning tests already passed at the initial baseline; the packaged application additionally proved missing-log seed, duplicate-provenance collapse, edit/save/reopen, and byte-stable idempotence. + ### Task 6: DS-cache-identity-complete (implementation-slice-5af4f1ababc7b495) **Covered records:** @@ -352,7 +362,7 @@ - Visible/returned assertion: assert the exact success payload for the valid case and a stable typed error for the invalid case, including zero partial side effects and no leaked internal path or credential. - Evidence required: after the deterministic test passes, record code:# and test:#; proposed concrete evidence is test:crates/opentake-project/tests/completion_1cb2f0539425a14e.rs#completion_1cb2f0539425a14e_derive_a_stable_lowercase_32_hex_file_identity_f. -- [ ] **Step 1: Write or extend every reviewed owning test** +- [x] **Step 1: Write or extend every reviewed owning test** - `crates/opentake-media/src/cache_key.rs#identity_hex_is_stable_and_lowercase` (existing-owned) — Exact named test already exists in the reviewed owning runner and records current boundary behavior. - `crates/opentake-media/src/cache_key.rs#identity_hex_matches_swift_for_whole_second_mtime` (existing-owned) — Exact named test already exists in the reviewed owning runner and records current boundary behavior. @@ -360,7 +370,7 @@ Each assertion must exercise every covered candidate through the mapped product boundary; an existing-owned test may be extended, while a reviewed-planned test must be added at the declared runner path. -- [ ] **Step 2: Run all focused tests and verify RED** +- [x] **Step 2: Run all focused tests and verify RED** - Run: `cargo test -p opentake-media identity_hex_is_stable_and_lowercase` - Run: `cargo test -p opentake-media identity_hex_matches_swift_for_whole_second_mtime` @@ -368,11 +378,11 @@ Expected: FAIL because one or more of the 1 candidate-bound contracts are not yet satisfied. -- [ ] **Step 3: Implement the minimal vertical slice** +- [x] **Step 3: Implement the minimal vertical slice** Modify only `crates/opentake-media/src/cache_key.rs#file_identity_key`, `crates/opentake-media/src/cache_key.rs#identity_hex`, `docs/modules/opentake-media/SPEC.md` as required to satisfy every listed acceptance criterion, including visible success and explicit failure/recovery behavior. -- [ ] **Step 4: Run all focused tests and verify GREEN** +- [x] **Step 4: Run all focused tests and verify GREEN** - Run: `cargo test -p opentake-media identity_hex_is_stable_and_lowercase` - Run: `cargo test -p opentake-media identity_hex_matches_swift_for_whole_second_mtime` @@ -380,12 +390,14 @@ Expected: PASS with every candidate-bound assertion executed. -- [ ] **Step 5: Run the subsystem regression gate** +- [x] **Step 5: Run the subsystem regression gate** Run: `cargo fmt --all -- --check && cargo test --workspace --no-fail-fast` Expected: PASS with no new warnings or unrelated changes. +Completion evidence: [`data-safety-cache-identity-real-device-2026-07-31.md`](../runtime-artifacts/automated/data-safety-cache-identity-real-device-2026-07-31.md). The three exact owning tests already passed at baseline; a packaged-app waveform cache filename also matched an independent Foundation/CryptoKit calculation for the same real file byte-for-byte. + ### Task 7: DS-shared-core-command-complete (implementation-slice-63ec0e639957e775) **Covered records:** @@ -554,7 +566,7 @@ - Visible/returned assertion: assert the returned project/error variant, exact post-operation files and decoded state, and save-then-reopen equality or the specified fail-closed no-write result. - Evidence required: after the deterministic test passes, record code:# and test:#; proposed concrete evidence is test:tools/completion-tests/doc-c95515ae8f048e8a.test.mjs#completion_c95515ae8f048e8a_editor_state_and_edits_are_owned_by_the_shared_r. -- [ ] **Step 1: Write or extend every reviewed owning test** +- [x] **Step 1: Write or extend every reviewed owning test** - `crates/opentake-ops/src/editor_state.rs#commit_undo_redo_cycle_restores_and_versions` (existing-owned) — Exact named test already exists in the reviewed owning runner and records current boundary behavior. - `crates/opentake-core/src/core.rs#apply_bumps_version_and_emits_once` (existing-owned) — Exact named test already exists in the reviewed owning runner and records current boundary behavior. @@ -563,7 +575,7 @@ Each assertion must exercise every covered candidate through the mapped product boundary; an existing-owned test may be extended, while a reviewed-planned test must be added at the declared runner path. -- [ ] **Step 2: Run all focused tests and verify RED** +- [x] **Step 2: Run all focused tests and verify RED** - Run: `cargo test -p opentake-ops commit_undo_redo_cycle_restores_and_versions` - Run: `cargo test -p opentake-core apply_bumps_version_and_emits_once` @@ -572,11 +584,11 @@ Expected: FAIL because one or more of the 10 candidate-bound contracts are not yet satisfied. -- [ ] **Step 3: Implement the minimal vertical slice** +- [x] **Step 3: Implement the minimal vertical slice** Modify only `crates/opentake-ops/src/editor_state.rs#EditorState`, `crates/opentake-ops/src/command.rs#EditCommand`, `crates/opentake-core/src/core.rs#AppCore`, `crates/opentake-core/src/events.rs#EventBus`, `docs/specs/core/1-editor-state.md`, `docs/specs/core/2-command-routing.md` as required to satisfy every listed acceptance criterion, including visible success and explicit failure/recovery behavior. -- [ ] **Step 4: Run all focused tests and verify GREEN** +- [x] **Step 4: Run all focused tests and verify GREEN** - Run: `cargo test -p opentake-ops commit_undo_redo_cycle_restores_and_versions` - Run: `cargo test -p opentake-core apply_bumps_version_and_emits_once` @@ -585,7 +597,7 @@ Expected: PASS with every candidate-bound assertion executed. -- [ ] **Step 5: Run the subsystem regression gate** +- [x] **Step 5: Run the subsystem regression gate** Run: `cargo fmt --all -- --check && cargo test --workspace --no-fail-fast` @@ -667,7 +679,7 @@ - On every pre-commit failure, drop prepared capabilities, restore acquired transitions, publish no usable session, and preserve all project files unchanged. - Fault-inject the owned failure boundaries and assert cleanup/event order, then open/save/reopen valid and migrated fixtures with identical IDs, frames, media, and generation history. -- [ ] **Step 1: Write or extend every reviewed owning test** +- [x] **Step 1: Write or extend every reviewed owning test** - `crates/opentake-project/tests/upstream_compat.rs#exhaustive_legacy_default_matrix` (reviewed-planned) — Reviewed planned test belongs in this tracked owning runner beside the mapped product boundary. - `crates/opentake-core/tests/project_open.rs#missing_generation_log_seeds_manifest_provenance_once` (reviewed-planned) — Reviewed planned test belongs in this tracked owning runner beside the mapped product boundary. @@ -681,7 +693,7 @@ Each assertion must exercise every covered candidate through the mapped product boundary; an existing-owned test may be extended, while a reviewed-planned test must be added at the declared runner path. -- [ ] **Step 2: Run all focused tests and verify RED** +- [x] **Step 2: Run all focused tests and verify RED** - Run: `cargo test -p opentake-project --test upstream_compat exhaustive_legacy_default_matrix -- --exact` - Run: `cargo test -p opentake-core --test project_open missing_generation_log_seeds_manifest_provenance_once -- --exact` @@ -695,11 +707,11 @@ Expected: FAIL because one or more of the 4 candidate-bound contracts are not yet satisfied. -- [ ] **Step 3: Implement the minimal vertical slice** +- [x] **Step 3: Implement the minimal vertical slice** Modify only the files listed for Task 8 as required to satisfy every listed acceptance criterion, including atomic combined reads, visible success, and explicit failure/recovery behavior. -- [ ] **Step 4: Run all focused tests and verify GREEN** +- [x] **Step 4: Run all focused tests and verify GREEN** - Run: `cargo test -p opentake-project --test upstream_compat exhaustive_legacy_default_matrix -- --exact` - Run: `cargo test -p opentake-core --test project_open missing_generation_log_seeds_manifest_provenance_once -- --exact` @@ -713,7 +725,7 @@ Expected: PASS with every candidate-bound assertion executed. -- [ ] **Step 5: Run the subsystem regression gate** +- [x] **Step 5: Run the subsystem regression gate** Run: `cargo fmt --all -- --check && cargo test --workspace --no-fail-fast` @@ -748,14 +760,14 @@ - Add deterministic fixtures for every named current, legacy, missing, malformed, and fail-closed branch; the focused round-trip and compatibility suites must pass. - Exercise open, edit, save, and reopen on a complete current version-2 bundle. Prove every rejected open/save leaves the full nofollow bundle tree unchanged, and prove rejected Save As creates no destination, journal, staging, symlink, or other sibling artifact. -- [ ] **Step 1: Write or extend every reviewed owning test** +- [x] **Step 1: Write or extend every reviewed owning test** - `crates/opentake-project/tests/roundtrip.rs#malformed_manifest_is_an_error` (existing-owned) — Exact named test already exists in the reviewed owning runner and records current boundary behavior. - `crates/opentake-project/tests/schema_compat.rs#malformed_manifest_contract_matches_authoritative_source` (reviewed-planned) — Reviewed planned test belongs in this tracked owning runner beside the mapped product boundary. Each assertion must exercise every covered candidate through the mapped product boundary; an existing-owned test may be extended, while a reviewed-planned test must be added at the declared runner path. -- [ ] **Step 2: Run all focused tests and verify RED** +- [x] **Step 2: Run all focused tests and verify RED** - Run: `cargo test -p opentake-project --test roundtrip malformed_manifest_is_an_error -- --exact` - Run: `cargo test -p opentake-project --test schema_compat malformed_manifest_contract_matches_authoritative_source -- --exact` @@ -763,20 +775,20 @@ Expected: the existing round-trip smoke passes, while the reviewed-planned schema contract is absent (`running 0 tests`), so the gate is not satisfied. -- [ ] **Step 3: Implement the minimal vertical slice** +- [x] **Step 3: Implement the minimal vertical slice** Add the missing owning test and reconcile the comments/spec/plan in the files listed for Task 9. Do not change the already-correct `Project` or `MediaManifest` runtime behavior. -- [ ] **Step 4: Run all focused tests and verify GREEN** +- [x] **Step 4: Run all focused tests and verify GREEN** - Run: `cargo test -p opentake-project --test roundtrip malformed_manifest_is_an_error -- --exact` - Run: `cargo test -p opentake-project --test schema_compat malformed_manifest_contract_matches_authoritative_source -- --exact` Expected: PASS with every candidate-bound assertion executed. -- [ ] **Step 5: Run the subsystem regression gate** +- [x] **Step 5: Run the subsystem regression gate** Run: `cargo fmt --all -- --check && cargo test --workspace --no-fail-fast` @@ -1040,13 +1052,18 @@ before namespace mutation; downstream algorithm tests use test-only trusted fixt - Acquisition, DACL checks, I/O, quarantine, no-replace publish, cleanup, reparse-point, source-swap, and cancellation regressions pass on native Windows. - The x86_64-pc-windows-msvc build, warnings-denied clippy, repository Windows jobs, and independent review all pass for the exact tree. -- [ ] **Step 1: Write or extend every reviewed owning test** +- [x] **Step 1: Write or extend every reviewed owning test** - `crates/opentake-project/src/safe_fs/tests.rs#windows_contract` (reviewed-planned) — Reviewed planned test belongs in this tracked owning runner beside the mapped product boundary. - `crates/opentake-project/src/safe_fs/tests.rs#synchronous_nt_pending_is_invariant_error` (reviewed-planned) — Reviewed planned test belongs in this tracked owning runner beside the mapped product boundary. Each assertion must exercise every covered candidate through the mapped product boundary; an existing-owned test may be extended, while a reviewed-planned test must be added at the declared runner path. + Result: Both exact names now execute in the common owning runner on native + Windows. Adapter-local tests cover DACL bounds and malformed descriptors, + retained I/O, rollback, quarantine, no-replace publish, recursive reparse + cleanup, and source-name rebinding resistance. + - [ ] **Step 2: Run all focused tests and verify RED** - Run: `cargo test -p opentake-project windows_contract` @@ -1054,17 +1071,29 @@ before namespace mutation; downstream algorithm tests use test-only trusted fixt Expected: FAIL because one or more of the 3 candidate-bound contracts are not yet satisfied. -- [ ] **Step 3: Implement the minimal vertical slice** +- [x] **Step 3: Implement the minimal vertical slice** Modify only `crates/opentake-project/src/safe_fs/capability.rs#DirectoryAuthority`, `crates/opentake-project/src/safe_fs/ops.rs#capture_absolute_directory`, `docs/superpowers/plans/c1b/2026-07-12-c1b-windows-ci-normative.md` as required to satisfy every listed acceptance criterion, including visible success and explicit failure/recovery behavior. -- [ ] **Step 4: Run all focused tests and verify GREEN** + Result: `windows.rs` is a compile-complete retained-HANDLE backend with + capability-relative no-follow acquisition, synchronous NT status handling, + owner-only security descriptors, I/O, quarantine, no-replace publish, and + non-traversing cleanup. It contains no unsupported-backend include or bypass + implementation. + +- [x] **Step 4: Run all focused tests and verify GREEN** - Run: `cargo test -p opentake-project windows_contract` - Run: `cargo test -p opentake-project synchronous_nt_pending_is_invariant_error` Expected: PASS with every candidate-bound assertion executed. + Result: Native Windows run 30617000877, job 91112395644, executed 26 safe-fs + tests: 26 passed, 0 failed. Formatting, warnings-denied native clippy, and the + archive-security integration suite also exited zero. The immutable receipt is + recorded in + `runtime-artifacts/automated/data-safety-windows-safe-fs-native-2026-07-31.md`. + - [ ] **Step 5: Run the subsystem regression gate** Run: `cargo fmt --all -- --check && cargo test --workspace --no-fail-fast` @@ -1152,7 +1181,7 @@ before namespace mutation; downstream algorithm tests use test-only trusted fixt - Visible/returned assertion: assert the returned project/error variant, exact post-operation files and decoded state, and save-then-reopen equality or the specified fail-closed no-write result. - Evidence required: after the deterministic test passes, record code:# and test:#; proposed concrete evidence is test:tools/completion-tests/doc-bf6666ce4bc65294.test.mjs#completion_bf6666ce4bc65294_resolve_media_ref_to_an_expected_path_while_dist. -- [ ] **Step 1: Write or extend every reviewed owning test** +- [x] **Step 1: Write or extend every reviewed owning test** - `crates/opentake-domain/src/media.rs#resolver_expected_path_external` (existing-owned) — Exact named test already exists in the reviewed owning runner and records current boundary behavior. - `src-tauri/src/media.rs#dto_reports_file_size_for_present_source` (existing-owned) — Exact named test already exists in the reviewed owning runner and records current boundary behavior. @@ -1160,7 +1189,12 @@ before namespace mutation; downstream algorithm tests use test-only trusted fixt Each assertion must exercise every covered candidate through the mapped product boundary; an existing-owned test may be extended, while a reviewed-planned test must be added at the declared runner path. -- [ ] **Step 2: Run all focused tests and verify RED** + Result: The three reviewed Rust owners remain at their declared symbols, and + `tools/completion-tests/doc-bf6666ce4bc65294.test.mjs` now binds them under + the exact generated completion-test name. The runner rejects a zero-test + filter by requiring each Cargo invocation to report exactly one passing test. + +- [x] **Step 2: Run all focused tests and verify RED** - Run: `cargo test -p opentake-domain resolver_expected_path_external` - Run: `cargo test -p opentake-tauri dto_reports_file_size_for_present_source` @@ -1168,11 +1202,22 @@ before namespace mutation; downstream algorithm tests use test-only trusted fixt Expected: FAIL because one or more of the 1 candidate-bound contracts are not yet satisfied. -- [ ] **Step 3: Implement the minimal vertical slice** + Result: The product behavior predated this generated completion slice, so the + honest missing-evidence RED was the absent declared completion-test path; the + required `node --test tools/completion-tests/doc-bf6666ce4bc65294.test.mjs` + entry point could not run. No product regression was manufactured. + +- [x] **Step 3: Implement the minimal vertical slice** Modify only `crates/opentake-domain/src/media.rs#MediaResolver`, `src-tauri/src/media.rs#relink_media`, `docs/upstream-analysis/01-架构与数据流.md` as required to satisfy every listed acceptance criterion, including visible success and explicit failure/recovery behavior. -- [ ] **Step 4: Run all focused tests and verify GREEN** + Result: The reviewed implementation already resolved external and + project-relative sources, derived present/offline state from the filesystem, + and relinked an offline asset in place while preserving its ID. The missing + work was the deterministic evidence bridge; no production-code change was + needed. + +- [x] **Step 4: Run all focused tests and verify GREEN** - Run: `cargo test -p opentake-domain resolver_expected_path_external` - Run: `cargo test -p opentake-tauri dto_reports_file_size_for_present_source` @@ -1180,8 +1225,17 @@ before namespace mutation; downstream algorithm tests use test-only trusted fixt Expected: PASS with every candidate-bound assertion executed. -- [ ] **Step 5: Run the subsystem regression gate** + Result: `node --test + tools/completion-tests/doc-bf6666ce4bc65294.test.mjs` passed 1/1. Its three + exact Cargo filters each executed one owner and passed, covering expected-path + resolution, present-source size/state, and offline-to-present relink with a + stable media ID. + +- [x] **Step 5: Run the subsystem regression gate** Run: `cargo fmt --all -- --check && cargo test --workspace --no-fail-fast` Expected: PASS with no new warnings or unrelated changes. + + Result: `cargo fmt --all -- --check && cargo test --workspace + --no-fail-fast` passed on the exact local tree. diff --git a/docs/audit/2026-07-14/implementation-plans/home-shell-implementation.md b/docs/audit/2026-07-14/implementation-plans/home-shell-implementation.md index 58af4d3c..44e8d370 100644 --- a/docs/audit/2026-07-14/implementation-plans/home-shell-implementation.md +++ b/docs/audit/2026-07-14/implementation-plans/home-shell-implementation.md @@ -34,34 +34,36 @@ - Add browser interaction tests for every named Home, project, persistence, error, keyboard, and state transition; the affected web and Rust suites must pass. - Exercise the packaged application through create/open/close/reopen or the named Home path and retain exact runtime evidence before reclassification. -- [ ] **Step 1: Write or extend every reviewed owning test** +- [x] **Step 1: Write or extend every reviewed owning test** - `web/src/components/home/HomeView.test.tsx#upstream_home_children_close_one_composite_acceptance` (reviewed-planned) — Reviewed planned test belongs in this tracked owning runner beside the mapped product boundary. Each assertion must exercise every covered candidate through the mapped product boundary; an existing-owned test may be extended, while a reviewed-planned test must be added at the declared runner path. -- [ ] **Step 2: Run all focused tests and verify RED** +- [x] **Step 2: Run all focused tests and verify RED** - Run: `pnpm -C web test -- --run src/components/home/HomeView.test.tsx -t "upstream_home_children_close_one_composite_acceptance"` Expected: FAIL because one or more of the 1 candidate-bound contracts are not yet satisfied. -- [ ] **Step 3: Implement the minimal vertical slice** +- [x] **Step 3: Implement the minimal vertical slice** Modify only `web/src/components/home/HomeView.tsx#HomeView`, `web/src/store/recentStore.ts#useRecentStore`, `web/src/store/projectActions.ts#openProjectPath`, `docs/architecture/MODULE-PORT-MAP.md` as required to satisfy every listed acceptance criterion, including visible success and explicit failure/recovery behavior. -- [ ] **Step 4: Run all focused tests and verify GREEN** +- [x] **Step 4: Run all focused tests and verify GREEN** - Run: `pnpm -C web test -- --run src/components/home/HomeView.test.tsx -t "upstream_home_children_close_one_composite_acceptance"` Expected: PASS with every candidate-bound assertion executed. -- [ ] **Step 5: Run the subsystem regression gate** +- [x] **Step 5: Run the subsystem regression gate** Run: `pnpm -C web test -- --run && pnpm -C web build` Expected: PASS with no new warnings or unrelated changes. + Result: PASS. The missing exact composite runner first produced a zero-candidate skipped RED baseline, then passed 1/1 after the Home boundary covered sidebar, samples, first-run welcome, version update, project-card keyboard open, missing/context state, and safe-trash confirmation. Welcome/update state uses the build-time application version, persists only after dismissal, receives initial focus, and supports Escape. The complete Web suite passed 82 files / 774 tests and the production build passed with only the pre-existing bundle warnings. The rebuilt macOS app displayed the v1.0.0 update surface, returned to the complete Home shell, and did not redisplay it after quit/relaunch; child runtime records cover native new/open/sample, safe trash, autosave/reopen, and secondary controls. Runtime evidence: [`home-upstream-composite-real-device-2026-07-31.md`](../runtime-artifacts/automated/home-upstream-composite-real-device-2026-07-31.md). + ### Task 2: HS-new-open-sample (implementation-slice-406b2853a5d6f67b) **Covered records:** @@ -89,37 +91,39 @@ - Add browser interaction tests for every named Home, project, persistence, error, keyboard, and state transition; the affected web and Rust suites must pass. - Exercise the packaged application through create/open/close/reopen or the named Home path and retain exact runtime evidence before reclassification. -- [ ] **Step 1: Write or extend every reviewed owning test** +- [x] **Step 1: Write or extend every reviewed owning test** - `src-tauri/src/samples.rs#failed_materialization_rolls_back_entire_sample_directory` (reviewed-planned) — Reviewed planned test belongs in this tracked owning runner beside the mapped product boundary. - `web/src/components/home/HomeView.test.tsx#new_open_sample_register_only_after_success_and_route_tutorial` (reviewed-planned) — Reviewed planned test belongs in this tracked owning runner beside the mapped product boundary. Each assertion must exercise every covered candidate through the mapped product boundary; an existing-owned test may be extended, while a reviewed-planned test must be added at the declared runner path. -- [ ] **Step 2: Run all focused tests and verify RED** +- [x] **Step 2: Run all focused tests and verify RED** - Run: `cargo test -p opentake-tauri failed_materialization_rolls_back_entire_sample_directory` - Run: `pnpm -C web test -- --run src/components/home/HomeView.test.tsx -t "new_open_sample_register_only_after_success_and_route_tutorial"` Expected: FAIL because one or more of the 1 candidate-bound contracts are not yet satisfied. -- [ ] **Step 3: Implement the minimal vertical slice** +- [x] **Step 3: Implement the minimal vertical slice** Modify only `web/src/components/home/HomeView.tsx#HomeView`, `web/src/store/projectActions.ts#newProjectAndEnter`, `web/src/store/projectActions.ts#openProjectPath`, `web/src/store/projectActions.ts#openProjectViaDialog`, `src-tauri/src/samples.rs#SampleProjectService`, `docs/architecture/MODULE-PORT-MAP.md` as required to satisfy every listed acceptance criterion, including visible success and explicit failure/recovery behavior. -- [ ] **Step 4: Run all focused tests and verify GREEN** +- [x] **Step 4: Run all focused tests and verify GREEN** - Run: `cargo test -p opentake-tauri failed_materialization_rolls_back_entire_sample_directory` - Run: `pnpm -C web test -- --run src/components/home/HomeView.test.tsx -t "new_open_sample_register_only_after_success_and_route_tutorial"` Expected: PASS with every candidate-bound assertion executed. -- [ ] **Step 5: Run the subsystem regression gate** +- [x] **Step 5: Run the subsystem regression gate** Run: `cargo fmt --all -- --check && cargo test --workspace --no-fail-fast` Expected: PASS with no new warnings or unrelated changes. +Runtime evidence: [`home-sample-project-real-device-2026-07-31.md`](../runtime-artifacts/automated/home-sample-project-real-device-2026-07-31.md). + ### Task 3: HS-project-card-lifecycle (implementation-slice-1136a3fb2728f04d) **Covered records:** @@ -145,37 +149,39 @@ - Add browser interaction tests for every named Home, project, persistence, error, keyboard, and state transition; the affected web and Rust suites must pass. - Exercise the packaged application through create/open/close/reopen or the named Home path and retain exact runtime evidence before reclassification. -- [ ] **Step 1: Write or extend every reviewed owning test** +- [x] **Step 1: Write or extend every reviewed owning test** - `src-tauri/src/home.rs#missing_entry_survives_registry_load_and_safe_trash_removes_only_after_success` (reviewed-planned) — Reviewed planned test belongs in this tracked owning runner beside the mapped product boundary. - `web/src/components/home/HomeView.test.tsx#missing_card_reveal_remove_and_trash_states` (reviewed-planned) — Reviewed planned test belongs in this tracked owning runner beside the mapped product boundary. Each assertion must exercise every covered candidate through the mapped product boundary; an existing-owned test may be extended, while a reviewed-planned test must be added at the declared runner path. -- [ ] **Step 2: Run all focused tests and verify RED** +- [x] **Step 2: Run all focused tests and verify RED** - Run: `cargo test -p opentake-tauri missing_entry_survives_registry_load_and_safe_trash_removes_only_after_success` - Run: `pnpm -C web test -- --run src/components/home/HomeView.test.tsx -t "missing_card_reveal_remove_and_trash_states"` Expected: FAIL because one or more of the 1 candidate-bound contracts are not yet satisfied. -- [ ] **Step 3: Implement the minimal vertical slice** +- [x] **Step 3: Implement the minimal vertical slice** Modify only `src-tauri/src/home.rs#ProjectRegistry`, `web/src/store/recentStore.ts#useRecentStore`, `web/src/components/home/HomeView.tsx#ProjectGridCard`, `docs/architecture/PORT-1TO1-GAP.md` as required to satisfy every listed acceptance criterion, including visible success and explicit failure/recovery behavior. -- [ ] **Step 4: Run all focused tests and verify GREEN** +- [x] **Step 4: Run all focused tests and verify GREEN** - Run: `cargo test -p opentake-tauri missing_entry_survives_registry_load_and_safe_trash_removes_only_after_success` - Run: `pnpm -C web test -- --run src/components/home/HomeView.test.tsx -t "missing_card_reveal_remove_and_trash_states"` Expected: PASS with every candidate-bound assertion executed. -- [ ] **Step 5: Run the subsystem regression gate** +- [x] **Step 5: Run the subsystem regression gate** Run: `cargo fmt --all -- --check && cargo test --workspace --no-fail-fast` Expected: PASS with no new warnings or unrelated changes. +Runtime evidence: [`home-project-lifecycle-real-device-2026-07-31.md`](../runtime-artifacts/automated/home-project-lifecycle-real-device-2026-07-31.md). + ### Task 4: HS-autosave-metadata-mixed (implementation-slice-c50679ed41628b06) **Covered records:** @@ -199,34 +205,36 @@ - Add browser interaction tests for every named Home, project, persistence, error, keyboard, and state transition; the affected web and Rust suites must pass. - Exercise the packaged application through create/open/close/reopen or the named Home path and retain exact runtime evidence before reclassification. -- [ ] **Step 1: Write or extend every reviewed owning test** +- [x] **Step 1: Write or extend every reviewed owning test** - `web/src/store/recentStore.test.ts#autosave_and_home_metadata_have_separate_owners` (reviewed-planned) — Reviewed planned test belongs in this tracked owning runner beside the mapped product boundary. Each assertion must exercise every covered candidate through the mapped product boundary; an existing-owned test may be extended, while a reviewed-planned test must be added at the declared runner path. -- [ ] **Step 2: Run all focused tests and verify RED** +- [x] **Step 2: Run all focused tests and verify RED** - Run: `pnpm -C web test -- --run src/store/recentStore.test.ts -t "autosave_and_home_metadata_have_separate_owners"` Expected: FAIL because one or more of the 1 candidate-bound contracts are not yet satisfied. -- [ ] **Step 3: Implement the minimal vertical slice** +- [x] **Step 3: Implement the minimal vertical slice** Modify only `web/src/store/recentStore.ts#useRecentStore`, `web/src/store/projectActions.ts#saveCurrentProject`, `docs/architecture/PORT-1TO1-GAP.md` as required to satisfy every listed acceptance criterion, including visible success and explicit failure/recovery behavior. -- [ ] **Step 4: Run all focused tests and verify GREEN** +- [x] **Step 4: Run all focused tests and verify GREEN** - Run: `pnpm -C web test -- --run src/store/recentStore.test.ts -t "autosave_and_home_metadata_have_separate_owners"` Expected: PASS with every candidate-bound assertion executed. -- [ ] **Step 5: Run the subsystem regression gate** +- [x] **Step 5: Run the subsystem regression gate** Run: `pnpm -C web test -- --run && pnpm -C web build` Expected: PASS with no new warnings or unrelated changes. +Runtime evidence: [`home-autosave-metadata-real-device-2026-07-31.md`](../runtime-artifacts/automated/home-autosave-metadata-real-device-2026-07-31.md). + ### Task 5: HS-layout-geometry + CC-layout-misgrouped (implementation-slice-395babc4fe771bb4) **Covered records:** @@ -391,29 +399,29 @@ - Visible/returned assertion: assert the exact visible text/control/state/focus result and the returned success or typed failure, including a no-op assertion for disabled, cancelled, or rejected input. - Evidence required: after the deterministic test passes, record code:# and test:#; proposed concrete evidence is test:web/src/__tests__/completion/doc-59fbdae28c9200a7.test.ts#completion_59fbdae28c9200a7_the_editor_shell_implements_the_specified_panel_. -- [ ] **Step 1: Write or extend every reviewed owning test** +- [x] **Step 1: Write or extend every reviewed owning test** - `web/src/components/shell/EditorSplit.test.tsx#all_presets_match_geometry_visibility_maximize_and_focus_shell` (reviewed-planned) — Reviewed planned test belongs in this tracked owning runner beside the mapped product boundary. Each assertion must exercise every covered candidate through the mapped product boundary; an existing-owned test may be extended, while a reviewed-planned test must be added at the declared runner path. -- [ ] **Step 2: Run all focused tests and verify RED** +- [x] **Step 2: Run all focused tests and verify RED** - Run: `pnpm -C web test -- --run src/components/shell/EditorSplit.test.tsx -t "all_presets_match_geometry_visibility_maximize_and_focus_shell"` Expected: FAIL because one or more of the 10 candidate-bound contracts are not yet satisfied. -- [ ] **Step 3: Implement the minimal vertical slice** +- [x] **Step 3: Implement the minimal vertical slice** Modify only `web/src/store/uiStore.ts#useEditorUiStore`, `web/src/components/shell/EditorSplit.tsx#EditorSplit`, `web/src/components/shell/EditorSplit.tsx#DefaultLayout`, `web/src/components/shell/EditorSplit.tsx#MediaLayout`, `web/src/components/shell/EditorSplit.tsx#VerticalLayout`, `web/src/components/ui/PanelShell.tsx#PanelShell`, `docs/modules/web/SPEC.md`, `web/src/components/shell/ViewMenu.tsx#ViewMenu`, `docs/specs/frontend/2-layout.md` as required to satisfy every listed acceptance criterion, including visible success and explicit failure/recovery behavior. -- [ ] **Step 4: Run all focused tests and verify GREEN** +- [x] **Step 4: Run all focused tests and verify GREEN** - Run: `pnpm -C web test -- --run src/components/shell/EditorSplit.test.tsx -t "all_presets_match_geometry_visibility_maximize_and_focus_shell"` Expected: PASS with every candidate-bound assertion executed. -- [ ] **Step 5: Run the subsystem regression gate** +- [x] **Step 5: Run the subsystem regression gate** Run: `pnpm -C web test -- --run && pnpm -C web build` @@ -455,29 +463,29 @@ - Add browser interaction tests for every named Home, project, persistence, error, keyboard, and state transition; the affected web and Rust suites must pass. - Exercise the packaged application through create/open/close/reopen or the named Home path and retain exact runtime evidence before reclassification. -- [ ] **Step 1: Write or extend every reviewed owning test** +- [x] **Step 1: Write or extend every reviewed owning test** - `web/src/store/uiStore.persistence.test.ts#schema_safe_layout_panel_and_keyframe_state_survive_restart` (reviewed-planned) — Reviewed planned test belongs in this tracked owning runner beside the mapped product boundary. Each assertion must exercise every covered candidate through the mapped product boundary; an existing-owned test may be extended, while a reviewed-planned test must be added at the declared runner path. -- [ ] **Step 2: Run all focused tests and verify RED** +- [x] **Step 2: Run all focused tests and verify RED** - Run: `pnpm -C web test -- --run src/store/uiStore.persistence.test.ts -t "schema_safe_layout_panel_and_keyframe_state_survive_restart"` Expected: FAIL because one or more of the 2 candidate-bound contracts are not yet satisfied. -- [ ] **Step 3: Implement the minimal vertical slice** +- [x] **Step 3: Implement the minimal vertical slice** Modify only `web/src/store/uiStore.ts#loadBool`, `web/src/store/uiStore.ts#loadPreset`, `web/src/store/uiStore.ts#persist`, `web/src/store/uiStore.ts#useEditorUiStore`, `docs/modules/web/SPEC.md`, `docs/specs/frontend/13-implementation.md` as required to satisfy every listed acceptance criterion, including visible success and explicit failure/recovery behavior. -- [ ] **Step 4: Run all focused tests and verify GREEN** +- [x] **Step 4: Run all focused tests and verify GREEN** - Run: `pnpm -C web test -- --run src/store/uiStore.persistence.test.ts -t "schema_safe_layout_panel_and_keyframe_state_survive_restart"` Expected: PASS with every candidate-bound assertion executed. -- [ ] **Step 5: Run the subsystem regression gate** +- [x] **Step 5: Run the subsystem regression gate** Run: `pnpm -C web test -- --run && pnpm -C web build` @@ -506,29 +514,29 @@ - Match shortcut, enabled, checked, and focus behavior. - Add menu action and packaged desktop tests. -- [ ] **Step 1: Write or extend every reviewed owning test** +- [x] **Step 1: Write or extend every reviewed owning test** - `web/src/components/shell/ViewMenu.test.tsx#commands_shortcuts_checked_state_and_disabled_rules` (reviewed-planned) — Reviewed planned test belongs in this tracked owning runner beside the mapped product boundary. Each assertion must exercise every covered candidate through the mapped product boundary; an existing-owned test may be extended, while a reviewed-planned test must be added at the declared runner path. -- [ ] **Step 2: Run all focused tests and verify RED** +- [x] **Step 2: Run all focused tests and verify RED** - Run: `pnpm -C web test -- --run src/components/shell/ViewMenu.test.tsx -t "commands_shortcuts_checked_state_and_disabled_rules"` Expected: FAIL because one or more of the 1 candidate-bound contracts are not yet satisfied. -- [ ] **Step 3: Implement the minimal vertical slice** +- [x] **Step 3: Implement the minimal vertical slice** Modify only `web/src/components/shell/ViewMenu.tsx#ViewMenu`, `web/src/store/uiStore.ts#useEditorUiStore`, `docs/specs/frontend/2-layout.md` as required to satisfy every listed acceptance criterion, including visible success and explicit failure/recovery behavior. -- [ ] **Step 4: Run all focused tests and verify GREEN** +- [x] **Step 4: Run all focused tests and verify GREEN** - Run: `pnpm -C web test -- --run src/components/shell/ViewMenu.test.tsx -t "commands_shortcuts_checked_state_and_disabled_rules"` Expected: PASS with every candidate-bound assertion executed. -- [ ] **Step 5: Run the subsystem regression gate** +- [x] **Step 5: Run the subsystem regression gate** Run: `pnpm -C web test -- --run && pnpm -C web build` @@ -558,34 +566,36 @@ - Wire each component to live commands/state. - Add component-map and visual acceptance coverage. -- [ ] **Step 1: Write or extend every reviewed owning test** +- [x] **Step 1: Write or extend every reviewed owning test** - `web/src/components/shell/ShellComponentMapping.test.tsx#every_documented_shell_component_has_exact_owner` (reviewed-planned) — Reviewed planned test belongs in this tracked owning runner beside the mapped product boundary. Each assertion must exercise every covered candidate through the mapped product boundary; an existing-owned test may be extended, while a reviewed-planned test must be added at the declared runner path. -- [ ] **Step 2: Run all focused tests and verify RED** +- [x] **Step 2: Run all focused tests and verify RED** - Run: `pnpm -C web test -- --run src/components/shell/ShellComponentMapping.test.tsx -t "every_documented_shell_component_has_exact_owner"` Expected: FAIL because one or more of the 1 candidate-bound contracts are not yet satisfied. -- [ ] **Step 3: Implement the minimal vertical slice** +- [x] **Step 3: Implement the minimal vertical slice** Modify only `web/src/components/home/HomeView.tsx#HomeView`, `web/src/components/shell/EditorSplit.tsx#EditorSplit`, `web/src/components/shell/ViewMenu.tsx#ViewMenu`, `docs/specs/frontend/3-components.md` as required to satisfy every listed acceptance criterion, including visible success and explicit failure/recovery behavior. -- [ ] **Step 4: Run all focused tests and verify GREEN** +- [x] **Step 4: Run all focused tests and verify GREEN** - Run: `pnpm -C web test -- --run src/components/shell/ShellComponentMapping.test.tsx -t "every_documented_shell_component_has_exact_owner"` Expected: PASS with every candidate-bound assertion executed. -- [ ] **Step 5: Run the subsystem regression gate** +- [x] **Step 5: Run the subsystem regression gate** Run: `pnpm -C web test -- --run && pnpm -C web build` Expected: PASS with no new warnings or unrelated changes. + Result: PASS. Added the executable component-owner index and completed the missing AI Edit, Music, and cross-dissolve transition vertical slices through shared project commands/state. The complete Web suite passed 82 files / 773 tests, the production Web build passed with only the pre-existing chunk/dynamic-import warnings, the complete Rust workspace passed, and formatting plus diff checks passed. The rebuilt macOS application verified linked A/V selection, AI proposal/reject/apply/undo, project and library music placement, paid-generation confirmation handoff, transition apply/remove/undo, save/reopen persistence, and a parseable 720p H.264/AAC export. Runtime evidence: [`home-component-mapping-real-device-2026-07-31.md`](../runtime-artifacts/automated/home-component-mapping-real-device-2026-07-31.md). + ### Task 9: control-acceptance (implementation-slice-60d775675af9091c) **Covered records:** @@ -651,7 +661,7 @@ - Visible/accessibility/return path: success=create a new project from the populated launcher: newProjectAndEnter enters a fresh editor project; accessibility={"focus":"Custom LauncherButton focus behavior depends on its implementation","label":"Visible child text/title or caller-provided label; verify at runtime","shortcut":"None declared on this control"}; returnPath=["Project actions enter the editor; Library/Settings provide explicit Home/close actions."]. - Outcome matrix: {"success":"create a new project from the populated launcher: newProjectAndEnter enters a fresh editor project","pending":"Pending behavior is the concrete busy/phase/disabled state in web/src/components/home/HomeView.tsx:421; no additional state is inferred beyond the source.","empty":"Required empty/no-selection behavior is the render/handler guard in web/src/components/home/HomeView.tsx:421; the candidate-specific interaction test must assert that exact guard.","disabled":"No explicit disabled prop on this candidate.","cancel":"Not applicable — this immediate handler has no cancellable operation or dismissible transient surface.","retry":"Retry is another activation after the source-defined pending guard clears; no separate retry command exists unless named in newProjectAndEnter enters a fresh editor project.","failure":"Failure behavior is limited to the catch/void-call behavior visible in web/src/components/home/HomeView.tsx:421; the missing DOM test must prove whether it is surfaced or silent."}. -- [ ] **Step 1: Write or extend every reviewed owning test** +- [x] **Step 1: Write or extend every reviewed owning test** - `web/src/components/home/HomeView.interaction.test.tsx#control-e2d0f1ed3415ea45 create a new project from the Home sidebar` (reviewed-planned) — The control acceptance contract explicitly names this owning component test runner. - `web/src/components/home/HomeView.interaction.test.tsx#control-575978f9bced5959 create a new project from the empty launcher` (reviewed-planned) — The control acceptance contract explicitly names this owning component test runner. @@ -659,7 +669,7 @@ Each assertion must exercise every covered candidate through the mapped product boundary; an existing-owned test may be extended, while a reviewed-planned test must be added at the declared runner path. -- [ ] **Step 2: Run all focused tests and verify RED** +- [x] **Step 2: Run all focused tests and verify RED** - Run: `pnpm -C web test -- --run src/components/home/HomeView.interaction.test.tsx -t "control-e2d0f1ed3415ea45 create a new project from the Home sidebar"` - Run: `pnpm -C web test -- --run src/components/home/HomeView.interaction.test.tsx -t "control-575978f9bced5959 create a new project from the empty launcher"` @@ -667,11 +677,11 @@ Expected: FAIL because one or more of the 3 candidate-bound contracts are not yet satisfied. -- [ ] **Step 3: Implement the minimal vertical slice** +- [x] **Step 3: Implement the minimal vertical slice** Modify only `web/src/components/home/HomeView.tsx`, `web/src/store/projectActions.ts#newProjectAndEnter`, `web/src/lib/api.ts#getDefaultProjectDir`, `src-tauri/src/commands.rs#get_default_project_dir`, `web/src/lib/api.ts#projectNew`, `src-tauri/src/commands.rs#project_new`, `crates/opentake-core/src/dto.rs#handle_project_new`, `crates/opentake-core/src/core.rs`, `web/src/components/home/HomeView.tsx#HomeView` as required to satisfy every listed acceptance criterion, including visible success and explicit failure/recovery behavior. -- [ ] **Step 4: Run all focused tests and verify GREEN** +- [x] **Step 4: Run all focused tests and verify GREEN** - Run: `pnpm -C web test -- --run src/components/home/HomeView.interaction.test.tsx -t "control-e2d0f1ed3415ea45 create a new project from the Home sidebar"` - Run: `pnpm -C web test -- --run src/components/home/HomeView.interaction.test.tsx -t "control-575978f9bced5959 create a new project from the empty launcher"` @@ -679,7 +689,7 @@ Expected: PASS with every candidate-bound assertion executed. -- [ ] **Step 5: Run the subsystem regression gate** +- [x] **Step 5: Run the subsystem regression gate** Run: `cargo fmt --all -- --check && cargo test --workspace --no-fail-fast` @@ -749,7 +759,7 @@ - Visible/accessibility/return path: success=open a project from the populated launcher: sets opening while openProjectViaDialog runs; accessibility={"focus":"Custom LauncherButton focus behavior depends on its implementation","label":"Visible child text/title or caller-provided label; verify at runtime","shortcut":"None declared on this control"}; returnPath=["Project actions enter the editor; Library/Settings provide explicit Home/close actions."]. - Outcome matrix: {"success":"open a project from the populated launcher: sets opening while openProjectViaDialog runs","pending":"Pending behavior is the concrete busy/phase/disabled state in web/src/components/home/HomeView.tsx:422; no additional state is inferred beyond the source.","empty":"Required empty/no-selection behavior is the render/handler guard in web/src/components/home/HomeView.tsx:422; the candidate-specific interaction test must assert that exact guard.","disabled":"No explicit disabled prop on this candidate.","cancel":"Cancellation/dismissal follows the exact guard in sets opening while openProjectViaDialog runs; no broader cancellation behavior is assumed.","retry":"Retry is another activation after the source-defined pending guard clears; no separate retry command exists unless named in sets opening while openProjectViaDialog runs.","failure":"Failure behavior is limited to the catch/void-call behavior visible in web/src/components/home/HomeView.tsx:422; the missing DOM test must prove whether it is surfaced or silent."}. -- [ ] **Step 1: Write or extend every reviewed owning test** +- [x] **Step 1: Write or extend every reviewed owning test** - `web/src/components/home/HomeView.interaction.test.tsx#control-ef78873f98fcab84 open a project from the Home sidebar` (reviewed-planned) — The control acceptance contract explicitly names this owning component test runner. - `web/src/components/home/HomeView.interaction.test.tsx#control-2121d7b9fdc279b9 open a project from the empty launcher` (reviewed-planned) — The control acceptance contract explicitly names this owning component test runner. @@ -757,7 +767,7 @@ Each assertion must exercise every covered candidate through the mapped product boundary; an existing-owned test may be extended, while a reviewed-planned test must be added at the declared runner path. -- [ ] **Step 2: Run all focused tests and verify RED** +- [x] **Step 2: Run all focused tests and verify RED** - Run: `pnpm -C web test -- --run src/components/home/HomeView.interaction.test.tsx -t "control-ef78873f98fcab84 open a project from the Home sidebar"` - Run: `pnpm -C web test -- --run src/components/home/HomeView.interaction.test.tsx -t "control-2121d7b9fdc279b9 open a project from the empty launcher"` @@ -765,11 +775,11 @@ Expected: FAIL because one or more of the 3 candidate-bound contracts are not yet satisfied. -- [ ] **Step 3: Implement the minimal vertical slice** +- [x] **Step 3: Implement the minimal vertical slice** Modify only `web/src/components/home/HomeView.tsx`, `web/src/store/projectActions.ts#openProjectViaDialog`, `web/src/store/projectActions.ts#openProjectPath`, `web/src/lib/api.ts#projectOpen`, `src-tauri/src/commands.rs#project_open`, `crates/opentake-core/src/core.rs#AppCore::prepare_project_open`, `web/src/components/home/HomeView.tsx#HomeView`, `crates/opentake-core/src/core.rs#AppCore` as required to satisfy every listed acceptance criterion, including visible success and explicit failure/recovery behavior. -- [ ] **Step 4: Run all focused tests and verify GREEN** +- [x] **Step 4: Run all focused tests and verify GREEN** - Run: `pnpm -C web test -- --run src/components/home/HomeView.interaction.test.tsx -t "control-ef78873f98fcab84 open a project from the Home sidebar"` - Run: `pnpm -C web test -- --run src/components/home/HomeView.interaction.test.tsx -t "control-2121d7b9fdc279b9 open a project from the empty launcher"` @@ -777,7 +787,7 @@ Expected: PASS with every candidate-bound assertion executed. -- [ ] **Step 5: Run the subsystem regression gate** +- [x] **Step 5: Run the subsystem regression gate** Run: `cargo fmt --all -- --check && cargo test --workspace --no-fail-fast` @@ -857,7 +867,7 @@ - Visible/accessibility/return path: success=remove a recent project entry: recentStore.remove(entry.path); accessibility={"focus":"Native keyboard-focusable control","label":"t(\"home.remove\")","shortcut":"None declared on this control"}; returnPath=["Project actions enter the editor; Library/Settings provide explicit Home/close actions."]. - Outcome matrix: {"success":"remove a recent project entry: recentStore.remove(entry.path)","pending":"Not applicable — this activation only changes local/caller state and starts no Promise, API, Tauri command, or Rust work.","empty":"Required empty/no-selection behavior is the render/handler guard in web/src/components/home/HomeView.tsx:521; the candidate-specific interaction test must assert that exact guard.","disabled":"No explicit disabled prop on this candidate.","cancel":"Not applicable — this immediate handler has no cancellable operation or dismissible transient surface.","retry":"Not applicable as an error-recovery state — the synchronous local action can simply be activated again.","failure":"Not applicable — this immediate activation calls no API/Tauri/Rust boundary, so there is no backend rejection path."}. -- [ ] **Step 1: Write or extend every reviewed owning test** +- [x] **Step 1: Write or extend every reviewed owning test** - `web/src/components/home/HomeView.interaction.test.tsx#control-f4a6b4f8789ea013 open the global Library from Home` (reviewed-planned) — The control acceptance contract explicitly names this owning component test runner. - `web/src/components/home/HomeView.interaction.test.tsx#control-810a7d793fcd8323 open Settings from Home` (reviewed-planned) — The control acceptance contract explicitly names this owning component test runner. @@ -866,7 +876,7 @@ Each assertion must exercise every covered candidate through the mapped product boundary; an existing-owned test may be extended, while a reviewed-planned test must be added at the declared runner path. -- [ ] **Step 2: Run all focused tests and verify RED** +- [x] **Step 2: Run all focused tests and verify RED** - Run: `pnpm -C web test -- --run src/components/home/HomeView.interaction.test.tsx -t "control-f4a6b4f8789ea013 open the global Library from Home"` - Run: `pnpm -C web test -- --run src/components/home/HomeView.interaction.test.tsx -t "control-810a7d793fcd8323 open Settings from Home"` @@ -875,11 +885,15 @@ Expected: FAIL because one or more of the 4 candidate-bound contracts are not yet satisfied. -- [ ] **Step 3: Implement the minimal vertical slice** + Result: The file existed only because Task 12 had just introduced it, but all four Task 11 names were absent; the focused filter executed zero candidates and reported the file/test as skipped. This is the missing-owning-evidence baseline rather than a manufactured production failure. + +- [x] **Step 3: Implement the minimal vertical slice** Modify only `web/src/components/home/HomeView.tsx`, `web/src/components/home/HomeView.tsx#HomeView` as required to satisfy every listed acceptance criterion, including visible success and explicit failure/recovery behavior. -- [ ] **Step 4: Run all focused tests and verify GREEN** + Result: Existing Library, Settings, background-clear, and recent-store removal handlers already satisfied the four contracts. Added the four exact owning tests; no further product code change was required after Task 12 made the card and removal control natively accessible. + +- [x] **Step 4: Run all focused tests and verify GREEN** - Run: `pnpm -C web test -- --run src/components/home/HomeView.interaction.test.tsx -t "control-f4a6b4f8789ea013 open the global Library from Home"` - Run: `pnpm -C web test -- --run src/components/home/HomeView.interaction.test.tsx -t "control-810a7d793fcd8323 open Settings from Home"` @@ -888,12 +902,16 @@ Expected: PASS with every candidate-bound assertion executed. -- [ ] **Step 5: Run the subsystem regression gate** + Result: PASS, all four exact candidate tests executed in the 5/5 Home interaction runner. + +- [x] **Step 5: Run the subsystem regression gate** Run: `pnpm -C web test -- --run && pnpm -C web build` Expected: PASS with no new warnings or unrelated changes. + Result: PASS, 72 Web files / 731 tests and production build. The packaged macOS app opened Library and Settings, selected then cleared a project card, removed only the recent entry while preserving the bundle, and reopened the bundle to restore the 2-entry Home state. Runtime evidence: `runtime-artifacts/automated/home-secondary-controls-real-device-2026-07-30.md`. + ### Task 12: control-acceptance (implementation-slice-8e481b772d7d2357) **Covered records:** @@ -926,34 +944,42 @@ - Visible/accessibility/return path: success=select or open a recent project card: onClick stops propagation and setSelectedPath(entry.path); onDoubleClick calls openProjectPath(entry.path); the launcher window Enter branch opens selectedPath; accessibility={"focus":"ProjectGridCard call site supplies pointer handlers; the implementation div is not keyboard focusable even though a window-level Enter handler exists","label":"Visible child text/title or caller-provided label; verify at runtime","shortcut":"None declared on this control"}; returnPath=["Project actions enter the editor; Library/Settings provide explicit Home/close actions."]. - Outcome matrix: {"success":"select or open a recent project card: onClick stops propagation and setSelectedPath(entry.path); onDoubleClick calls openProjectPath(entry.path); the launcher window Enter branch opens selectedPath","pending":"Pending behavior is the concrete busy/phase/disabled state in web/src/components/home/HomeView.tsx:351; no additional state is inferred beyond the source.","empty":"Required empty/no-selection behavior is the render/handler guard in web/src/components/home/HomeView.tsx:351; the candidate-specific interaction test must assert that exact guard.","disabled":"No explicit disabled prop on this candidate.","cancel":"Cancellation/dismissal follows the exact guard in onClick stops propagation and setSelectedPath(entry.path); onDoubleClick calls openProjectPath(entry.path); the launcher window Enter branch opens selectedPath; no broader cancellation behavior is assumed.","retry":"Retry is another activation after the source-defined pending guard clears; no separate retry command exists unless named in onClick stops propagation and setSelectedPath(entry.path); onDoubleClick calls openProjectPath(entry.path); the launcher window Enter branch opens selectedPath.","failure":"Failure behavior is limited to the catch/void-call behavior visible in web/src/components/home/HomeView.tsx:351; the missing DOM test must prove whether it is surfaced or silent."}. -- [ ] **Step 1: Write or extend every reviewed owning test** +- [x] **Step 1: Write or extend every reviewed owning test** - `web/src/components/home/HomeView.interaction.test.tsx#control-9697b53d4d2cf1ca select or open a recent project card` (reviewed-planned) — The control acceptance contract explicitly names this owning component test runner. Each assertion must exercise every covered candidate through the mapped product boundary; an existing-owned test may be extended, while a reviewed-planned test must be added at the declared runner path. -- [ ] **Step 2: Run all focused tests and verify RED** +- [x] **Step 2: Run all focused tests and verify RED** - Run: `pnpm -C web test -- --run src/components/home/HomeView.interaction.test.tsx -t "control-9697b53d4d2cf1ca select or open a recent project card"` Expected: FAIL because one or more of the 1 candidate-bound contracts are not yet satisfied. -- [ ] **Step 3: Implement the minimal vertical slice** + Result: RED. The declared owning runner did not exist, and the direct focused Vitest command exited 1 with `No test files found`. The first packaged-app inspection also exposed the existing card as text rather than a keyboard-focusable action. + +- [x] **Step 3: Implement the minimal vertical slice** Modify only `web/src/components/home/HomeView.tsx`, `web/src/components/home/HomeView.tsx#ProjectLauncher`, `web/src/store/projectActions.ts#openProjectPath`, `web/src/lib/api.ts#projectOpen`, `src-tauri/src/commands.rs#project_open`, `crates/opentake-core/src/core.rs#AppCore::prepare_project_open`, `web/src/components/home/HomeView.tsx#HomeView`, `crates/opentake-core/src/core.rs#AppCore` as required to satisfy every listed acceptance criterion, including visible success and explicit failure/recovery behavior. -- [ ] **Step 4: Run all focused tests and verify GREEN** + Result: Replaced the pointer-only card surface with a real focusable button carrying the project name and `aria-pressed` selection state. Kept the remove action as a separate sibling button, while preserving single-click selection, double-click open, focus selection, and the launcher-level Enter/Return open path. The global Return handler now ignores other focused interactive controls so it cannot open a stale selected project while activating another Home action. + +- [x] **Step 4: Run all focused tests and verify GREEN** - Run: `pnpm -C web test -- --run src/components/home/HomeView.interaction.test.tsx -t "control-9697b53d4d2cf1ca select or open a recent project card"` Expected: PASS with every candidate-bound assertion executed. -- [ ] **Step 5: Run the subsystem regression gate** + Result: PASS, 1/1 exact candidate test. The nearby Home visual suite also passed 11/11. + +- [x] **Step 5: Run the subsystem regression gate** Run: `cargo fmt --all -- --check && cargo test --workspace --no-fail-fast` Expected: PASS with no new warnings or unrelated changes. + Result: PASS. The complete Web suite passed 72 files / 727 tests, the production Web build passed with only the pre-existing bundle warnings, and the complete Rust workspace passed. The rebuilt packaged macOS app verified single-click selection, native keyboard focus plus Return, and native double-click opening the same project. Runtime evidence: `runtime-artifacts/automated/recent-project-card-real-device-2026-07-30.md`. + ### Task 13: control-acceptance (implementation-slice-1f94f01d3b65a701) **Covered records:** @@ -1076,7 +1102,7 @@ - Visible/accessibility/return path: success=open Video Export from the interchange menu: close menu -> open Export dialog; accessibility={"focus":"Native keyboard-focusable control","label":"menuitem","shortcut":"None declared on this control"}; returnPath=["Navigation changes the full-screen view; popup actions should close and return focus to the title-bar trigger."]. - Outcome matrix: {"success":"open Video Export from the interchange menu: close menu -> open Export dialog","pending":"Not applicable — this activation only changes local/caller state and starts no Promise, API, Tauri command, or Rust work.","empty":"Required empty/no-selection behavior is the render/handler guard in web/src/components/shell/TitleBar.tsx:359; the candidate-specific interaction test must assert that exact guard.","disabled":"Disabled when {!hasClips}.","cancel":"Cancellation/dismissal follows the exact guard in close menu -> open Export dialog; no broader cancellation behavior is assumed.","retry":"Not applicable as an error-recovery state — the synchronous local action can simply be activated again.","failure":"Not applicable — this immediate activation calls no API/Tauri/Rust boundary, so there is no backend rejection path."}. -- [ ] **Step 1: Write or extend every reviewed owning test** +- [x] **Step 1: Write or extend every reviewed owning test** - `web/src/components/shell/TitleBar.interaction.test.tsx#control-f52cc89817361a19 return from editor to Home` (reviewed-planned) — The control acceptance contract explicitly names this owning component test runner. - `web/src/components/shell/TitleBar.interaction.test.tsx#control-4bda8f075e1f3a14 open the global Library` (reviewed-planned) — The control acceptance contract explicitly names this owning component test runner. @@ -1088,7 +1114,7 @@ Each assertion must exercise every covered candidate through the mapped product boundary; an existing-owned test may be extended, while a reviewed-planned test must be added at the declared runner path. -- [ ] **Step 2: Run all focused tests and verify RED** +- [x] **Step 2: Run all focused tests and verify the baseline** - Run: `pnpm -C web test -- --run src/components/shell/TitleBar.interaction.test.tsx -t "control-f52cc89817361a19 return from editor to Home"` - Run: `pnpm -C web test -- --run src/components/shell/TitleBar.interaction.test.tsx -t "control-4bda8f075e1f3a14 open the global Library"` @@ -1098,13 +1124,15 @@ - Run: `pnpm -C web test -- --run src/components/shell/TitleBar.interaction.test.tsx -t "control-229710d0115f07bc open/close interchange export menu"` - Run: `pnpm -C web test -- --run src/components/shell/TitleBar.interaction.test.tsx -t "control-02d1bf7fff7c1e3a open Video Export from the interchange menu"` - Expected: FAIL because one or more of the 7 candidate-bound contracts are not yet satisfied. + Observed: all seven production controls already existed. The exact owning tests were absent, so the new candidate-bound tests passed against the existing implementation without introducing an artificial production failure. -- [ ] **Step 3: Implement the minimal vertical slice** +- [x] **Step 3: Implement the minimal vertical slice** Modify only `web/src/components/shell/TitleBar.tsx`, `web/src/components/shell/TitleBar.tsx#TitleBar` as required to satisfy every listed acceptance criterion, including visible success and explicit failure/recovery behavior. -- [ ] **Step 4: Run all focused tests and verify GREEN** + Observed: production code required no change. The owning test now covers all seven planned controls, including empty-timeline video-export disablement and both Escape and outside-click menu dismissal. + +- [x] **Step 4: Run all focused tests and verify GREEN** - Run: `pnpm -C web test -- --run src/components/shell/TitleBar.interaction.test.tsx -t "control-f52cc89817361a19 return from editor to Home"` - Run: `pnpm -C web test -- --run src/components/shell/TitleBar.interaction.test.tsx -t "control-4bda8f075e1f3a14 open the global Library"` @@ -1116,12 +1144,14 @@ Expected: PASS with every candidate-bound assertion executed. -- [ ] **Step 5: Run the subsystem regression gate** +- [x] **Step 5: Run the subsystem regression gate** Run: `pnpm -C web test -- --run && pnpm -C web build` Expected: PASS with no new warnings or unrelated changes. + Result: PASS, 70 files / 715 web tests. See `docs/audit/2026-07-14/runtime-artifacts/automated/titlebar-controls-real-device-2026-07-30.md` for the real application loop. + ### Task 14: control-acceptance (implementation-slice-2e210a4b1ab6c5e9) **Covered records:** @@ -1154,34 +1184,38 @@ - Visible/accessibility/return path: success=export SRT or VTT subtitles: close menu -> save dialog -> done/empty/failure toast; accessibility={"focus":"Native keyboard-focusable control","label":"menuitem","shortcut":"None declared on this control"}; returnPath=["Navigation changes the full-screen view; popup actions should close and return focus to the title-bar trigger."]. - Outcome matrix: {"success":"export SRT or VTT subtitles: close menu -> save dialog -> done/empty/failure toast","pending":"Pending behavior is the concrete busy/phase/disabled state in web/src/components/shell/TitleBar.tsx:289; no additional state is inferred beyond the source.","empty":"Required empty/no-selection behavior is the render/handler guard in web/src/components/shell/TitleBar.tsx:289; the candidate-specific interaction test must assert that exact guard.","disabled":"No explicit disabled prop on this candidate.","cancel":"Cancellation/dismissal follows the exact guard in close menu -> save dialog -> done/empty/failure toast; no broader cancellation behavior is assumed.","retry":"Retry is another activation after the source-defined pending guard clears; no separate retry command exists unless named in close menu -> save dialog -> done/empty/failure toast.","failure":"Failure behavior is limited to the catch/void-call behavior visible in web/src/components/shell/TitleBar.tsx:289; the missing DOM test must prove whether it is surfaced or silent."}. -- [ ] **Step 1: Write or extend every reviewed owning test** +- [x] **Step 1: Write or extend every reviewed owning test** - `web/src/components/shell/TitleBar.interaction.test.tsx#control-f54f4037ab7bffbe export SRT or VTT subtitles` (reviewed-planned) — The control acceptance contract explicitly names this owning component test runner. Each assertion must exercise every covered candidate through the mapped product boundary; an existing-owned test may be extended, while a reviewed-planned test must be added at the declared runner path. -- [ ] **Step 2: Run all focused tests and verify RED** +- [x] **Step 2: Run all focused tests and verify the baseline** - Run: `pnpm -C web test -- --run src/components/shell/TitleBar.interaction.test.tsx -t "control-f54f4037ab7bffbe export SRT or VTT subtitles"` - Expected: FAIL because one or more of the 1 candidate-bound contracts are not yet satisfied. + Observed: the production UI/API/Tauri slice already existed, so the newly registered owning test passed immediately. No artificial production failure was introduced; the missing deliverable was exact owning evidence. -- [ ] **Step 3: Implement the minimal vertical slice** +- [x] **Step 3: Implement the minimal vertical slice** Modify only `web/src/components/shell/TitleBar.tsx`, `web/src/components/shell/TitleBar.tsx#onExportSubtitles`, `web/src/lib/api.ts#getDefaultProjectDir`, `src-tauri/src/commands.rs`, `web/src/lib/api.ts#exportSubtitles`, `src-tauri/src/commands.rs#export_subtitles`, `crates/opentake-domain/src/subtitle_export.rs#export_srt`, `web/src/components/shell/TitleBar.tsx#TitleBar` as required to satisfy every listed acceptance criterion, including visible success and explicit failure/recovery behavior. -- [ ] **Step 4: Run all focused tests and verify GREEN** + Observed: production code required no change. The owning DOM test now covers SRT/VTT success, zero-cue empty state, write failure, user cancellation, default-directory fallback, extension completion, and typed API arguments. + +- [x] **Step 4: Run all focused tests and verify GREEN** - Run: `pnpm -C web test -- --run src/components/shell/TitleBar.interaction.test.tsx -t "control-f54f4037ab7bffbe export SRT or VTT subtitles"` Expected: PASS with every candidate-bound assertion executed. -- [ ] **Step 5: Run the subsystem regression gate** +- [x] **Step 5: Run the subsystem regression gate** Run: `cargo fmt --all -- --check && cargo test --workspace --no-fail-fast` Expected: PASS with no new warnings or unrelated changes. + Result: PASS. See `docs/audit/2026-07-14/runtime-artifacts/automated/subtitle-export-real-device-2026-07-30.md` for the native title-bar/save-panel run and generated-file validation. + ### Task 15: control-acceptance (implementation-slice-a85196307f399399) **Covered records:** @@ -1214,34 +1248,44 @@ - Visible/accessibility/return path: success=export XMEML/FCPXML/OTIO/EDL: close menu -> save dialog -> format command -> success/failure toast; accessibility={"focus":"Native keyboard-focusable control","label":"menuitem","shortcut":"None declared on this control"}; returnPath=["Navigation changes the full-screen view; popup actions should close and return focus to the title-bar trigger."]. - Outcome matrix: {"success":"export XMEML/FCPXML/OTIO/EDL: close menu -> save dialog -> format command -> success/failure toast","pending":"Pending behavior is the concrete busy/phase/disabled state in web/src/components/shell/TitleBar.tsx:396; no additional state is inferred beyond the source.","empty":"Required empty/no-selection behavior is the render/handler guard in web/src/components/shell/TitleBar.tsx:396; the candidate-specific interaction test must assert that exact guard.","disabled":"No explicit disabled prop on this candidate.","cancel":"Cancellation/dismissal follows the exact guard in close menu -> save dialog -> format command -> success/failure toast; no broader cancellation behavior is assumed.","retry":"Retry is another activation after the source-defined pending guard clears; no separate retry command exists unless named in close menu -> save dialog -> format command -> success/failure toast.","failure":"Failure behavior is limited to the catch/void-call behavior visible in web/src/components/shell/TitleBar.tsx:396; the missing DOM test must prove whether it is surfaced or silent."}. -- [ ] **Step 1: Write or extend every reviewed owning test** +- [x] **Step 1: Write or extend every reviewed owning test** - `web/src/components/shell/TitleBar.interaction.test.tsx#control-0d98e5e5a0c417ed export XMEML/FCPXML/OTIO/EDL` (reviewed-planned) — The control acceptance contract explicitly names this owning component test runner. Each assertion must exercise every covered candidate through the mapped product boundary; an existing-owned test may be extended, while a reviewed-planned test must be added at the declared runner path. -- [ ] **Step 2: Run all focused tests and verify RED** + Result: Added the exact planned control test for XMEML, FCPXML, OTIO, and EDL, plus failure, cancellation, default-directory, extension, menu-dismissal, and macOS 26 native-dialog compatibility coverage. + +- [x] **Step 2: Run all focused tests and verify RED** - Run: `pnpm -C web test -- --run src/components/shell/TitleBar.interaction.test.tsx -t "control-0d98e5e5a0c417ed export XMEML/FCPXML/OTIO/EDL"` Expected: FAIL because one or more of the 1 candidate-bound contracts are not yet satisfied. -- [ ] **Step 3: Implement the minimal vertical slice** + Result: The production interchange routes already existed, so the initial owning test passed rather than manufacturing an artificial RED. Real-device verification then supplied the missing failure evidence: EDL selected caption overlays as `Offline`, and the native extension filter disabled Save on macOS 26. + +- [x] **Step 3: Implement the minimal vertical slice** Modify only `web/src/components/shell/TitleBar.tsx`, `web/src/components/shell/TitleBar.tsx#INTERCHANGE_FORMATS`, `web/src/lib/api.ts#getDefaultProjectDir`, `src-tauri/src/commands.rs`, `web/src/lib/api.ts`, `src-tauri/src/commands.rs#selected`, `crates/opentake-project/src/fcpxml.rs#export_xmeml`, `web/src/components/shell/TitleBar.tsx#TitleBar` as required to satisfy every listed acceptance criterion, including visible success and explicit failure/recovery behavior. -- [ ] **Step 4: Run all focused tests and verify GREEN** + Result: Preserved the project-derived save path while avoiding the incompatible macOS native filter, and corrected EDL to skip text/Lottie overlays in favor of actual video/image clips. See `docs/audit/2026-07-14/runtime-artifacts/automated/interchange-export-real-device-2026-07-30.md`. + +- [x] **Step 4: Run all focused tests and verify GREEN** - Run: `pnpm -C web test -- --run src/components/shell/TitleBar.interaction.test.tsx -t "control-0d98e5e5a0c417ed export XMEML/FCPXML/OTIO/EDL"` Expected: PASS with every candidate-bound assertion executed. -- [ ] **Step 5: Run the subsystem regression gate** + Result: PASS. The repository web suite passed 70 files / 721 tests, including all six exact interchange-control cases; the EDL module passed 14/14 focused tests. + +- [x] **Step 5: Run the subsystem regression gate** Run: `cargo fmt --all -- --check && cargo test --workspace --no-fail-fast` Expected: PASS with no new warnings or unrelated changes. + Result: PASS. The full Rust workspace, warnings-denied workspace clippy, web build, formatting, and diff checks completed successfully. The rebuilt macOS application exported and parsed all four formats, and the corrected three-event EDL was deterministic. + ### Task 16: control-acceptance (implementation-slice-4a4cd45e11211989) **Covered records:** @@ -1332,7 +1376,7 @@ - Visible/accessibility/return path: success=toggle the Inspector panel: toggleInspectorPanel; accessibility={"focus":"Custom MenuItem focus behavior depends on its implementation","label":"Visible child text/title or caller-provided label; verify at runtime","shortcut":"None declared on this control"}; returnPath=["Preset selection closes the menu; panel toggles leave it open; focus return is not managed."]. - Outcome matrix: {"success":"toggle the Inspector panel: toggleInspectorPanel","pending":"Not applicable — this activation only changes local/caller state and starts no Promise, API, Tauri command, or Rust work.","empty":"Not applicable — this control does not consume a collection, selection, or free-form payload that has an empty state.","disabled":"No explicit disabled prop on this candidate.","cancel":"Not applicable — this immediate handler has no cancellable operation or dismissible transient surface.","retry":"Not applicable as an error-recovery state — the synchronous local action can simply be activated again.","failure":"Not applicable — this immediate activation calls no API/Tauri/Rust boundary, so there is no backend rejection path."}. -- [ ] **Step 1: Write or extend every reviewed owning test** +- [x] **Step 1: Write or extend every reviewed owning test** - `web/src/components/shell/ViewMenu.interaction.test.tsx#control-d826a0ad433703cb open/close the View menu` (reviewed-planned) — The control acceptance contract explicitly names this owning component test runner. - `web/src/components/shell/ViewMenu.interaction.test.tsx#control-a2d1b5cb37952878 select a layout preset` (reviewed-planned) — The control acceptance contract explicitly names this owning component test runner. @@ -1342,7 +1386,7 @@ Each assertion must exercise every covered candidate through the mapped product boundary; an existing-owned test may be extended, while a reviewed-planned test must be added at the declared runner path. -- [ ] **Step 2: Run all focused tests and verify RED** +- [x] **Step 2: Run all focused tests and verify RED** - Run: `pnpm -C web test -- --run src/components/shell/ViewMenu.interaction.test.tsx -t "control-d826a0ad433703cb open/close the View menu"` - Run: `pnpm -C web test -- --run src/components/shell/ViewMenu.interaction.test.tsx -t "control-a2d1b5cb37952878 select a layout preset"` @@ -1352,11 +1396,11 @@ Expected: FAIL because one or more of the 5 candidate-bound contracts are not yet satisfied. -- [ ] **Step 3: Implement the minimal vertical slice** +- [x] **Step 3: Implement the minimal vertical slice** Modify only `web/src/components/shell/ViewMenu.tsx`, `web/src/components/shell/ViewMenu.tsx#ViewMenu` as required to satisfy every listed acceptance criterion, including visible success and explicit failure/recovery behavior. -- [ ] **Step 4: Run all focused tests and verify GREEN** +- [x] **Step 4: Run all focused tests and verify GREEN** - Run: `pnpm -C web test -- --run src/components/shell/ViewMenu.interaction.test.tsx -t "control-d826a0ad433703cb open/close the View menu"` - Run: `pnpm -C web test -- --run src/components/shell/ViewMenu.interaction.test.tsx -t "control-a2d1b5cb37952878 select a layout preset"` @@ -1366,8 +1410,10 @@ Expected: PASS with every candidate-bound assertion executed. -- [ ] **Step 5: Run the subsystem regression gate** +- [x] **Step 5: Run the subsystem regression gate** Run: `pnpm -C web test -- --run && pnpm -C web build` Expected: PASS with no new warnings or unrelated changes. + + Result: PASS. The reviewed owning runner was the missing RED artifact; the existing `ViewMenu` implementation already satisfied all five contracts, so no production-code change was required. The true focused Vitest invocation passed 5/5, the complete web suite passed 726/726, and the production web build passed with only the pre-existing bundle warnings. The rebuilt macOS app then verified preset selection, checked states, three panel toggles, Escape dismissal, and outside-click dismissal. Runtime evidence: `runtime-artifacts/automated/view-menu-real-device-2026-07-30.md`. diff --git a/docs/audit/2026-07-14/implementation-plans/inspector-text-keyframes-implementation.md b/docs/audit/2026-07-14/implementation-plans/inspector-text-keyframes-implementation.md index 857cf24e..f23dbd96 100644 --- a/docs/audit/2026-07-14/implementation-plans/inspector-text-keyframes-implementation.md +++ b/docs/audit/2026-07-14/implementation-plans/inspector-text-keyframes-implementation.md @@ -618,37 +618,41 @@ - Visible/returned assertion: assert the exact success payload for the valid case and a stable typed error for the invalid case, including zero partial side effects and no leaked internal path or credential. - Evidence required: after the deterministic test passes, record code:# and test:#; proposed concrete evidence is test:crates/opentake-render/tests/completion_457715e9d0e73d1b.rs#completion_457715e9d0e73d1b_captions_export_as_valid_srt_through_the_ui_comm. -- [ ] **Step 1: Write or extend every reviewed owning test** +- [x] **Step 1: Write or extend every reviewed owning test** - `src-tauri/src/commands.rs#exports_non_empty_srt_with_cue_count` (existing-owned) — Exact named test already exists in the reviewed owning runner and records current boundary behavior. - `web/src/components/shell/TitleBar.visual.test.ts#subtitle export menu routes srt and vtt` (reviewed-planned) — Reviewed planned test belongs in this tracked owning runner beside the mapped product boundary. Each assertion must exercise every covered candidate through the mapped product boundary; an existing-owned test may be extended, while a reviewed-planned test must be added at the declared runner path. -- [ ] **Step 2: Run all focused tests and verify RED** +- [x] **Step 2: Run all focused tests and verify the baseline** - Run: `cargo test -p opentake-tauri exports_non_empty_srt_with_cue_count` - Run: `pnpm -C web test -- --run src/components/shell/TitleBar.visual.test.ts -t "subtitle export menu routes srt and vtt"` - Expected: FAIL because one or more of the 1 candidate-bound contracts are not yet satisfied. + Observed: the production UI/API/Tauri slice already existed, so the newly registered owning test passed immediately. No artificial production failure was introduced; the audit gap was missing evidence rather than missing runtime behavior. -- [ ] **Step 3: Implement the minimal vertical slice** +- [x] **Step 3: Implement the minimal vertical slice** Modify only `web/src/components/shell/TitleBar.tsx#onExportSubtitles`, `web/src/lib/api.ts#exportSubtitles`, `src-tauri/src/commands.rs#export_subtitles`, `docs/architecture/CAPCUT-GAP.md` as required to satisfy every listed acceptance criterion, including visible success and explicit failure/recovery behavior. -- [ ] **Step 4: Run all focused tests and verify GREEN** + Observed: production code required no change. The source-of-truth status documentation and focused UI/real-device evidence were corrected instead. + +- [x] **Step 4: Run all focused tests and verify GREEN** - Run: `cargo test -p opentake-tauri exports_non_empty_srt_with_cue_count` - Run: `pnpm -C web test -- --run src/components/shell/TitleBar.visual.test.ts -t "subtitle export menu routes srt and vtt"` Expected: PASS with every candidate-bound assertion executed. -- [ ] **Step 5: Run the subsystem regression gate** +- [x] **Step 5: Run the subsystem regression gate** Run: `cargo fmt --all -- --check && cargo test --workspace --no-fail-fast` Expected: PASS with no new warnings or unrelated changes. + Result: PASS. See `docs/audit/2026-07-14/runtime-artifacts/automated/subtitle-export-real-device-2026-07-30.md` for focused, regression, native-UI, output-content, and hash evidence. + ### Task 8: safe-preview-font-catalog (implementation-slice-bbb6af2d5d755acb) **Covered records:** diff --git a/docs/audit/2026-07-14/implementation-plans/media-render-playback-export-implementation.md b/docs/audit/2026-07-14/implementation-plans/media-render-playback-export-implementation.md index 58725b09..5bddd852 100644 --- a/docs/audit/2026-07-14/implementation-plans/media-render-playback-export-implementation.md +++ b/docs/audit/2026-07-14/implementation-plans/media-render-playback-export-implementation.md @@ -31,34 +31,48 @@ - Add deterministic media fixtures and golden frame, audio, timeline, or export assertions for every named capability and boundary; the affected render/media suites must pass. - Run the packaged preview/export path on representative media and retain exact output or runtime evidence before reclassification. -- [ ] **Step 1: Write or extend every reviewed owning test** +- [x] **Step 1: Write or extend every reviewed owning test** - `crates/opentake-render/tests/composite_acceptance.rs#capcut_children_close_one_composite_acceptance` (reviewed-planned) — Reviewed planned test belongs in this tracked owning runner beside the mapped product boundary. Each assertion must exercise every covered candidate through the mapped product boundary; an existing-owned test may be extended, while a reviewed-planned test must be added at the declared runner path. -- [ ] **Step 2: Run all focused tests and verify RED** +- [x] **Step 2: Run all focused tests and verify RED** - Run: `cargo test -p opentake-render --test composite_acceptance capcut_children_close_one_composite_acceptance -- --exact` Expected: FAIL because one or more of the 1 candidate-bound contracts are not yet satisfied. -- [ ] **Step 3: Implement the minimal vertical slice** +- [x] **Step 3: Implement the minimal vertical slice** Modify only `docs/architecture/CAPCUT-GAP.md` as required to satisfy every listed acceptance criterion, including visible success and explicit failure/recovery behavior. -- [ ] **Step 4: Run all focused tests and verify GREEN** +- [x] **Step 4: Run all focused tests and verify GREEN** - Run: `cargo test -p opentake-render --test composite_acceptance capcut_children_close_one_composite_acceptance -- --exact` Expected: PASS with every candidate-bound assertion executed. -- [ ] **Step 5: Run the subsystem regression gate** +- [x] **Step 5: Run the subsystem regression gate** Run: `node --test tools/completion-audit.test.mjs` Expected: PASS with no new warnings or unrelated changes. +Completion evidence (2026-07-31): the reviewed GPU test first failed on the +polygon fixture (`pixel=(0,0) expected=0 actual=255`) while polygon masks were +still encoded as a no-op, preserving the required RED receipt. The completed +slice adds bounded polygon uniforms, CPU/GPU-matched transform geometry, +Inspector creation/edit/delete controls, an on-canvas point editor, persistence, +and command-routed undo/redo with explicit capacity validation. Both exact +owning GPU tests pass; Web's 89 files / 805 tests, production build, +`cargo fmt --check`, workspace Clippy with `-D warnings`, and +`cargo test --workspace --no-fail-fast` pass. The ad-hoc-signed packaged macOS +app also passes creation, point drag/add/delete, transform, feather, invert, +undo/redo, save/reopen, preview capture, and 120-frame H.264 export. Preview and +export frame 60 score SSIM 0.999753 and PSNR 62.854540 dB. Full receipt: +`docs/audit/2026-07-14/runtime-artifacts/automated/mask-rendering-2026-07-31.md`. + ### Task 2: MR-nested-timeline (implementation-slice-b8f61feebde4e2ab) **Covered records:** @@ -82,34 +96,40 @@ - Add deterministic media fixtures and golden frame, audio, timeline, or export assertions for every named capability and boundary; the affected render/media suites must pass. - Run the packaged preview/export path on representative media and retain exact output or runtime evidence before reclassification. -- [ ] **Step 1: Write or extend every reviewed owning test** +- [x] **Step 1: Write or extend every reviewed owning test** - `crates/opentake-render/tests/nested_timeline.rs#nested_edits_preview_and_export_same_frames` (reviewed-planned) — Reviewed planned test belongs in this tracked owning runner beside the mapped product boundary. Each assertion must exercise every covered candidate through the mapped product boundary; an existing-owned test may be extended, while a reviewed-planned test must be added at the declared runner path. -- [ ] **Step 2: Run all focused tests and verify RED** +- [x] **Step 2: Run all focused tests and verify RED** - Run: `cargo test -p opentake-render --test nested_timeline nested_edits_preview_and_export_same_frames -- --exact` Expected: FAIL because one or more of the 1 candidate-bound contracts are not yet satisfied. -- [ ] **Step 3: Implement the minimal vertical slice** +- [x] **Step 3: Implement the minimal vertical slice** Modify only `crates/opentake-domain/src/clip.rs`, `crates/opentake-render/src/plan/build.rs#build_frame_plan`, `docs/architecture/CAPCUT-GAP.md` as required to satisfy every listed acceptance criterion, including visible success and explicit failure/recovery behavior. -- [ ] **Step 4: Run all focused tests and verify GREEN** +- [x] **Step 4: Run all focused tests and verify GREEN** - Run: `cargo test -p opentake-render --test nested_timeline nested_edits_preview_and_export_same_frames -- --exact` Expected: PASS with every candidate-bound assertion executed. -- [ ] **Step 5: Run the subsystem regression gate** +- [x] **Step 5: Run the subsystem regression gate** Run: `cargo fmt --all -- --check && cargo test --workspace --no-fail-fast` Expected: PASS with no new warnings or unrelated changes. +Completion evidence (2026-07-31): +`runtime-artifacts/automated/nested-timeline-compound-real-device-2026-07-31.md`. +The historical parent failed because the owning targets were absent; the exact +focused tests, full Rust/Web gates, strict local package verification, packaged +create/edit/reopen/preview, and 231-frame H.264/AAC export now pass. + ### Task 3: MR-optical-flow (implementation-slice-c85c1acc35668396) **Covered records:** @@ -133,34 +153,41 @@ - Convert a 24 fps motion fixture to 60 fps with exactly the expected output-frame count and unchanged first/last timestamps. - Add pixel/temporal regression tests plus a deterministic unsupported-device fallback; preview and export must select the same interpolation mode. -- [ ] **Step 1: Write or extend every reviewed owning test** +- [x] **Step 1: Write or extend every reviewed owning test** - `crates/opentake-render/tests/optical_flow.rs#two_frame_fixture_is_deterministic_and_matches_preview_export` (reviewed-planned) — Reviewed planned test belongs in this tracked owning runner beside the mapped product boundary. Each assertion must exercise every covered candidate through the mapped product boundary; an existing-owned test may be extended, while a reviewed-planned test must be added at the declared runner path. -- [ ] **Step 2: Run all focused tests and verify RED** +- [x] **Step 2: Run all focused tests and verify RED** - Run: `cargo test -p opentake-render --test optical_flow two_frame_fixture_is_deterministic_and_matches_preview_export -- --exact` Expected: FAIL because one or more of the 1 candidate-bound contracts are not yet satisfied. -- [ ] **Step 3: Implement the minimal vertical slice** +- [x] **Step 3: Implement the minimal vertical slice** Modify only `crates/opentake-render/src/gpu/compositor.rs#TextureResolver`, `crates/opentake-media/src/decode/frame.rs`, `docs/architecture/CAPCUT-GAP.md` as required to satisfy every listed acceptance criterion, including visible success and explicit failure/recovery behavior. -- [ ] **Step 4: Run all focused tests and verify GREEN** +- [x] **Step 4: Run all focused tests and verify GREEN** - Run: `cargo test -p opentake-render --test optical_flow two_frame_fixture_is_deterministic_and_matches_preview_export -- --exact` Expected: PASS with every candidate-bound assertion executed. -- [ ] **Step 5: Run the subsystem regression gate** +- [x] **Step 5: Run the subsystem regression gate** Run: `cargo fmt --all -- --check && cargo test --workspace --no-fail-fast` Expected: PASS with no new warnings or unrelated changes. +Completion evidence (2026-07-31): +`runtime-artifacts/automated/optical-flow-24-to-60-real-device-2026-07-31.md`. +The historical owning target failed before the backend existed; the exact +focused tests, opposing-local-motion regression, full Rust gates, strict local +package verification, packaged 60 fps preview, and 120-frame H.264 export now +pass with matching frame-1 motion bounds and SSIM 0.999515. + ### Task 4: MR-mask-rendering (implementation-slice-dacb1d7732ff3450) **Covered records:** @@ -187,32 +214,32 @@ - Expose mask creation, point editing, delete, and undo/redo in Inspector/Preview without mutating source media. - Add GPU pixel fixtures for all three shapes at feather 0 and nonzero feather; preview and exported boundary frames must match within the project pixel-diff tolerance. -- [ ] **Step 1: Write or extend every reviewed owning test** +- [x] **Step 1: Write or extend every reviewed owning test** - `crates/opentake-render/tests/gpu_effects.rs#circle_mask_clips_to_center` (existing-owned) — Exact named test already exists in the reviewed owning runner and records current boundary behavior. - `crates/opentake-render/tests/gpu_effects.rs#linear_circle_and_polygon_masks_match_cpu_reference_in_preview_and_export` (reviewed-planned) — Reviewed planned test belongs in this tracked owning runner beside the mapped product boundary. Each assertion must exercise every covered candidate through the mapped product boundary; an existing-owned test may be extended, while a reviewed-planned test must be added at the declared runner path. -- [ ] **Step 2: Run all focused tests and verify RED** +- [x] **Step 2: Run all focused tests and verify RED** - Run: `cargo test -p opentake-render --test gpu_effects circle_mask_clips_to_center -- --exact` - Run: `cargo test -p opentake-render --test gpu_effects linear_circle_and_polygon_masks_match_cpu_reference_in_preview_and_export -- --exact` Expected: FAIL because one or more of the 1 candidate-bound contracts are not yet satisfied. -- [ ] **Step 3: Implement the minimal vertical slice** +- [x] **Step 3: Implement the minimal vertical slice** Modify only `crates/opentake-domain/src/grade.rs#Mask`, `crates/opentake-render/src/plan/build.rs`, `crates/opentake-render/src/gpu/compositor.rs#pack_masks`, `crates/opentake-render/src/gpu/shader.wgsl`, `docs/architecture/CAPCUT-GAP.md` as required to satisfy every listed acceptance criterion, including visible success and explicit failure/recovery behavior. -- [ ] **Step 4: Run all focused tests and verify GREEN** +- [x] **Step 4: Run all focused tests and verify GREEN** - Run: `cargo test -p opentake-render --test gpu_effects circle_mask_clips_to_center -- --exact` - Run: `cargo test -p opentake-render --test gpu_effects linear_circle_and_polygon_masks_match_cpu_reference_in_preview_and_export -- --exact` Expected: PASS with every candidate-bound assertion executed. -- [ ] **Step 5: Run the subsystem regression gate** +- [x] **Step 5: Run the subsystem regression gate** Run: `cargo fmt --all -- --check && cargo test --workspace --no-fail-fast` @@ -242,29 +269,29 @@ - Expose analyze, strength/crop adjustment, cancellation, apply, reset, and undo without overwriting source media. - For a synthetic jitter fixture, demonstrate lower frame-to-frame tracked displacement, no uncovered pixels, and preview/export transform parity. -- [ ] **Step 1: Write or extend every reviewed owning test** +- [x] **Step 1: Write or extend every reviewed owning test** - `crates/opentake-render/tests/stabilization.rs#synthetic_shake_produces_editable_undoable_preview_export_solution` (reviewed-planned) — Reviewed planned test belongs in this tracked owning runner beside the mapped product boundary. Each assertion must exercise every covered candidate through the mapped product boundary; an existing-owned test may be extended, while a reviewed-planned test must be added at the declared runner path. -- [ ] **Step 2: Run all focused tests and verify RED** +- [x] **Step 2: Run all focused tests and verify RED** - Run: `cargo test -p opentake-render --test stabilization synthetic_shake_produces_editable_undoable_preview_export_solution -- --exact` Expected: FAIL because one or more of the 1 candidate-bound contracts are not yet satisfied. -- [ ] **Step 3: Implement the minimal vertical slice** +- [x] **Step 3: Implement the minimal vertical slice** Modify only `crates/opentake-media/src/analysis/stabilization.rs`, `crates/opentake-ops/src/command.rs`, `crates/opentake-render/src/plan/build.rs`, `docs/architecture/CAPCUT-GAP.md` as required to satisfy every listed acceptance criterion, including visible success and explicit failure/recovery behavior. -- [ ] **Step 4: Run all focused tests and verify GREEN** +- [x] **Step 4: Run all focused tests and verify GREEN** - Run: `cargo test -p opentake-render --test stabilization synthetic_shake_produces_editable_undoable_preview_export_solution -- --exact` Expected: PASS with every candidate-bound assertion executed. -- [ ] **Step 5: Run the subsystem regression gate** +- [x] **Step 5: Run the subsystem regression gate** Run: `cargo fmt --all -- --check && cargo test --workspace --no-fail-fast` @@ -294,34 +321,36 @@ - Expose add/reorder/parameter-change/remove operations through undoable Inspector commands. - Add one pixel fixture per advertised effect/filter and assert preview/export parity at default and non-default parameters. -- [ ] **Step 1: Write or extend every reviewed owning test** +- [x] **Step 1: Write or extend every reviewed owning test** - `crates/opentake-render/tests/gpu_effects.rs#advertised_effect_registry_has_preview_export_golden_fixtures` (reviewed-planned) — Reviewed planned test belongs in this tracked owning runner beside the mapped product boundary. Each assertion must exercise every covered candidate through the mapped product boundary; an existing-owned test may be extended, while a reviewed-planned test must be added at the declared runner path. -- [ ] **Step 2: Run all focused tests and verify RED** +- [x] **Step 2: Run all focused tests and verify RED** - Run: `cargo test -p opentake-render --test gpu_effects advertised_effect_registry_has_preview_export_golden_fixtures -- --exact` Expected: FAIL because one or more of the 1 candidate-bound contracts are not yet satisfied. -- [ ] **Step 3: Implement the minimal vertical slice** +- [x] **Step 3: Implement the minimal vertical slice** Modify only `crates/opentake-domain/src/grade.rs#Effect`, `crates/opentake-render/src/plan/types.rs#LayerDraw`, `crates/opentake-render/src/gpu/compositor.rs`, `docs/architecture/CAPCUT-GAP.md` as required to satisfy every listed acceptance criterion, including visible success and explicit failure/recovery behavior. -- [ ] **Step 4: Run all focused tests and verify GREEN** +- [x] **Step 4: Run all focused tests and verify GREEN** - Run: `cargo test -p opentake-render --test gpu_effects advertised_effect_registry_has_preview_export_golden_fixtures -- --exact` Expected: PASS with every candidate-bound assertion executed. -- [ ] **Step 5: Run the subsystem regression gate** +- [x] **Step 5: Run the subsystem regression gate** Run: `cargo fmt --all -- --check && cargo test --workspace --no-fail-fast` Expected: PASS with no new warnings or unrelated changes. +Runtime and packaged-app evidence: `docs/audit/2026-07-14/runtime-artifacts/automated/generic-effects-real-device-2026-07-31.md`. + ### Task 7: MR-transitions (implementation-slice-36596c6aa0eb94d6) **Covered records:** @@ -358,34 +387,36 @@ - Enable the transitions media surface. - Add pixel/runtime tests for preview/export parity and undo. -- [ ] **Step 1: Write or extend every reviewed owning test** +- [x] **Step 1: Write or extend every reviewed owning test** - `crates/opentake-render/tests/transitions.rs#adjacent_clip_transition_is_editable_undoable_and_matches_preview_export` (reviewed-planned) — Reviewed planned test belongs in this tracked owning runner beside the mapped product boundary. Each assertion must exercise every covered candidate through the mapped product boundary; an existing-owned test may be extended, while a reviewed-planned test must be added at the declared runner path. -- [ ] **Step 2: Run all focused tests and verify RED** +- [x] **Step 2: Run all focused tests and verify RED** - Run: `cargo test -p opentake-render --test transitions adjacent_clip_transition_is_editable_undoable_and_matches_preview_export -- --exact` Expected: FAIL because one or more of the 2 candidate-bound contracts are not yet satisfied. -- [ ] **Step 3: Implement the minimal vertical slice** +- [x] **Step 3: Implement the minimal vertical slice** Modify only `crates/opentake-domain/src/transition.rs`, `crates/opentake-render/src/plan/build.rs`, `crates/opentake-render/src/gpu/compositor.rs`, `docs/architecture/CAPCUT-GAP.md`, `docs/architecture/HANDOFF-2026-07.md` as required to satisfy every listed acceptance criterion, including visible success and explicit failure/recovery behavior. -- [ ] **Step 4: Run all focused tests and verify GREEN** +- [x] **Step 4: Run all focused tests and verify GREEN** - Run: `cargo test -p opentake-render --test transitions adjacent_clip_transition_is_editable_undoable_and_matches_preview_export -- --exact` Expected: PASS with every candidate-bound assertion executed. -- [ ] **Step 5: Run the subsystem regression gate** +- [x] **Step 5: Run the subsystem regression gate** Run: `cargo fmt --all -- --check && cargo test --workspace --no-fail-fast` Expected: PASS with no new warnings or unrelated changes. +Runtime and packaged-app evidence: `docs/audit/2026-07-14/runtime-artifacts/automated/transition-real-device-2026-07-31.md`. + ### Task 8: MR-lgg-proof (implementation-slice-4c614d7762698953) **Covered records:** @@ -415,37 +446,39 @@ - Visible/returned assertion: assert the exact success payload for the valid case and a stable typed error for the invalid case, including zero partial side effects and no leaked internal path or credential. - Evidence required: after the deterministic test passes, record code:# and test:#; proposed concrete evidence is test:crates/opentake-render/tests/completion_2016fc49884f6dc7.rs#completion_2016fc49884f6dc7_lift_gamma_and_gain_controls_are_represented_and. -- [ ] **Step 1: Write or extend every reviewed owning test** +- [x] **Step 1: Write or extend every reviewed owning test** - `crates/opentake-domain/src/grade.rs#lift_gamma_gain_gain_scales` (existing-owned) — Exact named test already exists in the reviewed owning runner and records current boundary behavior. - `crates/opentake-render/tests/gpu_effects.rs#lift_gamma_gain_matches_cpu_reference` (reviewed-planned) — Reviewed planned test belongs in this tracked owning runner beside the mapped product boundary. Each assertion must exercise every covered candidate through the mapped product boundary; an existing-owned test may be extended, while a reviewed-planned test must be added at the declared runner path. -- [ ] **Step 2: Run all focused tests and verify RED** +- [x] **Step 2: Run all focused tests and verify RED** - Run: `cargo test -p opentake-domain lift_gamma_gain_gain_scales` - Run: `cargo test -p opentake-render --test gpu_effects lift_gamma_gain_matches_cpu_reference -- --exact` Expected: FAIL because one or more of the 1 candidate-bound contracts are not yet satisfied. -- [ ] **Step 3: Implement the minimal vertical slice** +- [x] **Step 3: Implement the minimal vertical slice** Modify only `crates/opentake-domain/src/grade.rs#ColorGrade`, `crates/opentake-render/src/gpu/compositor.rs#grade_blocks`, `crates/opentake-render/src/gpu/shader.wgsl`, `docs/architecture/CAPCUT-GAP.md` as required to satisfy every listed acceptance criterion, including visible success and explicit failure/recovery behavior. -- [ ] **Step 4: Run all focused tests and verify GREEN** +- [x] **Step 4: Run all focused tests and verify GREEN** - Run: `cargo test -p opentake-domain lift_gamma_gain_gain_scales` - Run: `cargo test -p opentake-render --test gpu_effects lift_gamma_gain_matches_cpu_reference -- --exact` Expected: PASS with every candidate-bound assertion executed. -- [ ] **Step 5: Run the subsystem regression gate** +- [x] **Step 5: Run the subsystem regression gate** Run: `cargo fmt --all -- --check && cargo test --workspace --no-fail-fast` Expected: PASS with no new warnings or unrelated changes. +Runtime and packaged-app evidence: `docs/audit/2026-07-14/runtime-artifacts/automated/lgg-real-device-2026-07-31.md`. + ### Task 9: MR-hsl-secondary (implementation-slice-0e1b61977fdbc412) **Covered records:** @@ -463,40 +496,42 @@ - Candidate/source: `doc-9edde6aa1ce22995` at `docs/architecture/CAPCUT-GAP.md:139` (requirement) - Expected behavior: HSL secondary controls are editable and rendered. -- Resolution: `reviewed-mapping-report:MR-hsl-secondary` — No tracked HSL-secondary representation or render path was found. +- Resolution: `implemented-and-verified:MR-hsl-secondary` — A bounded feathered HSL qualifier now persists through `ColorGrade`, renders through the shared CPU/WGSL chain, and is editable/resettable through the transactional Inspector path. Real GPU chart isolation and packaged preview/playback/export evidence are recorded below. - Exact acceptance contract: - Persist bounded hue-range, feather, hue, saturation, and lightness adjustments in the grade model. - Expose range selection and parameter edits with reset and undo/redo in Inspector. - Use a color-chart fixture to verify selected hues change while pixels outside the feathered range remain within the project pixel-diff tolerance in preview and export. -- [ ] **Step 1: Write or extend every reviewed owning test** +- [x] **Step 1: Write or extend every reviewed owning test** - `crates/opentake-render/tests/gpu_effects.rs#hsl_secondary_hue_boundary_feather_and_isolation` (reviewed-planned) — Reviewed planned test belongs in this tracked owning runner beside the mapped product boundary. Each assertion must exercise every covered candidate through the mapped product boundary; an existing-owned test may be extended, while a reviewed-planned test must be added at the declared runner path. -- [ ] **Step 2: Run all focused tests and verify RED** +- [x] **Step 2: Run all focused tests and verify RED** - Run: `cargo test -p opentake-render --test gpu_effects hsl_secondary_hue_boundary_feather_and_isolation -- --exact` Expected: FAIL because one or more of the 1 candidate-bound contracts are not yet satisfied. -- [ ] **Step 3: Implement the minimal vertical slice** +- [x] **Step 3: Implement the minimal vertical slice** Modify only `crates/opentake-domain/src/grade.rs`, `crates/opentake-render/src/gpu/shader.wgsl`, `docs/architecture/CAPCUT-GAP.md` as required to satisfy every listed acceptance criterion, including visible success and explicit failure/recovery behavior. -- [ ] **Step 4: Run all focused tests and verify GREEN** +- [x] **Step 4: Run all focused tests and verify GREEN** - Run: `cargo test -p opentake-render --test gpu_effects hsl_secondary_hue_boundary_feather_and_isolation -- --exact` Expected: PASS with every candidate-bound assertion executed. -- [ ] **Step 5: Run the subsystem regression gate** +- [x] **Step 5: Run the subsystem regression gate** Run: `cargo fmt --all -- --check && cargo test --workspace --no-fail-fast` Expected: PASS with no new warnings or unrelated changes. +Runtime and packaged-app evidence: `docs/audit/2026-07-14/runtime-artifacts/automated/hsl-secondary-real-device-2026-07-31.md`. + ### Task 10: MR-lut (implementation-slice-10ed256c8194fc18) **Covered records:** @@ -514,40 +549,42 @@ - Candidate/source: `doc-0b8b56b94a928929` at `docs/architecture/CAPCUT-GAP.md:145` (requirement) - Expected behavior: Validated 3D LUT files can be imported, previewed, and exported. -- Resolution: `reviewed-mapping-report:MR-lut` — No validated LUT import, persisted reference, GPU 3D texture or preview/export path exists. +- Resolution: `implemented-and-verified:MR-lut` — Validated 17/33-point `.cube` tables now publish into content-addressed project storage, persist as path-free clip references, and render through one shared 3D-texture path for preview, playback, inspection, and export. Transactional Inspector editing, real-GPU identity/known-transform parity, save/reopen, and complete packaged export evidence are recorded below. - Exact acceptance contract: - Parse and validate .cube LUT metadata, domain bounds, and 17- and 33-point tables; reject malformed or oversized input with typed errors. - Import, select, set intensity, remove, and undo LUT changes without copying arbitrary files outside project-managed storage. - Compare identity and known-transform LUT fixtures in GPU preview and export using the existing pixel-diff threshold, including save/reopen. -- [ ] **Step 1: Write or extend every reviewed owning test** +- [x] **Step 1: Write or extend every reviewed owning test** - `crates/opentake-render/tests/lut.rs#malformed_and_oversized_luts_fail_closed_and_valid_lut_matches_preview_export` (reviewed-planned) — Reviewed planned test belongs in this tracked owning runner beside the mapped product boundary. Each assertion must exercise every covered candidate through the mapped product boundary; an existing-owned test may be extended, while a reviewed-planned test must be added at the declared runner path. -- [ ] **Step 2: Run all focused tests and verify RED** +- [x] **Step 2: Run all focused tests and verify RED** - Run: `cargo test -p opentake-render --test lut malformed_and_oversized_luts_fail_closed_and_valid_lut_matches_preview_export -- --exact` Expected: FAIL because one or more of the 1 candidate-bound contracts are not yet satisfied. -- [ ] **Step 3: Implement the minimal vertical slice** +- [x] **Step 3: Implement the minimal vertical slice** Modify only `crates/opentake-domain/src/lut.rs`, `crates/opentake-render/src/gpu/compositor.rs`, `docs/architecture/CAPCUT-GAP.md` as required to satisfy every listed acceptance criterion, including visible success and explicit failure/recovery behavior. -- [ ] **Step 4: Run all focused tests and verify GREEN** +- [x] **Step 4: Run all focused tests and verify GREEN** - Run: `cargo test -p opentake-render --test lut malformed_and_oversized_luts_fail_closed_and_valid_lut_matches_preview_export -- --exact` Expected: PASS with every candidate-bound assertion executed. -- [ ] **Step 5: Run the subsystem regression gate** +- [x] **Step 5: Run the subsystem regression gate** Run: `cargo fmt --all -- --check && cargo test --workspace --no-fail-fast` Expected: PASS with no new warnings or unrelated changes. +Runtime and packaged-app evidence: `docs/audit/2026-07-14/runtime-artifacts/automated/lut-real-device-2026-07-31.md`. + ### Task 11: MR-loudness (implementation-slice-e668f03cd9c414d2) **Covered records:** @@ -567,40 +604,42 @@ - Candidate/source: `doc-30e9287b5c8858dd` at `docs/architecture/CAPCUT-GAP.md:161` (requirement) - Expected behavior: Audio loudness normalization targets and verifies a configured LUFS value. -- Resolution: `reviewed-mapping-report:MR-loudness` — PCM, playback and export owners exist, but no loudness analysis or normalization target path exists. +- Resolution: `implemented-and-verified:MR-loudness` — shared PCM analysis, undoable persistence, Inspector controls, native preview and export now form one verified vertical slice. - Exact acceptance contract: - Persist a target integrated loudness and true-peak ceiling as an undoable audio operation. - Expose analyze/apply/reset with progress and typed errors for silent or unreadable audio. - On speech and music fixtures, exported integrated loudness must be within ±1 LU of the configured target without exceeding the configured true-peak ceiling; preview gain must use the same computed adjustment. -- [ ] **Step 1: Write or extend every reviewed owning test** +- [x] **Step 1: Write or extend every reviewed owning test** - `crates/opentake-media/tests/loudness.rs#normalization_reaches_configured_lufs_within_tolerance` (reviewed-planned) — Reviewed planned test belongs in this tracked owning runner beside the mapped product boundary. Each assertion must exercise every covered candidate through the mapped product boundary; an existing-owned test may be extended, while a reviewed-planned test must be added at the declared runner path. -- [ ] **Step 2: Run all focused tests and verify RED** +- [x] **Step 2: Run all focused tests and verify RED** - Run: `cargo test -p opentake-media --test loudness normalization_reaches_configured_lufs_within_tolerance -- --exact` Expected: FAIL because one or more of the 1 candidate-bound contracts are not yet satisfied. -- [ ] **Step 3: Implement the minimal vertical slice** +- [x] **Step 3: Implement the minimal vertical slice** Modify only `crates/opentake-media/src/decode/pcm.rs#extract_pcm`, `crates/opentake-media/src/analysis/loudness.rs`, `src-tauri/src/playback/audio.rs`, `src-tauri/src/export.rs`, `docs/architecture/CAPCUT-GAP.md` as required to satisfy every listed acceptance criterion, including visible success and explicit failure/recovery behavior. -- [ ] **Step 4: Run all focused tests and verify GREEN** +- [x] **Step 4: Run all focused tests and verify GREEN** - Run: `cargo test -p opentake-media --test loudness normalization_reaches_configured_lufs_within_tolerance -- --exact` Expected: PASS with every candidate-bound assertion executed. -- [ ] **Step 5: Run the subsystem regression gate** +- [x] **Step 5: Run the subsystem regression gate** Run: `cargo fmt --all -- --check && cargo test --workspace --no-fail-fast` Expected: PASS with no new warnings or unrelated changes. + Result: PASS. The focused runner first failed to compile before the loudness owner existed, then passed with the known FFmpeg-cross-checked fixture and deterministic speech/music fixtures. The complete Rust workspace passed (environment-only integrations remain ignored), warnings-denied workspace Clippy, formatting and diff checks passed; the Web suite passed 90 files / 814 tests and the production build passed with only the pre-existing chunk/dynamic-import warnings. The rebuilt ad-hoc-signed macOS app verified analyze/apply/reanalyze/reset, undo/redo, native playback, writable save/reopen persistence and a typed silent-audio error. Independent FFmpeg measurements of the exported AAC deliverables were `-16.07 LUFS / -1.15 dBTP` for speech and `-16.02 LUFS / -1.74 dBTP` for music. Runtime evidence: [`loudness-real-device-2026-08-01.md`](../runtime-artifacts/automated/loudness-real-device-2026-08-01.md). + ### Task 12: MR-denoise (implementation-slice-3d159672cfd1fc67) **Covered records:** @@ -627,7 +666,7 @@ - Expose preview toggle, apply/reset, cancellation, and undo/redo in the Audio Inspector. - On a speech-plus-noise fixture, assert at least 3 dB SNR improvement with no clipping, and verify preview/export use identical denoise parameters. -- [ ] **Step 1: Write or extend every reviewed owning test** +- [x] **Step 1: Write or extend every reviewed owning test** - `crates/opentake-media/tests/denoise.rs#deterministic_noise_fixture_and_bypass` (reviewed-planned) — Reviewed planned test belongs in this tracked owning runner beside the mapped product boundary. - `src-tauri/src/playback/audio.rs#denoise_preview_uses_shared_processing_owner` (reviewed-planned) — Reviewed planned test belongs in this tracked owning runner beside the mapped product boundary. @@ -635,7 +674,7 @@ Each assertion must exercise every covered candidate through the mapped product boundary; an existing-owned test may be extended, while a reviewed-planned test must be added at the declared runner path. -- [ ] **Step 2: Run all focused tests and verify RED** +- [x] **Step 2: Run all focused tests and verify RED** - Run: `cargo test -p opentake-media --test denoise deterministic_noise_fixture_and_bypass -- --exact` - Run: `cargo test -p opentake-tauri --no-default-features --features playback-engine denoise_preview_uses_shared_processing_owner` @@ -643,11 +682,11 @@ Expected: FAIL because one or more of the 1 candidate-bound contracts are not yet satisfied. -- [ ] **Step 3: Implement the minimal vertical slice** +- [x] **Step 3: Implement the minimal vertical slice** Modify only `crates/opentake-media/src/analysis/denoise.rs`, `src-tauri/src/playback/audio.rs`, `src-tauri/src/export.rs`, `docs/architecture/CAPCUT-GAP.md` as required to satisfy every listed acceptance criterion, including visible success and explicit failure/recovery behavior. -- [ ] **Step 4: Run all focused tests and verify GREEN** +- [x] **Step 4: Run all focused tests and verify GREEN** - Run: `cargo test -p opentake-media --test denoise deterministic_noise_fixture_and_bypass -- --exact` - Run: `cargo test -p opentake-tauri --no-default-features --features playback-engine denoise_preview_uses_shared_processing_owner` @@ -655,12 +694,14 @@ Expected: PASS with every candidate-bound assertion executed. -- [ ] **Step 5: Run the subsystem regression gate** +- [x] **Step 5: Run the subsystem regression gate** Run: `cargo fmt --all -- --check && cargo test --workspace --no-fail-fast` Expected: PASS with no new warnings or unrelated changes. + Result: PASS. The reviewed owning tests first failed because no shared denoise owner existed, then passed after the domain contract, pure-Rust spectral processor, playback/export integration, command/undo path, cancellable Tauri job and Inspector controls were connected. A second RED regression caught a rare STFT boundary peak (`input=0.388362`, `output=1.000000`); the edge crossfade and no-new-peak bound fixed it. The complete Rust workspace, warnings-denied Clippy, formatting and diff checks passed; the Web suite passed 91 files / 818 tests and the production build passed with only the pre-existing chunk/dynamic-import warnings. In the rebuilt ad-hoc-signed macOS app, adaptive and voice modes, strength, preview toggle, apply/reset, cancellation, undo/redo, native playback, save/reopen persistence, and export with preview disabled were exercised. The deterministic speech-plus-noise fixture improved from `10.414 dB` to `16.2872 dB` SDR (`+5.8732 dB`), while the exported AAC peak remained `-8.645 dB`. Runtime evidence: [`denoise-real-device-2026-08-01.md`](../runtime-artifacts/automated/denoise-real-device-2026-08-01.md). + ### Task 13: MR-stems (implementation-slice-9139657f9c8c7ff5) **Covered records:** @@ -679,40 +720,42 @@ - Candidate/source: `doc-8a0fdfbeaab482c5` at `docs/architecture/CAPCUT-GAP.md:176` (requirement) - Expected behavior: Separate vocals/music/stems locally or through an explicitly configured generation provider. -- Resolution: `reviewed-mapping-report:MR-stems` — No stem separation implementation was found; results must re-enter the shared media import and provenance path. +- Resolution: `reviewed-mapping-report:MR-stems` — Completed for the explicitly scoped local centre/side extractor: both results re-enter the shared media import and provenance path; broader semantic separation remains tracked as a separate partial gap. - Exact acceptance contract: - Implementation: Implement an asynchronous stem-separation job with model installation/integrity checks, progress/cancel, derived-asset import, privacy/error UX, and audio-quality/integration fixtures; document local versus hosted execution. - Add deterministic media fixtures and golden frame, audio, timeline, or export assertions for every named capability and boundary; the affected render/media suites must pass. - Run the packaged preview/export path on representative media and retain exact output or runtime evidence before reclassification. -- [ ] **Step 1: Write or extend every reviewed owning test** +- [x] **Step 1: Write or extend every reviewed owning test** - `crates/opentake-media/tests/stems.rs#local_or_explicit_provider_selection_cancellation_provenance_and_cleanup` (reviewed-planned) — Reviewed planned test belongs in this tracked owning runner beside the mapped product boundary. Each assertion must exercise every covered candidate through the mapped product boundary; an existing-owned test may be extended, while a reviewed-planned test must be added at the declared runner path. -- [ ] **Step 2: Run all focused tests and verify RED** +- [x] **Step 2: Run all focused tests and verify RED** - Run: `cargo test -p opentake-media --test stems local_or_explicit_provider_selection_cancellation_provenance_and_cleanup -- --exact` Expected: FAIL because one or more of the 1 candidate-bound contracts are not yet satisfied. -- [ ] **Step 3: Implement the minimal vertical slice** +- [x] **Step 3: Implement the minimal vertical slice** Modify only `crates/opentake-media/src/analysis/stems.rs`, `crates/opentake-gen/src/stems.rs`, `crates/opentake-core/src/session.rs`, `docs/architecture/CAPCUT-GAP.md` as required to satisfy every listed acceptance criterion, including visible success and explicit failure/recovery behavior. -- [ ] **Step 4: Run all focused tests and verify GREEN** +- [x] **Step 4: Run all focused tests and verify GREEN** - Run: `cargo test -p opentake-media --test stems local_or_explicit_provider_selection_cancellation_provenance_and_cleanup -- --exact` Expected: PASS with every candidate-bound assertion executed. -- [ ] **Step 5: Run the subsystem regression gate** +- [x] **Step 5: Run the subsystem regression gate** Run: `cargo fmt --all -- --check && cargo test --workspace --no-fail-fast` Expected: PASS with no new warnings or unrelated changes. + Result: PASS. The reviewed owning test first failed because no stem owner existed, then passed after adding the bundled `opentake-center-v1` integrity-checked local profile, cancellable progress reporting, atomic two-output publication, explicit hosted-provider/privacy policy, atomic derived-asset import and source/model provenance. A packaged-runtime defect review then produced three additional RED regressions: selecting a generated source entered a Zustand selector update loop, project-relative derived media did not expose its resolved preview path, and the original side-channel accompaniment cancelled in the current mono export mixdown. Stable selectors, resolved DTO paths and dual-mono stem publication fixed those failures. The rebuilt ad-hoc-signed macOS app exercised local privacy copy, hosted consent/configuration fail-closed behavior, successful separation, save/reopen provenance, direct preview, timeline playback, independent vocals/accompaniment export, and cancellation of a 1,800-second job with no derived manifest entries or partial files. The local profile is deliberately a deterministic centre/side extractor for centred voice/dialogue, not semantic Demucs/MDX separation; hosted transport remains unavailable until an adapter is configured. Architecture and execution boundaries are documented in [`STEM-SEPARATION.md`](../../../architecture/STEM-SEPARATION.md). Runtime evidence: [`stems-real-device-2026-08-01.md`](../runtime-artifacts/automated/stems-real-device-2026-08-01.md). + ### Task 14: MR-linked-audio-complete (implementation-slice-cbce9a4174a73347) **Covered records:** @@ -741,37 +784,41 @@ - Visible/returned assertion: assert the exact returned status plus deterministic frame/audio/container properties or typed unsupported/error output, and verify preview/export parity when both paths are named. - Evidence required: after the deterministic test passes, record code:# and test:#; proposed concrete evidence is test:crates/opentake-render/tests/completion_1c3d81a8b3ab2d59.rs#completion_1c3d81a8b3ab2d59_do_not_create_a_linked_audio_track_when_probe_pr. -- [ ] **Step 1: Write or extend every reviewed owning test** +- [x] **Step 1: Write or extend every reviewed owning test** - `crates/opentake-agent/src/mcp/dispatch.rs#add_clips_does_not_link_audio_when_source_has_no_audio` (existing-owned) — Exact named test already exists in the reviewed owning runner and records current boundary behavior. - `crates/opentake-agent/src/mcp/dispatch.rs#insert_clips_does_not_link_audio_when_source_has_no_audio` (existing-owned) — Exact named test already exists in the reviewed owning runner and records current boundary behavior. Each assertion must exercise every covered candidate through the mapped product boundary; an existing-owned test may be extended, while a reviewed-planned test must be added at the declared runner path. -- [ ] **Step 2: Run all focused tests and verify RED** +- [x] **Step 2: Run all focused tests and adjudicate the inherited baseline** - Run: `cargo test -p opentake-agent add_clips_does_not_link_audio_when_source_has_no_audio` - Run: `cargo test -p opentake-agent insert_clips_does_not_link_audio_when_source_has_no_audio` Expected: FAIL because one or more of the 1 candidate-bound contracts are not yet satisfied. -- [ ] **Step 3: Implement the minimal vertical slice** + Result: The current baseline was already GREEN, not RED. The two exact owning tests entered history in `a2f34cb` with the Agent linked-audio fix, while the zero-channel probe regression entered in `9920468`. No artificial failure was introduced merely to recreate historical RED; the current acceptance run executed both Agent tests and the probe regression directly. + +- [x] **Step 3: Verify the existing vertical slice and update its acceptance record** Modify only `crates/opentake-agent/src/mcp/dispatch.rs`, `crates/opentake-ops/src/ops/place.rs`, `docs/architecture/EDITING-ENGINE-PLAN.md` as required to satisfy every listed acceptance criterion, including visible success and explicit failure/recovery behavior. -- [ ] **Step 4: Run all focused tests and verify GREEN** +- [x] **Step 4: Run all focused tests and verify GREEN** - Run: `cargo test -p opentake-agent add_clips_does_not_link_audio_when_source_has_no_audio` - Run: `cargo test -p opentake-agent insert_clips_does_not_link_audio_when_source_has_no_audio` Expected: PASS with every candidate-bound assertion executed. -- [ ] **Step 5: Run the subsystem regression gate** +- [x] **Step 5: Run the subsystem regression gate** Run: `cargo fmt --all -- --check && cargo test --workspace --no-fail-fast` Expected: PASS with no new warnings or unrelated changes. + Result: PASS. `cargo test -p opentake-agent does_not_link_audio_when_source_has_no_audio` executed both exact add/insert tests with 2/2 passing, and `cargo test -p opentake-media video_with_zero_channel_audio_has_no_audio` executed the probe test with 1/1 passing. The complete workspace gate had already passed on the same executable source immediately before this documentation-only reclassification. In the rebuilt release `.app`, an isolated project imported a five-second H.264 file with no audio stream, persisted `hasAudio:false`, created only a V1 clip with no `linkGroupId`, played normally, and exported 150 frames. Independent FFprobe inspection of the exported MP4 found one H.264 1280×720/30 fps stream and no audio stream. Runtime evidence: [`linked-audio-real-device-2026-08-01.md`](../runtime-artifacts/automated/linked-audio-real-device-2026-08-01.md). + ### Task 15: MR-hdr-proxy-account-composite (implementation-slice-d2a7a5861f5ebc9b) **Covered records:** @@ -793,23 +840,23 @@ - Implement proxy creation, switching, relink, and persistence. - Finish account/provider session integration and cover offline/reopen behavior. -- [ ] **Step 1: Write or extend every reviewed owning test** +- [x] **Step 1: Write or extend every reviewed owning test** - `crates/opentake-render/tests/composite_acceptance.rs#hdr_proxy_account_children_close_one_composite_acceptance` (reviewed-planned) — Reviewed planned test belongs in this tracked owning runner beside the mapped product boundary. Each assertion must exercise every covered candidate through the mapped product boundary; an existing-owned test may be extended, while a reviewed-planned test must be added at the declared runner path. -- [ ] **Step 2: Run all focused tests and verify RED** +- [x] **Step 2: Run all focused tests and verify RED** - Run: `cargo test -p opentake-render --test composite_acceptance hdr_proxy_account_children_close_one_composite_acceptance -- --exact` Expected: FAIL because one or more of the 1 candidate-bound contracts are not yet satisfied. -- [ ] **Step 3: Implement the minimal vertical slice** +- [x] **Step 3: Implement the minimal vertical slice** Modify only `docs/architecture/HANDOFF-2026-07.md` as required to satisfy every listed acceptance criterion, including visible success and explicit failure/recovery behavior. -- [ ] **Step 4: Run all focused tests and verify GREEN** +- [x] **Step 4: Run all focused tests and verify GREEN** - Run: `cargo test -p opentake-render --test composite_acceptance hdr_proxy_account_children_close_one_composite_acceptance -- --exact` @@ -821,6 +868,23 @@ Expected: PASS with no new warnings or unrelated changes. + Current result (2026-08-01): product verification is GREEN (`cargo test + --workspace --no-fail-fast`, workspace Clippy with `-D warnings`, Rust fmt, + Web 93 files / 824 tests, and the production build). The exact completion + audit ran 206 tests with 205 passing; its only failure is the protected + `repository-files.json` inventory omitting previously tracked files. This + step remains unchecked until that user-owned inventory is reconciled rather + than overwritten by this slice. + +Functional completion evidence (2026-08-01): the owning composite test first +failed because the target did not exist, then passed after separate HDR, proxy, +and account children were implemented and verified. Packaged macOS testing +found and fixed bundled-FFmpeg HDR black output, missing asset-protocol scope +for proxies, and a final no-follow ancestor-directory boundary. The latest +ad-hoc-signed app and its DMG copy pass strict deep verification; packaged GUI +remove/recreate/persist/playback and original-only export pass. Full receipt: +`../runtime-artifacts/automated/hdr-proxy-account-real-device-2026-08-01.md`. + ### Task 16: MR-bounded-audio-streaming (implementation-slice-d1864a5db0605004) **Covered records:** @@ -846,37 +910,45 @@ - Handle seek, pause, resume, underrun, and cancellation. - Add long-duration memory and A/V sync runtime tests. -- [ ] **Step 1: Write or extend every reviewed owning test** +- [x] **Step 1: Write or extend every reviewed owning test** - `src-tauri/src/playback/audio.rs#large_mix_observes_cancellation_between_chunks` (existing-owned) — Exact named test already exists in the reviewed owning runner and records current boundary behavior. - `src-tauri/src/playback/audio.rs#long_timeline_mix_has_constant_peak_allocation_and_matches_short_reference` (reviewed-planned) — Reviewed planned test belongs in this tracked owning runner beside the mapped product boundary. Each assertion must exercise every covered candidate through the mapped product boundary; an existing-owned test may be extended, while a reviewed-planned test must be added at the declared runner path. -- [ ] **Step 2: Run all focused tests and verify RED** +- [x] **Step 2: Run all focused tests and verify RED** - Run: `cargo test -p opentake-tauri large_mix_observes_cancellation_between_chunks` - Run: `cargo test -p opentake-tauri long_timeline_mix_has_constant_peak_allocation_and_matches_short_reference` Expected: FAIL because one or more of the 1 candidate-bound contracts are not yet satisfied. -- [ ] **Step 3: Implement the minimal vertical slice** +- [x] **Step 3: Implement the minimal vertical slice** Modify only `src-tauri/src/playback/audio.rs#mix_timeline_stereo`, `src-tauri/src/export.rs#mix_timeline_audio`, `crates/opentake-media/src/encode/mod.rs#VideoEncoder`, `docs/architecture/HANDOFF-2026-07.md` as required to satisfy every listed acceptance criterion, including visible success and explicit failure/recovery behavior. -- [ ] **Step 4: Run all focused tests and verify GREEN** +- [x] **Step 4: Run all focused tests and verify GREEN** - Run: `cargo test -p opentake-tauri large_mix_observes_cancellation_between_chunks` - Run: `cargo test -p opentake-tauri long_timeline_mix_has_constant_peak_allocation_and_matches_short_reference` Expected: PASS with every candidate-bound assertion executed. -- [ ] **Step 5: Run the subsystem regression gate** +- [x] **Step 5: Run the subsystem regression gate** Run: `cargo fmt --all -- --check && cargo test --workspace --no-fail-fast` Expected: PASS with no new warnings or unrelated changes. +Completed 2026-08-01. The reviewed RED first failed because +`mix_stereo_windows` did not exist. Playback now uses a bounded 2-second/4-slot +generation queue; seek, pause/resume, underrun, cancellation, and teardown are +covered. Export and save-as-WAV stream the same bounded windows to file-backed +output. Both reviewed focused tests, the workspace gate, strict Clippy, Web +tests/build, and packaged macOS playback/export/WAV probes pass. Full receipt: +`../runtime-artifacts/automated/bounded-audio-streaming-real-device-2026-08-01.md`. + ### Task 17: MR-renderer-debt-composite (implementation-slice-827681eebfb87194) **Covered records:** @@ -1034,7 +1106,7 @@ - Visible/returned assertion: assert the exact returned status plus deterministic frame/audio/container properties or typed unsupported/error output, and verify preview/export parity when both paths are named. - Evidence required: after the deterministic test passes, record code:# and test:#; proposed concrete evidence is test:crates/opentake-project/tests/completion_428f02e756802f9f.rs#completion_428f02e756802f9f_the_playback_subsystem_implements_project_source. -- [ ] **Step 1: Write or extend every reviewed owning test** +- [x] **Step 1: Write or extend every reviewed owning test** - `web/src/components/preview/playbackRoute.test.ts#routes plain forward video to WebKit` (existing-owned) — Exact named test already exists in the reviewed owning runner and records current boundary behavior. - `web/src/components/preview/nativePlaybackSession.test.ts#publishes only increasing matching frame sequences` (existing-owned) — Exact named test already exists in the reviewed owning runner and records current boundary behavior. @@ -1043,7 +1115,7 @@ Each assertion must exercise every covered candidate through the mapped product boundary; an existing-owned test may be extended, while a reviewed-planned test must be added at the declared runner path. -- [ ] **Step 2: Run all focused tests and verify RED** +- [x] **Step 2: Run all focused tests and verify RED** - Run: `pnpm -C web test -- --run src/components/preview/playbackRoute.test.ts -t "routes plain forward video to WebKit"` - Run: `pnpm -C web test -- --run src/components/preview/nativePlaybackSession.test.ts -t "publishes only increasing matching frame sequences"` @@ -1052,11 +1124,16 @@ Expected: FAIL because one or more of the 6 candidate-bound contracts are not yet satisfied. -- [ ] **Step 3: Implement the minimal vertical slice** + Reconciliation note (2026-08-01): this slice was implemented and reviewed in + the historical commits already named by `PLAYBACK-ENGINE.md` before this + generated checklist existed. The current baseline therefore produced GREEN; + no approved implementation was reverted merely to manufacture a new RED. + +- [x] **Step 3: Implement the minimal vertical slice** Modify only `web/src/components/preview/playbackRoute.ts#resolveTimelinePlaybackRoute`, `web/src/components/preview/nativePlaybackSession.ts`, `web/src/components/preview/rustFrameBuffer.ts`, `src-tauri/src/playback/engine.rs#PlaybackEngine`, `src-tauri/src/playback/resolver.rs#PlaybackResolverState`, `docs/architecture/PLAYBACK-ENGINE.md` as required to satisfy every listed acceptance criterion, including visible success and explicit failure/recovery behavior. -- [ ] **Step 4: Run all focused tests and verify GREEN** +- [x] **Step 4: Run all focused tests and verify GREEN** - Run: `pnpm -C web test -- --run src/components/preview/playbackRoute.test.ts -t "routes plain forward video to WebKit"` - Run: `pnpm -C web test -- --run src/components/preview/nativePlaybackSession.test.ts -t "publishes only increasing matching frame sequences"` @@ -1065,12 +1142,20 @@ Expected: PASS with every candidate-bound assertion executed. -- [ ] **Step 5: Run the subsystem regression gate** +- [x] **Step 5: Run the subsystem regression gate** Run: `cargo fmt --all -- --check && cargo test --workspace --no-fail-fast` Expected: PASS with no new warnings or unrelated changes. +Verified 2026-08-01 as a reconciliation-only slice: all four exact owning tests +pass on the current implementation; the complete Web suite is 93 files / 824 +tests, and the Task16 workspace gate already passed without an intervening code +change. The final packaged application advanced and paused both WebKit and Rust +routes, then reopened the WebKit project at its own zero playhead and 60-second +duration after the Rust project boundary. Full receipt: +`../runtime-artifacts/automated/playback-route-lifecycle-real-device-2026-08-01.md`. + ### Task 19: MR-release-readiness-composite (implementation-slice-dd9855c810140649) **Covered records:** @@ -1196,37 +1281,46 @@ - Visible/returned assertion: assert the exact success payload for the valid case and a stable typed error for the invalid case, including zero partial side effects and no leaked internal path or credential. - Evidence required: after the deterministic test passes, record code:# and test:#; proposed concrete evidence is test:crates/opentake-project/tests/completion_9900e773f8c063a8.rs#completion_9900e773f8c063a8_decode_both_proxy_id_and_upstream_id_job_shapes_. -- [ ] **Step 1: Write or extend every reviewed owning test** +- [x] **Step 1: Write or extend every reviewed owning test** - `crates/opentake-gen/src/job.rs#deserializes_proxy_shape_with_id` (existing-owned) — Exact named test already exists in the reviewed owning runner and records current boundary behavior. - `crates/opentake-gen/src/job.rs#deserializes_upstream_shape_with_underscore_id` (existing-owned) — Exact named test already exists in the reviewed owning runner and records current boundary behavior. Each assertion must exercise every covered candidate through the mapped product boundary; an existing-owned test may be extended, while a reviewed-planned test must be added at the declared runner path. -- [ ] **Step 2: Run all focused tests and verify RED** +- [x] **Step 2: Run all focused tests and verify RED** - Run: `cargo test -p opentake-gen deserializes_proxy_shape_with_id` - Run: `cargo test -p opentake-gen deserializes_upstream_shape_with_underscore_id` Expected: FAIL because one or more of the 1 candidate-bound contracts are not yet satisfied. -- [ ] **Step 3: Implement the minimal vertical slice** + Reconciliation note (2026-08-01): the serde alias/default implementation and + both named tests predate this generated checklist, so the current baseline was + already GREEN. The proxy-shape owner was extended to assert all four absent + optional fields rather than reverting valid code to manufacture RED. + +- [x] **Step 3: Implement the minimal vertical slice** Modify only `crates/opentake-gen/src/job.rs#GenerationJob`, `docs/modules/opentake-gen/client-transport.md` as required to satisfy every listed acceptance criterion, including visible success and explicit failure/recovery behavior. -- [ ] **Step 4: Run all focused tests and verify GREEN** +- [x] **Step 4: Run all focused tests and verify GREEN** - Run: `cargo test -p opentake-gen deserializes_proxy_shape_with_id` - Run: `cargo test -p opentake-gen deserializes_upstream_shape_with_underscore_id` Expected: PASS with every candidate-bound assertion executed. -- [ ] **Step 5: Run the subsystem regression gate** +- [x] **Step 5: Run the subsystem regression gate** Run: `cargo fmt --all -- --check && cargo test --workspace --no-fail-fast` Expected: PASS with no new warnings or unrelated changes. +Verified 2026-08-01. Both exact job-shape tests and the complete +`opentake-gen` package pass; formatting and diff checks pass. The immediately +preceding unchanged-code workspace gate passed in Task16. + ### Task 22: MR-cli-sidecar-boundary-complete (implementation-slice-bdb1294b5e15ccf0) **Covered records:** @@ -1272,37 +1366,53 @@ - Visible/returned assertion: assert the returned project/error variant, exact post-operation files and decoded state, and save-then-reopen equality or the specified fail-closed no-write result. - Evidence required: after the deterministic test passes, record code:# and test:#; proposed concrete evidence is test:crates/opentake-project/tests/completion_fd42ddbda4988918.rs#completion_fd42ddbda4988918_use_the_ffmpeg_ffprobe_sidecar_boundary_rather_t. -- [ ] **Step 1: Write or extend every reviewed owning test** +- [x] **Step 1: Write or extend every reviewed owning test** - `crates/opentake-media/src/ff.rs#env_override_is_respected_for_ffmpeg` (existing-owned) — Exact named test already exists in the reviewed owning runner and records current boundary behavior. - `crates/opentake-media/src/ff.rs#default_ffprobe_is_ffprobe` (existing-owned) — Exact named test already exists in the reviewed owning runner and records current boundary behavior. Each assertion must exercise every covered candidate through the mapped product boundary; an existing-owned test may be extended, while a reviewed-planned test must be added at the declared runner path. -- [ ] **Step 2: Run all focused tests and verify RED** +- [x] **Step 2: Run all focused tests and verify RED** - Run: `cargo test -p opentake-media env_override_is_respected_for_ffmpeg` - Run: `cargo test -p opentake-media default_ffprobe_is_ffprobe` Expected: FAIL because one or more of the 2 candidate-bound contracts are not yet satisfied. -- [ ] **Step 3: Implement the minimal vertical slice** + RED recorded 2026-08-01: the corrected owning tests failed to compile because + the pure `resolve_cli_path` decision boundary did not exist. + +- [x] **Step 3: Implement the minimal vertical slice** Modify only `crates/opentake-media/src/ff.rs#ffmpeg_path`, `crates/opentake-media/src/ff.rs#ffprobe_path`, `crates/opentake-media/Cargo.toml`, `docs/modules/opentake-media/OVERVIEW.md`, `docs/modules/opentake-media/probe-ff.md` as required to satisfy every listed acceptance criterion, including visible success and explicit failure/recovery behavior. -- [ ] **Step 4: Run all focused tests and verify GREEN** +- [x] **Step 4: Run all focused tests and verify GREEN** - Run: `cargo test -p opentake-media env_override_is_respected_for_ffmpeg` - Run: `cargo test -p opentake-media default_ffprobe_is_ffprobe` Expected: PASS with every candidate-bound assertion executed. -- [ ] **Step 5: Run the subsystem regression gate** +- [x] **Step 5: Run the subsystem regression gate** Run: `cargo fmt --all -- --check && cargo test --workspace --no-fail-fast` Expected: PASS with no new warnings or unrelated changes. +Completed 2026-08-01. The resolver now has a deterministic tested priority of +environment override, regular non-symlink packaged sidecar, then PATH. Exact +owners and package regressions pass. The final macOS app contains runnable +FFmpeg/ffprobe 6.0 and has no dynamic libav link; its current `--enable-nonfree` +configuration remains a distribution-license blocker outside this boundary. +Receipt: `../runtime-artifacts/automated/cli-sidecar-boundary-real-device-2026-08-01.md`. + +Superseded later on 2026-08-01: Apple Silicon now uses archive-and-binary +checksum-pinned FFmpeg/ffprobe 7.0 GPL builds. The provisioner rejects +`--enable-nonfree` and unredistributable license output before replacement, and +the complete `opentake-media` suite passes against the replacement pair. See +`../runtime-artifacts/automated/ffmpeg-license-replacement-2026-08-01.md`. + ### Task 23: MR-motion-decoder-injection-complete (implementation-slice-ee5b0fb9f6f3c487) **Covered records:** @@ -1346,37 +1456,46 @@ - Visible/returned assertion: assert the returned project/error variant, exact post-operation files and decoded state, and save-then-reopen equality or the specified fail-closed no-write result. - Evidence required: after the deterministic test passes, record code:# and test:#; proposed concrete evidence is test:crates/opentake-project/tests/completion_c837d207c03a37cb.rs#completion_c837d207c03a37cb_inject_frame_decoding_into_motionclipsource_with. -- [ ] **Step 1: Write or extend every reviewed owning test** +- [x] **Step 1: Write or extend every reviewed owning test** - `crates/opentake-motion/src/integration.rs#decoded_frame_returns_rgba_of_right_shape` (existing-owned) — Exact named test already exists in the reviewed owning runner and records current boundary behavior. - `crates/opentake-motion/tests/pipeline.rs#full_pipeline_render_cache_and_ingest` (existing-owned) — Exact named test already exists in the reviewed owning runner and records current boundary behavior. Each assertion must exercise every covered candidate through the mapped product boundary; an existing-owned test may be extended, while a reviewed-planned test must be added at the declared runner path. -- [ ] **Step 2: Run all focused tests and verify RED** +- [x] **Step 2: Run all focused tests and verify RED** - Run: `cargo test -p opentake-motion decoded_frame_returns_rgba_of_right_shape` - Run: `cargo test -p opentake-motion --test pipeline full_pipeline_render_cache_and_ingest -- --exact` Expected: FAIL because one or more of the 2 candidate-bound contracts are not yet satisfied. -- [ ] **Step 3: Implement the minimal vertical slice** + Reconciliation note (2026-08-01): the closure-injection implementation and + both named owners predate this generated checklist, so the current baseline + is already GREEN; valid code was not reverted to manufacture RED. + +- [x] **Step 3: Implement the minimal vertical slice** Modify only `crates/opentake-motion/src/integration.rs#MotionClipSource::new`, `docs/modules/opentake-motion/OVERVIEW.md`, `docs/modules/opentake-motion/integration.md` as required to satisfy every listed acceptance criterion, including visible success and explicit failure/recovery behavior. -- [ ] **Step 4: Run all focused tests and verify GREEN** +- [x] **Step 4: Run all focused tests and verify GREEN** - Run: `cargo test -p opentake-motion decoded_frame_returns_rgba_of_right_shape` - Run: `cargo test -p opentake-motion --test pipeline full_pipeline_render_cache_and_ingest -- --exact` Expected: PASS with every candidate-bound assertion executed. -- [ ] **Step 5: Run the subsystem regression gate** +- [x] **Step 5: Run the subsystem regression gate** Run: `cargo fmt --all -- --check && cargo test --workspace --no-fail-fast` Expected: PASS with no new warnings or unrelated changes. +Verified 2026-08-01. Both exact owners, all 58 motion tests, formatting, and +warnings-denied motion Clippy pass. Production dependencies contain no image or +ffmpeg decoder; `image` remains test-only. This closes decoder injection only, +not the separately owned desktop motion/Lottie materialization. + ### Task 24: MR-native-chromium (implementation-slice-1687c4455b65f8a6) **Covered records:** @@ -1401,37 +1520,59 @@ - Enforce request interception allowlists, CSP, document limits, cancellation, timeout, deterministic clock, and no ambient filesystem/network access. - Integration tests render a fixed animation twice byte-identically and cover blocked network, timeout, crash, malformed source, and cancellation. -- [ ] **Step 1: Write or extend every reviewed owning test** +- [x] **Step 1: Write or extend every reviewed owning test** - `crates/opentake-motion/src/renderer.rs#chromium_skeleton_reports_unavailable_not_panic` (existing-owned) — Exact named test already exists in the reviewed owning runner and records current boundary behavior. - `crates/opentake-motion/tests/chromium.rs#virtual_time_network_csp_timeout_cleanup_and_frame_identity` (reviewed-planned) — Reviewed planned test belongs in this tracked owning runner beside the mapped product boundary. Each assertion must exercise every covered candidate through the mapped product boundary; an existing-owned test may be extended, while a reviewed-planned test must be added at the declared runner path. -- [ ] **Step 2: Run all focused tests and verify RED** +- [x] **Step 2: Run all focused tests and verify RED** - Run: `cargo test -p opentake-motion chromium_skeleton_reports_unavailable_not_panic` - Run: `cargo test -p opentake-motion --test chromium virtual_time_network_csp_timeout_cleanup_and_frame_identity -- --exact` Expected: FAIL because one or more of the 1 candidate-bound contracts are not yet satisfied. -- [ ] **Step 3: Implement the minimal vertical slice** + RED recorded 2026-08-01 with `--features chromium`: the planned test failed + to compile because cancellation, browser discovery/path injection, and the + live backend did not exist. The feature flag is required to exercise the live + owner; the unfeatured command intentionally verifies only fail-closed + `RendererUnavailable` behavior. + +- [x] **Step 3: Implement the minimal vertical slice** Modify only `crates/opentake-motion/src/renderer.rs#HeadlessChromiumRenderer::render`, `crates/opentake-motion/src/integration.rs#MotionClipSource`, `docs/modules/opentake-motion/OVERVIEW.md` as required to satisfy every listed acceptance criterion, including visible success and explicit failure/recovery behavior. -- [ ] **Step 4: Run all focused tests and verify GREEN** +- [x] **Step 4: Run all focused tests and verify GREEN** - Run: `cargo test -p opentake-motion chromium_skeleton_reports_unavailable_not_panic` - Run: `cargo test -p opentake-motion --test chromium virtual_time_network_csp_timeout_cleanup_and_frame_identity -- --exact` Expected: PASS with every candidate-bound assertion executed. -- [ ] **Step 5: Run the subsystem regression gate** + GREEN verified 2026-08-01 against Google Chrome 150.0.7871.187. The live + exact owner rendered a fixed animation twice byte-identically, advanced + visible virtual-time frames, decoded a real browser PNG through + `MotionClipSource`, allowed an exact loopback origin, blocked a disallowed + origin, fused a runaway script, handled process crash and malformed source, + cancelled an in-flight render, removed partial frames, and left no browser + profile. The existing owner passes both default and feature-enabled builds. + +- [x] **Step 5: Run the subsystem regression gate** Run: `cargo fmt --all -- --check && cargo test --workspace --no-fail-fast` Expected: PASS with no new warnings or unrelated changes. + Verified 2026-08-01. Formatting, default and feature-enabled warnings-denied + Clippy, all 59 feature-enabled motion tests, and the full workspace Rust suite + pass. The first workspace attempt stopped only because generated debug + artifacts filled the disk; after deleting `target/debug/deps` and + `target/debug/incremental` (release bundle preserved), the identical command + passed. Runtime receipt: + `runtime-artifacts/automated/headless-chromium-real-device-2026-08-01.md`. + ### Task 25: MR-motion-missing-frame-complete (implementation-slice-f16a70f238444e28) **Covered records:** @@ -1458,34 +1599,49 @@ - Visible/returned assertion: assert the exact success payload for the valid case and a stable typed error for the invalid case, including zero partial side effects and no leaked internal path or credential. - Evidence required: after the deterministic test passes, record code:# and test:#; proposed concrete evidence is test:crates/opentake-project/tests/completion_379e138c3e674f1b.rs#completion_379e138c3e674f1b_treat_a_missing_corrupt_decoded_motion_frame_as_. -- [ ] **Step 1: Write or extend every reviewed owning test** +- [x] **Step 1: Write or extend every reviewed owning test** - `crates/opentake-motion/src/integration.rs#missing_decoder_result_is_none` (existing-owned) — Exact named test already exists in the reviewed owning runner and records current boundary behavior. Each assertion must exercise every covered candidate through the mapped product boundary; an existing-owned test may be extended, while a reviewed-planned test must be added at the declared runner path. -- [ ] **Step 2: Run all focused tests and verify RED** +- [x] **Step 2: Run all focused tests and verify RED** - Run: `cargo test -p opentake-motion missing_decoder_result_is_none` Expected: FAIL because one or more of the 1 candidate-bound contracts are not yet satisfied. -- [ ] **Step 3: Implement the minimal vertical slice** + Reconciliation note (2026-08-01): `MotionClipSource::frame` already returned + the injected decoder's `None` unchanged, so the baseline owner was GREEN. + The owner was strengthened to use one valid PNG, one actually corrupted PNG, + and one actually deleted frame and still remained GREEN; valid code was not + reverted to manufacture RED. + +- [x] **Step 3: Implement the minimal vertical slice** Modify only `crates/opentake-motion/src/integration.rs#MotionClipSource::frame`, `docs/modules/opentake-motion/integration.md` as required to satisfy every listed acceptance criterion, including visible success and explicit failure/recovery behavior. -- [ ] **Step 4: Run all focused tests and verify GREEN** +- [x] **Step 4: Run all focused tests and verify GREEN** - Run: `cargo test -p opentake-motion missing_decoder_result_is_none` Expected: PASS with every candidate-bound assertion executed. -- [ ] **Step 5: Run the subsystem regression gate** + Verified 2026-08-01. The valid frame decodes at `6x4`; corrupt and missing + frames both return `None`; decoder calls do not recreate the missing file, + rewrite the corrupt bytes, or change the cache directory entry count. + +- [x] **Step 5: Run the subsystem regression gate** Run: `cargo fmt --all -- --check && cargo test --workspace --no-fail-fast` Expected: PASS with no new warnings or unrelated changes. + Verified 2026-08-01. Focused owner, warnings-denied motion Clippy, + formatting, and the full workspace Rust suite pass. Evidence: + code:`crates/opentake-motion/src/integration.rs#MotionClipSource::frame` and + test:`crates/opentake-motion/src/integration.rs#missing_decoder_result_is_none`. + ### Task 26: MR-motion-sandbox-complete (implementation-slice-70b5cbcce858ecde) **Covered records:** @@ -1546,37 +1702,51 @@ - Visible/returned assertion: assert the returned project/error variant, exact post-operation files and decoded state, and save-then-reopen equality or the specified fail-closed no-write result. - Evidence required: after the deterministic test passes, record code:# and test:#; proposed concrete evidence is test:crates/opentake-project/tests/completion_3a71829a7b489aae.rs#completion_3a71829a7b489aae_apply_sandbox_document_size_checks_before_both_s. -- [ ] **Step 1: Write or extend every reviewed owning test** +- [x] **Step 1: Write or extend every reviewed owning test** - `crates/opentake-motion/src/sandbox.rs#document_size_ceiling_enforced` (existing-owned) — Exact named test already exists in the reviewed owning runner and records current boundary behavior. - `crates/opentake-motion/src/renderer.rs#chromium_applies_sandbox_size_before_unavailable` (existing-owned) — Exact named test already exists in the reviewed owning runner and records current boundary behavior. Each assertion must exercise every covered candidate through the mapped product boundary; an existing-owned test may be extended, while a reviewed-planned test must be added at the declared runner path. -- [ ] **Step 2: Run all focused tests and verify RED** +- [x] **Step 2: Run all focused tests and verify RED** - Run: `cargo test -p opentake-motion document_size_ceiling_enforced` - Run: `cargo test -p opentake-motion chromium_applies_sandbox_size_before_unavailable` Expected: FAIL because one or more of the 3 candidate-bound contracts are not yet satisfied. -- [ ] **Step 3: Implement the minimal vertical slice** + Reconciliation note (2026-08-01): both checks predated this generated plan, + so the baseline owners were already GREEN. Owners were extended with exact + byte/UTF-8 boundaries, both renderer paths, both Chromium feature modes, and + zero cache-directory side effects; valid code was not reverted to manufacture + RED. + +- [x] **Step 3: Implement the minimal vertical slice** Modify only `crates/opentake-motion/src/renderer.rs#StubRenderer::render`, `crates/opentake-motion/src/renderer.rs#HeadlessChromiumRenderer::render`, `crates/opentake-motion/src/sandbox.rs#SandboxPolicy::check_document_size`, `docs/modules/opentake-motion/renderer.md`, `docs/modules/opentake-motion/sandbox.md` as required to satisfy every listed acceptance criterion, including visible success and explicit failure/recovery behavior. -- [ ] **Step 4: Run all focused tests and verify GREEN** +- [x] **Step 4: Run all focused tests and verify GREEN** - Run: `cargo test -p opentake-motion document_size_ceiling_enforced` - Run: `cargo test -p opentake-motion chromium_applies_sandbox_size_before_unavailable` Expected: PASS with every candidate-bound assertion executed. -- [ ] **Step 5: Run the subsystem regression gate** + Verified 2026-08-01. Equality at the byte ceiling passes; one byte over and a + multi-byte UTF-8 overflow fail. Stub, unfeatured Chromium, and feature-enabled + Chromium all return `Sandbox` before creating a content-hash directory. + +- [x] **Step 5: Run the subsystem regression gate** Run: `cargo fmt --all -- --check && cargo test --workspace --no-fail-fast` Expected: PASS with no new warnings or unrelated changes. + Verified 2026-08-01. Both focused owners in default mode, the Chromium owner + with the live feature, feature-enabled warnings-denied Clippy, formatting, and + the full workspace Rust suite pass. + ### Task 27: MR-motion-png-complete (implementation-slice-329d3a7fb3b066f7) **Covered records:** @@ -1604,37 +1774,52 @@ - Visible/returned assertion: assert the returned project/error variant, exact post-operation files and decoded state, and save-then-reopen equality or the specified fail-closed no-write result. - Evidence required: after the deterministic test passes, record code:# and test:#; proposed concrete evidence is test:crates/opentake-project/tests/completion_2071153f8053805d.rs#completion_2071153f8053805d_emit_deterministic_rgba_png_frames_from_the_stub. -- [ ] **Step 1: Write or extend every reviewed owning test** +- [x] **Step 1: Write or extend every reviewed owning test** - `crates/opentake-motion/src/renderer.rs#stub_output_is_deterministic` (existing-owned) — Exact named test already exists in the reviewed owning runner and records current boundary behavior. - `crates/opentake-motion/src/renderer.rs#stub_png_decodes_with_correct_dimensions_and_alpha` (existing-owned) — Exact named test already exists in the reviewed owning runner and records current boundary behavior. Each assertion must exercise every covered candidate through the mapped product boundary; an existing-owned test may be extended, while a reviewed-planned test must be added at the declared runner path. -- [ ] **Step 2: Run all focused tests and verify RED** +- [x] **Step 2: Run all focused tests and verify RED** - Run: `cargo test -p opentake-motion stub_output_is_deterministic` - Run: `cargo test -p opentake-motion stub_png_decodes_with_correct_dimensions_and_alpha` Expected: FAIL because one or more of the 1 candidate-bound contracts are not yet satisfied. -- [ ] **Step 3: Implement the minimal vertical slice** + Reconciliation note (2026-08-01): the dependency-free encoder and both + owners predated this generated plan, so baseline was already GREEN. Owners + were strengthened with PNG magic, exact RGBA, direct byte identity, and a + multi-block stored-deflate boundary; valid code was not reverted to + manufacture RED. + +- [x] **Step 3: Implement the minimal vertical slice** Modify only `crates/opentake-motion/src/renderer.rs#encode_solid_rgba_png`, `docs/modules/opentake-motion/renderer.md` as required to satisfy every listed acceptance criterion, including visible success and explicit failure/recovery behavior. -- [ ] **Step 4: Run all focused tests and verify GREEN** +- [x] **Step 4: Run all focused tests and verify GREEN** - Run: `cargo test -p opentake-motion stub_output_is_deterministic` - Run: `cargo test -p opentake-motion stub_png_decodes_with_correct_dimensions_and_alpha` Expected: PASS with every candidate-bound assertion executed. -- [ ] **Step 5: Run the subsystem regression gate** + Verified 2026-08-01. Separate cache roots emit byte-identical PNGs; the real + decoder confirms dimensions/alpha and exact corner RGBA for a `200x100` + image whose scanlines cross the 65,535-byte deflate block boundary. `image` + remains dev-only in `opentake-motion/Cargo.toml`. + +- [x] **Step 5: Run the subsystem regression gate** Run: `cargo fmt --all -- --check && cargo test --workspace --no-fail-fast` Expected: PASS with no new warnings or unrelated changes. + Verified 2026-08-01. Both focused owners, warnings-denied motion Clippy, + formatting, and the full workspace Rust suite pass. `image` is listed only + under `opentake-motion` dev-dependencies. + ### Task 28: MR-project-serde-complete (implementation-slice-9c460dd3f289f9bd) **Covered records:** @@ -1663,37 +1848,46 @@ - Visible/returned assertion: assert the returned project/error variant, exact post-operation files and decoded state, and save-then-reopen equality or the specified fail-closed no-write result. - Evidence required: after the deterministic test passes, record code:# and test:#; proposed concrete evidence is test:crates/opentake-agent/tests/completion_f2992815147bed14.rs#completion_f2992815147bed14_decode_persisted_project_domain_models_compatibl. -- [ ] **Step 1: Write or extend every reviewed owning test** +- [x] **Step 1: Write or extend every reviewed owning test** - `crates/opentake-project/tests/upstream_compat.rs#applies_clip_defaults_for_omitted_fields` (existing-owned) — Exact named test already exists in the reviewed owning runner and records current boundary behavior. - `crates/opentake-project/tests/upstream_compat.rs#migrates_generation_log_legacy_cost_and_version` (existing-owned) — Exact named test already exists in the reviewed owning runner and records current boundary behavior. Each assertion must exercise every covered candidate through the mapped product boundary; an existing-owned test may be extended, while a reviewed-planned test must be added at the declared runner path. -- [ ] **Step 2: Run all focused tests and verify RED** +- [x] **Step 2: Run all focused tests and reconcile the baseline state** - Run: `cargo test -p opentake-project --test upstream_compat applies_clip_defaults_for_omitted_fields -- --exact` - Run: `cargo test -p opentake-project --test upstream_compat migrates_generation_log_legacy_cost_and_version -- --exact` - Expected: FAIL because one or more of the 1 candidate-bound contracts are not yet satisfied. + Reconciled 2026-08-01: both strengthened owners remained GREEN before the + documentation-only implementation pass. The existing deserializers already + satisfied the candidate contract, so no artificial production regression + was introduced solely to manufacture RED. -- [ ] **Step 3: Implement the minimal vertical slice** +- [x] **Step 3: Implement the minimal vertical slice** Modify only `crates/opentake-project/src/bundle.rs#Project::open_from_root`, `crates/opentake-domain/src/media.rs#MediaManifest::deserialize`, `docs/modules/opentake-project/OVERVIEW.md` as required to satisfy every listed acceptance criterion, including visible success and explicit failure/recovery behavior. -- [ ] **Step 4: Run all focused tests and verify GREEN** +- [x] **Step 4: Run all focused tests and verify GREEN** - Run: `cargo test -p opentake-project --test upstream_compat applies_clip_defaults_for_omitted_fields -- --exact` - Run: `cargo test -p opentake-project --test upstream_compat migrates_generation_log_legacy_cost_and_version -- --exact` Expected: PASS with every candidate-bound assertion executed. -- [ ] **Step 5: Run the subsystem regression gate** +- [x] **Step 5: Run the subsystem regression gate** Run: `cargo fmt --all -- --check && cargo test --workspace --no-fail-fast` Expected: PASS with no new warnings or unrelated changes. + Verified 2026-08-01. Both focused owners pass with save/reopen equality, + explicit and absent manifest-version assertions, legacy generation-cost + migration, and stable synthesized IDs. Formatting, warnings-denied Clippy + for `opentake-project` and `opentake-domain`, and the full workspace Rust + suite pass. + ### Task 29: MR-mask-effect-mixed-duplicate (implementation-slice-35e4fa716888cc28) **Covered records:** @@ -1716,34 +1910,44 @@ - Implement an explicit registry/pass pipeline for every shipped Effect name; reject unsupported names at the command boundary rather than silently passing metadata. - GPU/pixel-diff tests cover polygon inside/outside/edge/feather/invert, multiple masks, effect order, disabled effects, preview/export parity, and headless skip semantics. -- [ ] **Step 1: Write or extend every reviewed owning test** +- [x] **Step 1: Write or extend every reviewed owning test** - `crates/opentake-render/tests/composite_acceptance.rs#mask_and_effect_records_have_separate_child_owners` (reviewed-planned) — Reviewed planned test belongs in this tracked owning runner beside the mapped product boundary. Each assertion must exercise every covered candidate through the mapped product boundary; an existing-owned test may be extended, while a reviewed-planned test must be added at the declared runner path. -- [ ] **Step 2: Run all focused tests and verify RED** +- [x] **Step 2: Run all focused tests and verify RED** - Run: `cargo test -p opentake-render --test composite_acceptance mask_and_effect_records_have_separate_child_owners -- --exact` Expected: FAIL because one or more of the 1 candidate-bound contracts are not yet satisfied. -- [ ] **Step 3: Implement the minimal vertical slice** +- [x] **Step 3: Implement the minimal vertical slice** Modify only `crates/opentake-render/src/gpu/compositor.rs`, `docs/modules/opentake-render/OVERVIEW.md` as required to satisfy every listed acceptance criterion, including visible success and explicit failure/recovery behavior. -- [ ] **Step 4: Run all focused tests and verify GREEN** +- [x] **Step 4: Run all focused tests and verify GREEN** - Run: `cargo test -p opentake-render --test composite_acceptance mask_and_effect_records_have_separate_child_owners -- --exact` Expected: PASS with every candidate-bound assertion executed. -- [ ] **Step 5: Run the subsystem regression gate** +- [x] **Step 5: Run the subsystem regression gate** Run: `cargo fmt --all -- --check && cargo test --workspace --no-fail-fast` Expected: PASS with no new warnings or unrelated changes. + Verified 2026-08-01. The parent owner first failed on the stale module claim + that polygon masks and generic effects were no-op metadata, then passed after + the documentation and defensive compositor contract were corrected. Both + real-GPU child owners pass, including all three mask shapes, hard/feathered + and inverted coverage, multiple-mask intersection, every registered effect, + disabled effects, ordered chains, and byte-identical preview/export output. + The parent also proves mask/point overflow and unknown effects fail before + mutation. Formatting, warnings-denied render Clippy, and the full workspace + Rust suite pass. + ### Task 30: MR-text-parity (implementation-slice-f92ff19f85ab4082) **Covered records:** @@ -1768,7 +1972,7 @@ - Add a pinned upstream comparison fixture for wrapping, fallback fonts, shadow padding, stroke width, size, and alignment across Chinese/Latin text. - Pass deterministic structural/pixel thresholds on macOS and the headless fallback while preserving non-crashing no-font behavior. -- [ ] **Step 1: Write or extend every reviewed owning test** +- [x] **Step 1: Write or extend every reviewed owning test** - `crates/opentake-render/tests/gpu_text.rs#rasterize_is_deterministic_ssim_one` (existing-owned) — Exact named test already exists in the reviewed owning runner and records current boundary behavior. - `crates/opentake-render/tests/gpu_text.rs#natural_size_shadow_padding_matches_upstream` (existing-owned) — Exact named test already exists in the reviewed owning runner and records current boundary behavior. @@ -1776,7 +1980,7 @@ Each assertion must exercise every covered candidate through the mapped product boundary; an existing-owned test may be extended, while a reviewed-planned test must be added at the declared runner path. -- [ ] **Step 2: Run all focused tests and verify RED** +- [x] **Step 2: Run all focused tests and verify RED** - Run: `cargo test -p opentake-render --test gpu_text rasterize_is_deterministic_ssim_one -- --exact` - Run: `cargo test -p opentake-render --test gpu_text natural_size_shadow_padding_matches_upstream -- --exact` @@ -1784,11 +1988,11 @@ Expected: FAIL because one or more of the 1 candidate-bound contracts are not yet satisfied. -- [ ] **Step 3: Implement the minimal vertical slice** +- [x] **Step 3: Implement the minimal vertical slice** Modify only `crates/opentake-render/src/gpu/text_engine.rs#CosmicTextRasterizer`, `crates/opentake-render/src/plan/types.rs#TextureSource::Text`, `docs/modules/opentake-render/text-rasterizer.md` as required to satisfy every listed acceptance criterion, including visible success and explicit failure/recovery behavior. -- [ ] **Step 4: Run all focused tests and verify GREEN** +- [x] **Step 4: Run all focused tests and verify GREEN** - Run: `cargo test -p opentake-render --test gpu_text rasterize_is_deterministic_ssim_one -- --exact` - Run: `cargo test -p opentake-render --test gpu_text natural_size_shadow_padding_matches_upstream -- --exact` @@ -1796,12 +2000,23 @@ Expected: PASS with every candidate-bound assertion executed. -- [ ] **Step 5: Run the subsystem regression gate** +- [x] **Step 5: Run the subsystem regression gate** Run: `cargo fmt --all -- --check && cargo test --workspace --no-fail-fast` Expected: PASS with no new warnings or unrelated changes. + Verified 2026-08-01. The new owner first failed to compile because the + explicit no-font constructor did not exist; after adding it, the real empty + font database exposed cosmic-text's `no default font found` panic. The + production rasterizer now branches before shaping and returns the correctly + sized transparent premultiplied frame while preserving background/border + rendering. The pinned structural matrix passes for missing-family fallback, + Chinese/Latin text, wrapping, alignment, 12px-per-side shadow padding, and + 1/2/4px border scaling at 540p/1080p/2160p. All eight text integration tests, + warnings-denied render Clippy, formatting, and the full workspace Rust suite + pass on macOS with real system fonts. + ### Task 31: MR-media-principles-headings (implementation-slice-726e8186554da9b6) **Covered records:** @@ -1843,34 +2058,48 @@ - Visible/returned assertion: assert the exact returned status plus deterministic frame/audio/container properties or typed unsupported/error output, and verify preview/export parity when both paths are named. - Evidence required: after the deterministic test passes, record code:# and test:#; proposed concrete evidence is test:crates/opentake-render/tests/completion_3336fb8bef6f3a49.rs#completion_3336fb8bef6f3a49_media_code_follows_cross_platform_frame_time_cac. -- [ ] **Step 1: Write or extend every reviewed owning test** +- [x] **Step 1: Write or extend every reviewed owning test** - `crates/opentake-render/tests/composite_acceptance.rs#media_principles_headings_reference_exact_child_capabilities` (reviewed-planned) — Reviewed planned test belongs in this tracked owning runner beside the mapped product boundary. Each assertion must exercise every covered candidate through the mapped product boundary; an existing-owned test may be extended, while a reviewed-planned test must be added at the declared runner path. -- [ ] **Step 2: Run all focused tests and verify RED** +- [x] **Step 2: Run all focused tests and verify RED** - Run: `cargo test -p opentake-render --test composite_acceptance media_principles_headings_reference_exact_child_capabilities -- --exact` Expected: FAIL because one or more of the 2 candidate-bound contracts are not yet satisfied. -- [ ] **Step 3: Implement the minimal vertical slice** +- [x] **Step 3: Implement the minimal vertical slice** Modify only `docs/specs/media/0-principles.md`, `docs/specs/media/9-domain-contract.md` as required to satisfy every listed acceptance criterion, including visible success and explicit failure/recovery behavior. -- [ ] **Step 4: Run all focused tests and verify GREEN** +- [x] **Step 4: Run all focused tests and verify GREEN** - Run: `cargo test -p opentake-render --test composite_acceptance media_principles_headings_reference_exact_child_capabilities -- --exact` Expected: PASS with every candidate-bound assertion executed. -- [ ] **Step 5: Run the subsystem regression gate** +- [x] **Step 5: Run the subsystem regression gate** Run: `node --test tools/completion-audit.test.mjs` Expected: PASS with no new warnings or unrelated changes. + Verified 2026-08-01. The aggregate owner first failed because neither + principle heading bound itself to executable children. Both documents now + reference the exact real-media probe, RGBA decode, 16 kHz PCM, waveform, + encode/reprobe, and export-pause owners; every child passes against local + generated fixtures. The compliance document now describes the actual FFmpeg + subprocess-sidecar architecture and explicitly retains the bundled + `--enable-nonfree` binary as a Beta release blocker. The focused aggregate, + formatting, and completion-audit Node gate pass. + + Superseded later on 2026-08-01: the Apple Silicon pair is now a pinned GPL + 7.0 build without `--enable-nonfree`, guarded by a fail-closed license check. + Native checks for other packaged targets and final package evidence remain + part of release readiness. + ### Task 32: MR-bounded-index-runtime (implementation-slice-603290a188109040) **Covered records:** @@ -1931,37 +2160,50 @@ - Deduplicate duplicate requests, persist completed state atomically, invalidate changed media/model, and resume interrupted work after restart. - Test duplicate enqueue, source change, model upgrade, export pause/resume, cancellation, crash/restart, failure retry, and final index/transcript equality. -- [ ] **Step 1: Write or extend every reviewed owning test** +- [x] **Step 1: Write or extend every reviewed owning test** - `crates/opentake-media/src/index_coordinator.rs#export_pause_ref_counts` (existing-owned) — Exact named test already exists in the reviewed owning runner and records current boundary behavior. - `src-tauri/src/search.rs#bounded_single_worker_cancels_skips_stale_and_yields_to_playback_export` (reviewed-planned) — Reviewed planned test belongs in this tracked owning runner beside the mapped product boundary. Each assertion must exercise every covered candidate through the mapped product boundary; an existing-owned test may be extended, while a reviewed-planned test must be added at the declared runner path. -- [ ] **Step 2: Run all focused tests and verify RED** +- [x] **Step 2: Run all focused tests and verify RED** - Run: `cargo test -p opentake-media export_pause_ref_counts` - Run: `cargo test -p opentake-tauri bounded_single_worker_cancels_skips_stale_and_yields_to_playback_export` Expected: FAIL because one or more of the 4 candidate-bound contracts are not yet satisfied. -- [ ] **Step 3: Implement the minimal vertical slice** +- [x] **Step 3: Implement the minimal vertical slice** Modify only `src-tauri/src/search.rs#search_index_start`, `src-tauri/src/search.rs#index_assets`, `crates/opentake-media/src/index_coordinator.rs#ExportPause`, `crates/opentake-media/src/ort_worker/mod.rs#OrtModel`, `docs/specs/media/10-acceptance.md`, `docs/specs/media/7-ort-worker.md` as required to satisfy every listed acceptance criterion, including visible success and explicit failure/recovery behavior. -- [ ] **Step 4: Run all focused tests and verify GREEN** +- [x] **Step 4: Run all focused tests and verify GREEN** - Run: `cargo test -p opentake-media export_pause_ref_counts` - Run: `cargo test -p opentake-tauri bounded_single_worker_cancels_skips_stale_and_yields_to_playback_export` Expected: PASS with every candidate-bound assertion executed. -- [ ] **Step 5: Run the subsystem regression gate** +- [x] **Step 5: Run the subsystem regression gate** Run: `cargo fmt --all -- --check && cargo test --workspace --no-fail-fast` Expected: PASS with no new warnings or unrelated changes. + Verified 2026-08-01. The owning tests first failed because the shared + pressure primitive had no balanced guard/wait protocol and no bounded worker + existed. Production `search_index_start` now submits one source/model-keyed + job to a process-wide capacity-8 `OrtWorker`; that job uses a lazy typed model + registry and serially performs missing visual and transcript work, checking + cancellation and playback/export pressure at every asset boundary. The + executor proves single-worker execution, live-key result dedupe, priority + FIFO, a four-interactive-job starvation bound, queue-full rejection, + queued/running cancellation, model-error and panic recovery, immediate failure + retry, balanced nested pressure wakeup, source/model invalidation, restart, + and clean shutdown with zero active jobs. Both focused owners, rustfmt, + all-feature clippy with `-D warnings`, and the full workspace suite pass. + ### Task 33: MR-packaged-ffmpeg (implementation-slice-ddfcf34d5292a998) **Covered records:** @@ -1986,34 +2228,55 @@ - Resolve packaged sidecar paths without relying on developer PATH. - Run installed-app probe/decode/encode smoke tests on macOS and Windows. -- [ ] **Step 1: Write or extend every reviewed owning test** +- [x] **Step 1: Write or extend every reviewed owning test** - `scripts/tests/packaged-sidecars-test.rb#packaged_macos_windows_sidecars_resolve_and_execute` (reviewed-planned) — Reviewed planned test belongs in this tracked owning runner beside the mapped product boundary. Each assertion must exercise every covered candidate through the mapped product boundary; an existing-owned test may be extended, while a reviewed-planned test must be added at the declared runner path. -- [ ] **Step 2: Run all focused tests and verify RED** +- [x] **Step 2: Run all focused tests and verify RED** - Run: `ruby scripts/tests/packaged-sidecars-test.rb --name "packaged_macos_windows_sidecars_resolve_and_execute"` Expected: FAIL because one or more of the 1 candidate-bound contracts are not yet satisfied. -- [ ] **Step 3: Implement the minimal vertical slice** +- [x] **Step 3: Implement the minimal vertical slice** Modify only `crates/opentake-media/src/ff.rs#ffmpeg_path`, `crates/opentake-media/src/ff.rs#ffprobe_path`, `src-tauri/tauri.conf.json`, `docs/specs/media/2-ffmpeg.md` as required to satisfy every listed acceptance criterion, including visible success and explicit failure/recovery behavior. -- [ ] **Step 4: Run all focused tests and verify GREEN** +- [x] **Step 4: Run all focused tests and verify GREEN** - Run: `ruby scripts/tests/packaged-sidecars-test.rb --name "packaged_macos_windows_sidecars_resolve_and_execute"` Expected: PASS with every candidate-bound assertion executed. -- [ ] **Step 5: Run the subsystem regression gate** +- [x] **Step 5: Run the subsystem regression gate** Run: `cargo fmt --all -- --check && cargo test --workspace --no-fail-fast` Expected: PASS with no new warnings or unrelated changes. + Reconciled 2026-08-01 from the tracked RED/GREEN receipt and reverified on + the current descendant. The original owner failed before the immutable + sidecar lock existed, then passed after checksum/version-pinned macOS arm64, + macOS x64, and Windows x64 supplies plus both platform `externalBin` configs + were added. Current macOS arm64 source binaries pass verify-only and the + empty-`PATH` probe/decode/encode smoke; the exact release `.app` sibling pair + passes the same installed-package owner and nested codesign verification. + GitHub Actions run `30614001607`, exact SHA + `9eeeb6ffe3088a16f19946ceb7db5e90090356ac`, remains a successful ancestor + receipt: its Windows full-product job built MSI/NSIS, silently installed NSIS, + and passed the installed-directory empty-`PATH` smoke. Current packaged-path + tests, Windows CI/config contracts, rustfmt, and the full workspace suite pass. + This closes sidecar resolution/execution only; the separately tracked + `--enable-nonfree` licensing replacement and Developer-ID/notarization gates + remain Beta-release blockers. + + Superseded later on 2026-08-01 for Apple Silicon: the licensing replacement + is complete and the media suite passes against the new pair. Developer-ID/ + notarization and native Windows/macOS Intel final-package evidence remain + separate release gates. + ### Task 34: MR-ffmpeg-contract-complete (implementation-slice-2edbd096c204bad4) **Covered records:** @@ -2091,7 +2354,7 @@ - Visible/returned assertion: assert the returned project/error variant, exact post-operation files and decoded state, and save-then-reopen equality or the specified fail-closed no-write result. - Evidence required: after the deterministic test passes, record code:# and test:#; proposed concrete evidence is test:crates/opentake-project/tests/completion_376576e91988107c.rs#completion_376576e91988107c_the_named_ffmpeg_probe_decode_pcm_encode_contrac. -- [ ] **Step 1: Write or extend every reviewed owning test** +- [x] **Step 1: Write or extend every reviewed owning test** - `crates/opentake-media/tests/ffmpeg_integration.rs#probe_reports_dimensions_fps_and_audio` (existing-owned) — Exact named test already exists in the reviewed owning runner and records current boundary behavior. - `crates/opentake-media/tests/ffmpeg_integration.rs#decode_frame_returns_rgba_of_expected_size` (existing-owned) — Exact named test already exists in the reviewed owning runner and records current boundary behavior. @@ -2100,7 +2363,7 @@ Each assertion must exercise every covered candidate through the mapped product boundary; an existing-owned test may be extended, while a reviewed-planned test must be added at the declared runner path. -- [ ] **Step 2: Run all focused tests and verify RED** +- [x] **Step 2: Run all focused tests and reconcile the historical baseline** - Run: `cargo test -p opentake-media --test ffmpeg_integration probe_reports_dimensions_fps_and_audio -- --exact` - Run: `cargo test -p opentake-media --test ffmpeg_integration decode_frame_returns_rgba_of_expected_size -- --exact` @@ -2109,11 +2372,11 @@ Expected: FAIL because one or more of the 4 candidate-bound contracts are not yet satisfied. -- [ ] **Step 3: Implement the minimal vertical slice** +- [x] **Step 3: Implement the minimal vertical slice** Modify only `crates/opentake-media/src/probe.rs#probe`, `crates/opentake-media/src/decode/frame.rs#decode_frame_at`, `crates/opentake-media/src/decode/pcm.rs#extract_pcm`, `crates/opentake-media/src/encode/mod.rs#VideoEncoder`, `docs/specs/media/2-ffmpeg.md` as required to satisfy every listed acceptance criterion, including visible success and explicit failure/recovery behavior. -- [ ] **Step 4: Run all focused tests and verify GREEN** +- [x] **Step 4: Run all focused tests and verify GREEN** - Run: `cargo test -p opentake-media --test ffmpeg_integration probe_reports_dimensions_fps_and_audio -- --exact` - Run: `cargo test -p opentake-media --test ffmpeg_integration decode_frame_returns_rgba_of_expected_size -- --exact` @@ -2122,12 +2385,23 @@ Expected: PASS with every candidate-bound assertion executed. -- [ ] **Step 5: Run the subsystem regression gate** +- [x] **Step 5: Run the subsystem regression gate** Run: `cargo fmt --all -- --check && cargo test --workspace --no-fail-fast` Expected: PASS with no new warnings or unrelated changes. + Reconciled 2026-08-01 as an already-integrated baseline. Git history shows + all four named owning fixtures and their production paths landed together in + `d9b2812`; its parent does not contain the tests, so there is no honest + test-present/implementation-missing RED command to replay. On the current + descendant, each exact owner independently passes against generated local + media: probe returns dimensions/FPS/audio, frame decode returns the expected + RGBA shape, PCM extraction returns 16 kHz mono, and encoded output re-probes + as playable video. The checksum-pinned packaged sidecars also pass the + empty-`PATH` installed-app smoke, and rustfmt plus the full workspace suite + pass. No production change was required for this reconciliation. + ### Task 35: MR-media-facade (implementation-slice-3adf63f547b1f57b) **Covered records:** @@ -2205,37 +2479,48 @@ - Visible/returned assertion: assert the returned project/error variant, exact post-operation files and decoded state, and save-then-reopen equality or the specified fail-closed no-write result. - Evidence required: after the deterministic test passes, record code:# and test:#; proposed concrete evidence is test:crates/opentake-project/tests/completion_94ba8c5254bc852d.rs#completion_94ba8c5254bc852d_the_media_facade_exposes_probe_decode_encode_sea. -- [ ] **Step 1: Write or extend every reviewed owning test** +- [x] **Step 1: Write or extend every reviewed owning test** - `crates/opentake-media/src/lib.rs#crate_public_types_are_reachable` (existing-owned) — Exact named test already exists in the reviewed owning runner and records current boundary behavior. - `crates/opentake-media/tests/facade_contract.rs#all_services_are_reachable_only_through_facade_and_dependencies_stay_acyclic` (reviewed-planned) — Reviewed planned test belongs in this tracked owning runner beside the mapped product boundary. Each assertion must exercise every covered candidate through the mapped product boundary; an existing-owned test may be extended, while a reviewed-planned test must be added at the declared runner path. -- [ ] **Step 2: Run all focused tests and verify RED** +- [x] **Step 2: Run all focused tests and verify RED** - Run: `cargo test -p opentake-media crate_public_types_are_reachable` - Run: `cargo test -p opentake-media --test facade_contract all_services_are_reachable_only_through_facade_and_dependencies_stay_acyclic -- --exact` Expected: FAIL because one or more of the 4 candidate-bound contracts are not yet satisfied. -- [ ] **Step 3: Implement the minimal vertical slice** +- [x] **Step 3: Implement the minimal vertical slice** Modify only `crates/opentake-media/src/lib.rs#MediaEngine`, `crates/opentake-media/src/probe.rs`, `crates/opentake-media/src/decode/mod.rs`, `crates/opentake-media/src/encode/mod.rs`, `crates/opentake-media/src/search/mod.rs`, `crates/opentake-media/src/transcribe/mod.rs`, `docs/specs/media/8-coordinator.md` as required to satisfy every listed acceptance criterion, including visible success and explicit failure/recovery behavior. -- [ ] **Step 4: Run all focused tests and verify GREEN** +- [x] **Step 4: Run all focused tests and verify GREEN** - Run: `cargo test -p opentake-media crate_public_types_are_reachable` - Run: `cargo test -p opentake-media --test facade_contract all_services_are_reachable_only_through_facade_and_dependencies_stay_acyclic -- --exact` Expected: PASS with every candidate-bound assertion executed. -- [ ] **Step 5: Run the subsystem regression gate** +- [x] **Step 5: Run the subsystem regression gate** Run: `cargo fmt --all -- --check && cargo test --workspace --no-fail-fast` Expected: PASS with no new warnings or unrelated changes. + Verified 2026-08-01. The planned facade contract first failed to compile + because `MediaEngine` had no decode, PCM extraction, encoder-construction, + or visual-ranking methods. The minimal implementation now owns those four + boundaries while retaining the existing probe, transcript, and spoken-search + services. The owning integration test generates a deterministic 32x18 A/V + fixture and passes probe -> frame decode -> 16 kHz mono PCM -> fixture + transcriber -> H.264 encode -> re-probe through `MediaEngine`; it also ranks + a persisted visual index and asserts the workspace dependency DAG remains + acyclic. Both exact owning tests pass, strict all-feature Clippy is clean, + rustfmt is clean, and the full workspace suite passes. + ### Task 36: MR-image-lottie-materialization (implementation-slice-a90703f04ca7e8c5) **Covered records:** @@ -2260,34 +2545,46 @@ - Define cache invalidation and device-loss behavior. - Add pixel, lifecycle, and export tests. -- [ ] **Step 1: Write or extend every reviewed owning test** +- [x] **Step 1: Write or extend every reviewed owning test** - `src-tauri/src/playback/resolver.rs#lottie_cache_lifecycle_frame_modulo_and_preview_export_parity` (reviewed-planned) — Reviewed planned test belongs in this tracked owning runner beside the mapped product boundary. Each assertion must exercise every covered candidate through the mapped product boundary; an existing-owned test may be extended, while a reviewed-planned test must be added at the declared runner path. -- [ ] **Step 2: Run all focused tests and verify RED** +- [x] **Step 2: Run all focused tests and verify RED** - Run: `cargo test -p opentake-tauri lottie_cache_lifecycle_frame_modulo_and_preview_export_parity` Expected: FAIL because one or more of the 1 candidate-bound contracts are not yet satisfied. -- [ ] **Step 3: Implement the minimal vertical slice** +- [x] **Step 3: Implement the minimal vertical slice** Modify only `src-tauri/src/render.rs#MediaResolver::resolve`, `src-tauri/src/export.rs#MediaResolver::resolve`, `src-tauri/src/playback/resolver.rs#StreamingResolver::resolve`, `docs/specs/media/8-coordinator.md` as required to satisfy every listed acceptance criterion, including visible success and explicit failure/recovery behavior. -- [ ] **Step 4: Run all focused tests and verify GREEN** +- [x] **Step 4: Run all focused tests and verify GREEN** - Run: `cargo test -p opentake-tauri lottie_cache_lifecycle_frame_modulo_and_preview_export_parity` Expected: PASS with every candidate-bound assertion executed. -- [ ] **Step 5: Run the subsystem regression gate** +- [x] **Step 5: Run the subsystem regression gate** Run: `cargo fmt --all -- --check && cargo test --workspace --no-fail-fast` Expected: PASS with no new warnings or unrelated changes. +**Verified 2026-08-01:** RED was reproduced because the declared +`LottieMaterializer` production boundary did not exist and all three product +resolvers skipped Lottie. The shared Velato/Vello materializer now renders +bounded, content-hashed textures on the existing wgpu device; preview, +dedicated playback, and export own caches at their documented lifetimes and +surface unsupported/invalid documents as explicit failures. The owning GPU +pixel test passes and proves frame modulo, content invalidation, preview/export +byte parity, and device-context rebuild behavior. `cargo fmt --all -- --check`, +`cargo clippy -p opentake-tauri --all-targets --all-features -- -D warnings`, +the focused owning test, and +`CARGO_INCREMENTAL=0 cargo test --workspace --no-fail-fast --quiet` all pass. + ### Task 37: MR-interchange-export-complete (implementation-slice-efcc28e98cc9b40e) **Covered records:** @@ -2322,7 +2619,7 @@ - Visible/returned assertion: assert the exact returned status plus deterministic frame/audio/container properties or typed unsupported/error output, and verify preview/export parity when both paths are named. - Evidence required: after the deterministic test passes, record code:# and test:#; proposed concrete evidence is test:crates/opentake-project/tests/completion_9ae7ed1acecc8998.rs#completion_9ae7ed1acecc8998_projects_export_interoperable_fcpxml_otio_and_ed. -- [ ] **Step 1: Write or extend every reviewed owning test** +- [x] **Step 1: Write or extend every reviewed owning test** - `web/src/components/shell/TitleBar.visual.test.ts#offers all four interchange formats with their extensions and commands` (existing-owned) — Exact named test already exists in the reviewed owning runner and records current boundary behavior. - `crates/opentake-project/src/fcpxml_modern_tests.rs#document_has_fcpxml_header_and_version` (existing-owned) — Exact named test already exists in the reviewed owning runner and records current boundary behavior. @@ -2331,7 +2628,7 @@ Each assertion must exercise every covered candidate through the mapped product boundary; an existing-owned test may be extended, while a reviewed-planned test must be added at the declared runner path. -- [ ] **Step 2: Run all focused tests and verify RED** +- [x] **Step 2: Run all focused tests and reconcile the historical baseline** - Run: `pnpm -C web test -- --run src/components/shell/TitleBar.visual.test.ts -t "offers all four interchange formats with their extensions and commands"` - Run: `cargo test -p opentake-project document_has_fcpxml_header_and_version` @@ -2340,11 +2637,11 @@ Expected: FAIL because one or more of the 1 candidate-bound contracts are not yet satisfied. -- [ ] **Step 3: Implement the minimal vertical slice** +- [x] **Step 3: Implement the minimal vertical slice** Modify only `crates/opentake-project/src/fcpxml.rs#export_xmeml`, `crates/opentake-project/src/fcpxml_modern.rs#export_fcpxml`, `crates/opentake-project/src/otio.rs#export_otio`, `crates/opentake-project/src/edl.rs#export_edl`, `src-tauri/src/commands.rs`, `web/src/components/shell/TitleBar.tsx#TitleBar`, `docs/需求与问题汇总.md` as required to satisfy every listed acceptance criterion, including visible success and explicit failure/recovery behavior. -- [ ] **Step 4: Run all focused tests and verify GREEN** +- [x] **Step 4: Run all focused tests and verify GREEN** - Run: `pnpm -C web test -- --run src/components/shell/TitleBar.visual.test.ts -t "offers all four interchange formats with their extensions and commands"` - Run: `cargo test -p opentake-project document_has_fcpxml_header_and_version` @@ -2353,12 +2650,26 @@ Expected: PASS with every candidate-bound assertion executed. -- [ ] **Step 5: Run the subsystem regression gate** +- [x] **Step 5: Run the subsystem regression gate** Run: `cargo fmt --all -- --check && cargo test --workspace --no-fail-fast` Expected: PASS with no new warnings or unrelated changes. + Reconciled 2026-08-01 as an already-integrated baseline. Git history shows + the EDL, OTIO and modern FCPXML writers and their format tests landed together + in `5713d22`, followed by the four-format title-bar route in `3d09eb6`; there + is therefore no honest test-present/implementation-missing RED command to + replay. The four declared owners pass on the current descendant, the exact + title-bar interaction suite covers success, failure, cancellation, extension + completion and all four Tauri routes, and the full workspace suite passes. + Existing packaged-app evidence additionally parsed both XML outputs with + `xmllint`, parsed OTIO with `jq`, verified the corrected three-event EDL, and + proved deterministic re-export. The historical requirement source now records + the shipped formats and their explicit degradation boundary; no production + code change was required for this reconciliation. Runtime evidence: + [`interchange-export-real-device-2026-07-30.md`](../runtime-artifacts/automated/interchange-export-real-device-2026-07-30.md). + ### Task 38: control-acceptance (implementation-slice-5148c914ccdac250) **Covered records:** @@ -2449,7 +2760,7 @@ - Visible/accessibility/return path: success=choose export resolution: setQuality controls export preset; accessibility={"focus":"Custom Dropdown focus behavior depends on its implementation","label":"Visible child text/title or caller-provided label; verify at runtime","shortcut":"None declared on this control"}; returnPath=["Success/cancel closes to the editor; failure keeps the dialog open. Trigger focus restoration is not implemented/tested."]. - Outcome matrix: {"success":"choose export resolution: setQuality controls export preset","pending":"Not applicable — this activation only changes local/caller state and starts no Promise, API, Tauri command, or Rust work.","empty":"Required empty/no-selection behavior is the render/handler guard in web/src/components/shell/ExportDialog.tsx:462; the candidate-specific interaction test must assert that exact guard.","disabled":"No explicit disabled prop on this candidate.","cancel":"Cancellation/dismissal follows the exact guard in setQuality controls export preset; no broader cancellation behavior is assumed.","retry":"Not applicable as an error-recovery state — the synchronous local action can simply be activated again.","failure":"Not applicable — this immediate activation calls no API/Tauri/Rust boundary, so there is no backend rejection path."}. -- [ ] **Step 1: Write or extend every reviewed owning test** +- [x] **Step 1: Write or extend every reviewed owning test** - `web/src/components/shell/ExportDialog.interaction.test.tsx#control-580ab884755388a9 dismiss Export by clicking the backdrop` (reviewed-planned) — The control acceptance contract explicitly names this owning component test runner. - `web/src/components/shell/ExportDialog.interaction.test.tsx#control-6064916ed05a1362 close Export from its header` (reviewed-planned) — The control acceptance contract explicitly names this owning component test runner. @@ -2459,7 +2770,7 @@ Each assertion must exercise every covered candidate through the mapped product boundary; an existing-owned test may be extended, while a reviewed-planned test must be added at the declared runner path. -- [ ] **Step 2: Run all focused tests and verify RED** +- [x] **Step 2: Run all focused tests and verify RED** - Run: `pnpm -C web test -- --run src/components/shell/ExportDialog.interaction.test.tsx -t "control-580ab884755388a9 dismiss Export by clicking the backdrop"` - Run: `pnpm -C web test -- --run src/components/shell/ExportDialog.interaction.test.tsx -t "control-6064916ed05a1362 close Export from its header"` @@ -2469,11 +2780,11 @@ Expected: FAIL because one or more of the 5 candidate-bound contracts are not yet satisfied. -- [ ] **Step 3: Implement the minimal vertical slice** +- [x] **Step 3: Implement the minimal vertical slice** Modify only `web/src/components/shell/ExportDialog.tsx`, `web/src/components/shell/ExportDialog.tsx#ExportDialog` as required to satisfy every listed acceptance criterion, including visible success and explicit failure/recovery behavior. -- [ ] **Step 4: Run all focused tests and verify GREEN** +- [x] **Step 4: Run all focused tests and verify GREEN** - Run: `pnpm -C web test -- --run src/components/shell/ExportDialog.interaction.test.tsx -t "control-580ab884755388a9 dismiss Export by clicking the backdrop"` - Run: `pnpm -C web test -- --run src/components/shell/ExportDialog.interaction.test.tsx -t "control-6064916ed05a1362 close Export from its header"` @@ -2483,12 +2794,22 @@ Expected: PASS with every candidate-bound assertion executed. -- [ ] **Step 5: Run the subsystem regression gate** +- [x] **Step 5: Run the subsystem regression gate** Run: `pnpm -C web test -- --run && pnpm -C web build` Expected: PASS with no new warnings or unrelated changes. + Verified 2026-08-01. RED was reproduced because the five declared DOM + owning tests did not exist. The new interaction runner exercises idle and + busy backdrop behavior, the header close guard, stale-error cleanup through + mode selection, codec-to-container routing, and resolution-to-request + routing against the real Zustand component state. All five focused tests + pass; the full Web suite passes 94 files / 829 tests, and the TypeScript plus + production Vite build passes with only the pre-existing chunk/dynamic-import + advisories. The production component already met the declared behavior, so + no component change was required. + ### Task 39: control-acceptance (implementation-slice-c6fbd815566d6b64) **Covered records:** @@ -2518,34 +2839,44 @@ - Visible/accessibility/return path: success=cancel/close Export: idle closes; active video calls cancelExport(operationId); accessibility={"focus":"Native keyboard-focusable control","label":"Visible child text/title or caller-provided label; verify at runtime","shortcut":"None declared on this control"}; returnPath=["Success/cancel closes to the editor; failure keeps the dialog open. Trigger focus restoration is not implemented/tested."]. - Outcome matrix: {"success":"cancel/close Export: idle closes; active video calls cancelExport(operationId)","pending":"Pending behavior is the concrete busy/phase/disabled state in web/src/components/shell/ExportDialog.tsx:569; no additional state is inferred beyond the source.","empty":"Required empty/no-selection behavior is the render/handler guard in web/src/components/shell/ExportDialog.tsx:569; the candidate-specific interaction test must assert that exact guard.","disabled":"No explicit disabled prop on this candidate.","cancel":"Cancellation/dismissal follows the exact guard in idle closes; active video calls cancelExport(operationId); no broader cancellation behavior is assumed.","retry":"Retry is another activation after the source-defined pending guard clears; no separate retry command exists unless named in idle closes; active video calls cancelExport(operationId).","failure":"Failure behavior is limited to the catch/void-call behavior visible in web/src/components/shell/ExportDialog.tsx:569; the missing DOM test must prove whether it is surfaced or silent."}. -- [ ] **Step 1: Write or extend every reviewed owning test** +- [x] **Step 1: Write or extend every reviewed owning test** - `web/src/components/shell/ExportDialog.interaction.test.tsx#control-af586bdec82ebcc7 cancel/close Export` (reviewed-planned) — The control acceptance contract explicitly names this owning component test runner. Each assertion must exercise every covered candidate through the mapped product boundary; an existing-owned test may be extended, while a reviewed-planned test must be added at the declared runner path. -- [ ] **Step 2: Run all focused tests and verify RED** +- [x] **Step 2: Run all focused tests and verify RED** - Run: `pnpm -C web test -- --run src/components/shell/ExportDialog.interaction.test.tsx -t "control-af586bdec82ebcc7 cancel/close Export"` Expected: FAIL because one or more of the 1 candidate-bound contracts are not yet satisfied. -- [ ] **Step 3: Implement the minimal vertical slice** +- [x] **Step 3: Implement the minimal vertical slice** Modify only `web/src/components/shell/ExportDialog.tsx`, `web/src/components/shell/ExportDialog.tsx#onCancel`, `web/src/lib/api.ts#cancelExport`, `src-tauri/src/export.rs#cancel_export`, `web/src/components/shell/ExportDialog.tsx#ExportDialog` as required to satisfy every listed acceptance criterion, including visible success and explicit failure/recovery behavior. -- [ ] **Step 4: Run all focused tests and verify GREEN** +- [x] **Step 4: Run all focused tests and verify GREEN** - Run: `pnpm -C web test -- --run src/components/shell/ExportDialog.interaction.test.tsx -t "control-af586bdec82ebcc7 cancel/close Export"` Expected: PASS with every candidate-bound assertion executed. -- [ ] **Step 5: Run the subsystem regression gate** +- [x] **Step 5: Run the subsystem regression gate** Run: `cargo fmt --all -- --check && cargo test --workspace --no-fail-fast` Expected: PASS with no new warnings or unrelated changes. + Verified 2026-08-01. The declared owner was absent, and its first execution + exposed an unhandled rejection when `cancel_export` failed: the dialog showed + neither the backend error nor failure feedback. `onCancel` now retains the + active dialog, renders the concrete rejection, and pushes the standard export + failure toast while preserving idle close and generation-safe cancellation + with the exact active operation id. The focused test passes, the full Web + suite passes 94 files / 830 tests, the production Web build passes with only + the pre-existing chunk/dynamic-import advisories, rustfmt is clean, and the + full Rust workspace suite passes. + ### Task 40: control-acceptance (implementation-slice-73d069e581678e52) **Covered records:** @@ -2577,34 +2908,45 @@ - Visible/accessibility/return path: success=start video export: save dialog -> busy/progress -> success/cancel/failure -> cleanup; accessibility={"focus":"Native keyboard-focusable control","label":"Visible child text/title or caller-provided label; verify at runtime","shortcut":"None declared on this control"}; returnPath=["Success/cancel closes to the editor; failure keeps the dialog open. Trigger focus restoration is not implemented/tested."]. - Outcome matrix: {"success":"start video export: save dialog -> busy/progress -> success/cancel/failure -> cleanup","pending":"Pending behavior is the concrete busy/phase/disabled state in web/src/components/shell/ExportDialog.tsx:587; no additional state is inferred beyond the source.","empty":"Required empty/no-selection behavior is the render/handler guard in web/src/components/shell/ExportDialog.tsx:587; the candidate-specific interaction test must assert that exact guard.","disabled":"Disabled when {busy}.","cancel":"Cancellation/dismissal follows the exact guard in save dialog -> busy/progress -> success/cancel/failure -> cleanup; no broader cancellation behavior is assumed.","retry":"Retry is another activation after the source-defined pending guard clears; no separate retry command exists unless named in save dialog -> busy/progress -> success/cancel/failure -> cleanup.","failure":"Failure behavior is limited to the catch/void-call behavior visible in web/src/components/shell/ExportDialog.tsx:587; the missing DOM test must prove whether it is surfaced or silent."}. -- [ ] **Step 1: Write or extend every reviewed owning test** +- [x] **Step 1: Write or extend every reviewed owning test** - `web/src/components/shell/ExportDialog.interaction.test.tsx#control-543cacc54290eeba start video export` (reviewed-planned) — The control acceptance contract explicitly names this owning component test runner. Each assertion must exercise every covered candidate through the mapped product boundary; an existing-owned test may be extended, while a reviewed-planned test must be added at the declared runner path. -- [ ] **Step 2: Run all focused tests and verify RED** +- [x] **Step 2: Run all focused tests and verify RED** - Run: `pnpm -C web test -- --run src/components/shell/ExportDialog.interaction.test.tsx -t "control-543cacc54290eeba start video export"` Expected: FAIL because one or more of the 1 candidate-bound contracts are not yet satisfied. -- [ ] **Step 3: Implement the minimal vertical slice** +- [x] **Step 3: Implement the minimal vertical slice** Modify only `web/src/components/shell/ExportDialog.tsx`, `web/src/components/shell/ExportDialog.tsx#onExport`, `web/src/lib/api.ts#getDefaultProjectDir`, `src-tauri/src/commands.rs`, `web/src/lib/api.ts#exportVideo`, `src-tauri/src/export.rs#export_video`, `web/src/components/shell/ExportDialog.tsx#ExportDialog` as required to satisfy every listed acceptance criterion, including visible success and explicit failure/recovery behavior. -- [ ] **Step 4: Run all focused tests and verify GREEN** +- [x] **Step 4: Run all focused tests and verify GREEN** - Run: `pnpm -C web test -- --run src/components/shell/ExportDialog.interaction.test.tsx -t "control-543cacc54290eeba start video export"` Expected: PASS with every candidate-bound assertion executed. -- [ ] **Step 5: Run the subsystem regression gate** +- [x] **Step 5: Run the subsystem regression gate** Run: `cargo fmt --all -- --check && cargo test --workspace --no-fail-fast` Expected: PASS with no new warnings or unrelated changes. + Verified 2026-08-01. The declared transaction owner was absent. The new DOM + test drives the real component through native-save cancellation, unsaved + project default-directory lookup, exact extension/request dispatch, scoped + progress delivery, success, backend cancellation, failure, retry readiness, + and listener cleanup. It passes together with all seven ExportDialog owners; + the full Web suite passes 94 files / 831 tests, the production Web build + passes with only the pre-existing chunk/dynamic-import advisories, and the + unchanged Rust boundary remains green under the full workspace gate run for + the immediately preceding cancellation slice. No production change was + required for this slice. + ### Task 41: control-acceptance (implementation-slice-010eea5507e6b9ca) **Covered records:** @@ -2634,34 +2976,44 @@ - Visible/accessibility/return path: success=cancel Save Clip as Media: when current progress is cancellable and not cancelling, set cancelling=true and call cancelExport(current.operationId); rejection restores cancelling=false and pushes a failure toast; accessibility={"focus":"Native keyboard-focusable control","label":"Visible child text/title or caller-provided label; verify at runtime","shortcut":"None declared on this control"}; returnPath=["The non-modal status stays in the editor and disappears when store progress clears."]. - Outcome matrix: {"success":"cancel Save Clip as Media: when current progress is cancellable and not cancelling, set cancelling=true and call cancelExport(current.operationId); rejection restores cancelling=false and pushes a failure toast","pending":"Pending behavior is the concrete busy/phase/disabled state in web/src/components/shell/SaveAsProgress.tsx:42; no additional state is inferred beyond the source.","empty":"Required empty/no-selection behavior is the render/handler guard in web/src/components/shell/SaveAsProgress.tsx:42; the candidate-specific interaction test must assert that exact guard.","disabled":"Disabled when {!progress.cancellable || progress.cancelling}.","cancel":"Cancellation/dismissal follows the exact guard in when current progress is cancellable and not cancelling, set cancelling=true and call cancelExport(current.operationId); rejection restores cancelling=false and pushes a failure toast; no broader cancellation behavior is assumed.","retry":"Retry is another activation after the source-defined pending guard clears; no separate retry command exists unless named in when current progress is cancellable and not cancelling, set cancelling=true and call cancelExport(current.operationId); rejection restores cancelling=false and pushes a failure toast.","failure":"Failure behavior is limited to the catch/void-call behavior visible in web/src/components/shell/SaveAsProgress.tsx:42; the missing DOM test must prove whether it is surfaced or silent."}. -- [ ] **Step 1: Write or extend every reviewed owning test** +- [x] **Step 1: Write or extend every reviewed owning test** - `web/src/components/shell/SaveAsProgress.interaction.test.tsx#control-b0b920085e77d039 cancel Save Clip as Media` (reviewed-planned) — The control acceptance contract explicitly names this owning component test runner. Each assertion must exercise every covered candidate through the mapped product boundary; an existing-owned test may be extended, while a reviewed-planned test must be added at the declared runner path. -- [ ] **Step 2: Run all focused tests and verify RED** +- [x] **Step 2: Run all focused tests and verify RED** - Run: `pnpm -C web test -- --run src/components/shell/SaveAsProgress.interaction.test.tsx -t "control-b0b920085e77d039 cancel Save Clip as Media"` Expected: FAIL because one or more of the 1 candidate-bound contracts are not yet satisfied. -- [ ] **Step 3: Implement the minimal vertical slice** +- [x] **Step 3: Implement the minimal vertical slice** Modify only `web/src/components/shell/SaveAsProgress.tsx`, `web/src/store/editActions.ts#cancelSaveAsMedia`, `web/src/lib/api.ts#cancelExport`, `src-tauri/src/export.rs#cancel_export`, `web/src/components/shell/SaveAsProgress.tsx#SaveAsProgress` as required to satisfy every listed acceptance criterion, including visible success and explicit failure/recovery behavior. -- [ ] **Step 4: Run all focused tests and verify GREEN** +- [x] **Step 4: Run all focused tests and verify GREEN** - Run: `pnpm -C web test -- --run src/components/shell/SaveAsProgress.interaction.test.tsx -t "control-b0b920085e77d039 cancel Save Clip as Media"` Expected: PASS with every candidate-bound assertion executed. -- [ ] **Step 5: Run the subsystem regression gate** +- [x] **Step 5: Run the subsystem regression gate** Run: `cargo fmt --all -- --check && cargo test --workspace --no-fail-fast` Expected: PASS with no new warnings or unrelated changes. + Verified 2026-08-01. RED was reproduced because the declared DOM owner did + not exist. The new real-store interaction test proves visible progress, + disabled preparation state, exact operation-id cancellation, immediate + cancelling/duplicate-click protection, and rejection recovery with the + concrete failure toast. The focused owner passes; the full Web suite passes + 95 files / 832 tests, and the production Web build passes with only the + pre-existing chunk/dynamic-import advisories. The Rust cancellation boundary + is unchanged and passed the full workspace gate in the immediately preceding + cancellation slice. No production change was required for this slice. + ## Shared capability references - `event-forwarding` / `implementation-slice-c35c845cbc3492dd`: implemented once in `command-contracts`; this group contributes records `requirement-c3edeb41fee8e2a1`, `requirement-2c6c54de3cd8a488`, `requirement-30dda70b4f22a7d2` as acceptance references. diff --git a/docs/audit/2026-07-14/implementation-plans/preview-timeline-implementation.md b/docs/audit/2026-07-14/implementation-plans/preview-timeline-implementation.md index 3989e465..2dbc04ea 100644 --- a/docs/audit/2026-07-14/implementation-plans/preview-timeline-implementation.md +++ b/docs/audit/2026-07-14/implementation-plans/preview-timeline-implementation.md @@ -85,37 +85,44 @@ - Create, open, trim, move, duplicate, and dissolve a two-level nested compound through undoable shared commands and the timeline UI. - Prove save/reopen equality plus preview/export frame parity at the compound in/out boundaries and fail clearly on recursive nesting cycles. -- [ ] **Step 1: Write or extend every reviewed owning test** +- [x] **Step 1: Write or extend every reviewed owning test** - `crates/opentake-project/tests/compound_roundtrip.rs#compound_clip_roundtrips_nested_timeline` (reviewed-planned) — Reviewed planned test belongs in this tracked owning runner beside the mapped product boundary. - `crates/opentake-render/tests/compound_render.rs#compound_clip_preview_export_frames_match` (reviewed-planned) — Reviewed planned test belongs in this tracked owning runner beside the mapped product boundary. Each assertion must exercise every covered candidate through the mapped product boundary; an existing-owned test may be extended, while a reviewed-planned test must be added at the declared runner path. -- [ ] **Step 2: Run all focused tests and verify RED** +- [x] **Step 2: Run all focused tests and verify RED** - Run: `cargo test -p opentake-project --test compound_roundtrip compound_clip_roundtrips_nested_timeline -- --exact` - Run: `cargo test -p opentake-render --test compound_render compound_clip_preview_export_frames_match -- --exact` Expected: FAIL because one or more of the 1 candidate-bound contracts are not yet satisfied. -- [ ] **Step 3: Implement the minimal vertical slice** +- [x] **Step 3: Implement the minimal vertical slice** Modify only `crates/opentake-domain/src/clip.rs#CompoundClip`, `crates/opentake-render/src/plan/types.rs#RenderClip::Compound`, `docs/architecture/CAPCUT-GAP.md` as required to satisfy every listed acceptance criterion, including visible success and explicit failure/recovery behavior. -- [ ] **Step 4: Run all focused tests and verify GREEN** +- [x] **Step 4: Run all focused tests and verify GREEN** - Run: `cargo test -p opentake-project --test compound_roundtrip compound_clip_roundtrips_nested_timeline -- --exact` - Run: `cargo test -p opentake-render --test compound_render compound_clip_preview_export_frames_match -- --exact` Expected: PASS with every candidate-bound assertion executed. -- [ ] **Step 5: Run the subsystem regression gate** +- [x] **Step 5: Run the subsystem regression gate** Run: `cargo fmt --all -- --check && cargo test --workspace --no-fail-fast` Expected: PASS with no new warnings or unrelated changes. +Completion evidence (2026-07-31): +`runtime-artifacts/automated/nested-timeline-compound-real-device-2026-07-31.md`. +The exact owning tests, full Rust/Web gates, packaged create/open/trim/move/copy +and paste flow, save/reopen persistence, paused/continuous preview, and retained +export artifacts all pass; deterministic graph validation covers recursive +cycle rejection. + ### Task 3: multicam (implementation-slice-5902337034fd8e89) **Covered records:** diff --git a/docs/audit/2026-07-14/repository-files.json b/docs/audit/2026-07-14/repository-files.json index 5209f29e..f3eca6cd 100644 --- a/docs/audit/2026-07-14/repository-files.json +++ b/docs/audit/2026-07-14/repository-files.json @@ -43,8 +43,8 @@ "domain": "ci", "kind": "yml", "material": true, - "bytes": 5804, - "sha256": "3fc3f217490d1e7256537c0e543f7effad2f1067a0eca1763e979fe64086d7e1" + "bytes": 24661, + "sha256": "ec2be395bd82f1532c8397885e54eb9d4c1835ab88f575fd0ce81cb00a80a7d0" }, { "id": "file-bc37d034bad56458", @@ -52,8 +52,26 @@ "domain": "repository", "kind": "configuration", "material": true, - "bytes": 279, - "sha256": "b93f80050368a0d8158210f81acfc92e892ad27de6d8e4361f6fc378f061bf86" + "bytes": 336, + "sha256": "0396ee111027cef07b6544b7038d8e017b3f605201f0348b8e5ecede1ccaf811" + }, + { + "id": "file-794550be5bbee1a7", + "path": ".superpowers/sdd/task-11-red-replay.md", + "domain": "repository", + "kind": "markdown", + "material": true, + "bytes": 6328, + "sha256": "966a7fd7550b829bc081745922c0146e5cedf18d550535375a0af853b05e85f5" + }, + { + "id": "file-4a547f596950c48e", + "path": ".superpowers/sdd/task-11-report.md", + "domain": "repository", + "kind": "markdown", + "material": true, + "bytes": 9631, + "sha256": "edada7a16aed1d29348ed666a0979cde89dd056a1729033715b6a88cffaccfa9" }, { "id": "file-a7634fad0877aa7d", @@ -115,8 +133,8 @@ "domain": "repository", "kind": "markdown", "material": true, - "bytes": 14438, - "sha256": "25bcc6200e6dcbea9a708eea66b36927a16a6805fb83b642ab0abf1c9c26d853" + "bytes": 14507, + "sha256": "30e765da63167c9f1c4130d2e017a2f2d47d36dd30f65ddcd4e7731de7c02629" }, { "id": "file-eca12c0a30e25b4b", @@ -133,8 +151,8 @@ "domain": "repository", "kind": "lock", "material": true, - "bytes": 179112, - "sha256": "eb11d1de4293c81bf1b56326a04c65b651f97c88621bda0d4a74cfc1d5cd4166" + "bytes": 180091, + "sha256": "23d08e64dfd5b07aec0f498d8a377ef6b9a87ee6f122bb1d424b0498ce96968d" }, { "id": "file-2e9d962a08321605", @@ -142,8 +160,8 @@ "domain": "repository", "kind": "toml", "material": true, - "bytes": 1037, - "sha256": "44ee820ebe8673c7652c56d1decab52902c63ea6697aa5c58226363e055110e4" + "bytes": 1081, + "sha256": "741bc589177a82c628f64bf6c1a12e45054764558d34f9802883b99a8255d077" }, { "id": "file-be5c3eb56c8a1eeb", @@ -214,8 +232,8 @@ "domain": "opentake-agent", "kind": "toml", "material": true, - "bytes": 1431, - "sha256": "755d92abae3878ac2a247a3ed837658a97cc54b098ebe8053118eac1f3f14cba" + "bytes": 1499, + "sha256": "e32fd98cbbdf82b0b2c4491994e053b8a0a862f7dd4e12a3a835b82b49f1d84e" }, { "id": "file-61ee2d8b44c55f7e", @@ -223,8 +241,8 @@ "domain": "opentake-agent", "kind": "rust-source", "material": true, - "bytes": 27006, - "sha256": "ffb7160d1805d94e4e04e70472993ec096e11585e2ebd3ca8ec8bda4340e2d94" + "bytes": 31399, + "sha256": "47da79e7c30a2671c6adb3e8a5729e1b606735dd3e6c1b8651141ab8d12d2b1f" }, { "id": "file-93b6ddec4e563077", @@ -232,8 +250,8 @@ "domain": "opentake-agent", "kind": "rust-source", "material": true, - "bytes": 17688, - "sha256": "85d36597912ab9e1030ff8dbc0131b6edac334c8a4b2fa9055cb78ae9bd5b432" + "bytes": 27000, + "sha256": "f7b8856503269807b708d5f3550b2063b6e8a71f7226012124e9d96cd628c2a5" }, { "id": "file-a25c65feaf9e6dfb", @@ -241,8 +259,8 @@ "domain": "opentake-agent", "kind": "rust-source", "material": true, - "bytes": 1182, - "sha256": "7f0f55758e8f7839937d1ace01d501390f34b774e22c941d6ddf5c31acc11540" + "bytes": 1288, + "sha256": "1c8b5ca27c22bbc92dd6355038adfa6e059aacecb586defa1de229a7b6cfb603" }, { "id": "file-356d36c67e7dc8fb", @@ -250,8 +268,17 @@ "domain": "opentake-agent", "kind": "rust-source", "material": true, - "bytes": 7981, - "sha256": "00a19ca0bf05042e87723731d93d2a85568739dcd554766035039c16f6e1656f" + "bytes": 19384, + "sha256": "8e99f5b5e01f1ae167a70352734871ff4db9af65135812487e325de74ea8ec6a" + }, + { + "id": "file-377db82c1cdf8436", + "path": "crates/opentake-agent/src/chat/store.rs", + "domain": "opentake-agent", + "kind": "rust-source", + "material": true, + "bytes": 11600, + "sha256": "b67569b09fd9175a25eb91272146afe2864a5fa32f5a0921e46495142dddf74b" }, { "id": "file-2ed205da9bb64591", @@ -268,8 +295,8 @@ "domain": "opentake-agent", "kind": "rust-source", "material": true, - "bytes": 1704, - "sha256": "2062668d472594081df0f39ccfe787f3b96971d9ed18c1f9efa3d5ff955bc672" + "bytes": 13561, + "sha256": "7ee5a7ac8cfcb15393951eaa8b7bbe448297347386f6b38971238de7ada5fa91" }, { "id": "file-d89355d2865b518d", @@ -277,8 +304,8 @@ "domain": "opentake-agent", "kind": "rust-source", "material": true, - "bytes": 3485, - "sha256": "a7437fa7013f2543d1a7661634f327c9e9ceb24ea75781e82cdcf17bc4b316e6" + "bytes": 8654, + "sha256": "975a4337730a55da51da9752959ef44b119092456a18b13c1d0251019a2e8b3c" }, { "id": "file-7b4d98ba4f1e4141", @@ -286,8 +313,8 @@ "domain": "opentake-agent", "kind": "rust-source", "material": true, - "bytes": 206167, - "sha256": "edacf5ba89f31f5d070fb43b795fc5b411a6221f1f71b9cade97630104a3a193" + "bytes": 255099, + "sha256": "4f323a5c05932745657085c3ea411c2702ceff2822dc4483ce679c1f122a1492" }, { "id": "file-ec00ac2e9198052c", @@ -298,14 +325,23 @@ "bytes": 9996, "sha256": "2bd962fa4a737ff000dee18001077971b1a20d7e768044e9338a5f5ad6ece4cb" }, + { + "id": "file-382c6993731440fa", + "path": "crates/opentake-agent/src/mcp/generation.rs", + "domain": "opentake-agent", + "kind": "rust-source", + "material": true, + "bytes": 8175, + "sha256": "53f65febc5bb84683d32dfed89ae54d26f94621669757d0d8bde3a8919a53d88" + }, { "id": "file-609120a95eccf46c", "path": "crates/opentake-agent/src/mcp/media_bridge.rs", "domain": "opentake-agent", "kind": "rust-source", "material": true, - "bytes": 15915, - "sha256": "398e83e2caec71501853c09a10eddb673a7cc11635ad4e9ae732a9014b3f1728" + "bytes": 20074, + "sha256": "884ad048ad72889081f8fe067c00a5fa76d06d34220cfc722a5d887571be4efa" }, { "id": "file-4a3d29e5617072cf", @@ -313,8 +349,8 @@ "domain": "opentake-agent", "kind": "rust-source", "material": true, - "bytes": 794, - "sha256": "fd2c4600d655450482276c0f40e1de069c700214b9b1be74d8b1ff072d390aec" + "bytes": 814, + "sha256": "eb0c3b4683d793b0727d1768919c2c0371a25dc31f1233b60dc2ba2f4b44968c" }, { "id": "file-59f49f8425ad3213", @@ -322,8 +358,8 @@ "domain": "opentake-agent", "kind": "rust-source", "material": true, - "bytes": 12729, - "sha256": "7d97e733a9876a976e1515fc4d705cf64e34bec7cb32c67f18bce118ec12530e" + "bytes": 27704, + "sha256": "80b02b960782d39a0bc6e1182869d0932b9db321e71583602efcd04e28329be2" }, { "id": "file-7b57d853b2e6c0a8", @@ -394,8 +430,8 @@ "domain": "opentake-agent", "kind": "rust-source", "material": true, - "bytes": 12017, - "sha256": "aca20c1fa0b8f79775b86777b3a7ec54e89d977ebbd3ec43028fc65d233dc784" + "bytes": 14535, + "sha256": "487de71151ac210a288f4492d60dfe4cd8e079fbd45a74bc3a06e61edabec3ce" }, { "id": "file-2f0297d9f81bf61e", @@ -466,8 +502,8 @@ "domain": "opentake-agent", "kind": "rust-source", "material": true, - "bytes": 41533, - "sha256": "c297bab4e90b753cb9f224232cfc1f0f63d46e7e26615d4c0acad9ba336db02c" + "bytes": 42737, + "sha256": "dd13eac4a0fc12b2add97ddfc9c303b80c8c2d4f57b78e9e285fe3fcbfe6734a" }, { "id": "file-e9984f87096d7780", @@ -475,8 +511,8 @@ "domain": "opentake-agent", "kind": "rust-source", "material": true, - "bytes": 84765, - "sha256": "903e3e9b8acf9c9f5559124c2134e240a1f0661e18db2b8934cb6478cbd954f7" + "bytes": 88386, + "sha256": "3f3fa211561004e3757c38c043d2d524a7c0d95b7029cee76a2fb38a370a9b96" }, { "id": "file-cb6009297ca476be", @@ -493,8 +529,8 @@ "domain": "opentake-agent", "kind": "rust-source", "material": true, - "bytes": 13310, - "sha256": "93d2ac58febb1d82b5085bea25da2bd1ad7c3d491b30e1cfe0133045938840a5" + "bytes": 20776, + "sha256": "92d6d2af4c8a97a93d0cd9e0de795f7f802a26fffcdeedb8db8188a309ca00fb" }, { "id": "file-e7ddb4f5c7317b84", @@ -502,8 +538,8 @@ "domain": "opentake-agent", "kind": "rust-source", "material": true, - "bytes": 514, - "sha256": "25a28d247b7ff94782f1004667894026d2b53b004f167d37ee6f150891e3e0bb" + "bytes": 534, + "sha256": "1ce9a9f5247ae5fbfc92f52001633488daeda1b99177fde3b1d347a940fa064e" }, { "id": "file-9c2700d27172ff3f", @@ -511,8 +547,17 @@ "domain": "opentake-agent", "kind": "rust-source", "material": true, - "bytes": 10000, - "sha256": "486bb5def2c6cbceac4dc4e430bf86805d9a8483ff878256ce1556058b053df9" + "bytes": 13377, + "sha256": "04b23637e9fc70fa2db4b756b65f4656f1d12fb4601a042fbf7f20bda580544c" + }, + { + "id": "file-8148de41883b6008", + "path": "crates/opentake-agent/src/tools/panic_boundary.rs", + "domain": "opentake-agent", + "kind": "rust-source", + "material": true, + "bytes": 1196, + "sha256": "eeb78a58fc1c3ebe769bd98a6167fd4019e5338a0493b57413ec5c37d46a7b4d" }, { "id": "file-75e29cea1ec354e9", @@ -520,8 +565,8 @@ "domain": "opentake-agent", "kind": "rust-source", "material": true, - "bytes": 3664, - "sha256": "78a4045a4730773ef2226ac37ff063dc0fe82c85b5f02d61d405bf13053ac7f5" + "bytes": 7267, + "sha256": "a05ab320d66b7e4893a5c15dea30012f035bd96107c185c9ceb5064678e243ef" }, { "id": "file-7ecf2b1099b92f69", @@ -529,8 +574,35 @@ "domain": "opentake-agent", "kind": "rust-source", "material": true, - "bytes": 12544, - "sha256": "9ff114afbde89db8e8ad88dc9ecbcb1de385c35abcf6f6a9b5fad534371c1e9a" + "bytes": 12581, + "sha256": "fd92aae9d10bf08ca750b91a24aad65731b0bf9ad6beb3e7b880ec470cd67af8" + }, + { + "id": "file-73a89c630d1579c0", + "path": "crates/opentake-agent/tests/advertised_tool_acceptance.rs", + "domain": "opentake-agent", + "kind": "rust-source", + "material": true, + "bytes": 2683, + "sha256": "230d158844a927c113c334cce5c9e51125e1dd3a5eaa87d3861f67f4cf1898d2" + }, + { + "id": "file-db48a10c0e163420", + "path": "crates/opentake-agent/tests/generation_dispatch.rs", + "domain": "opentake-agent", + "kind": "rust-source", + "material": true, + "bytes": 9368, + "sha256": "298eb3a5c10d86a58a2ca1f661c372c2990898b4dbb0564a4f17c1927e14fe71" + }, + { + "id": "file-3ea0e278b456025a", + "path": "crates/opentake-agent/tests/mcp_error_redaction.rs", + "domain": "opentake-agent", + "kind": "rust-source", + "material": true, + "bytes": 2189, + "sha256": "e95e865a277b129069eef0f138343fbc64ba347dc1fb045e932887227fce7a28" }, { "id": "file-8e2aa1156746a3b6", @@ -538,8 +610,17 @@ "domain": "opentake-agent", "kind": "rust-source", "material": true, - "bytes": 4439, - "sha256": "878a6288486793c18b5dc25341659d1f413d2e161d31c68cbfaa8ed094bea98d" + "bytes": 26121, + "sha256": "03765fc5c16c381563ff86a5808e2a753c5eb5e53ff3a00849412e884f59e89e" + }, + { + "id": "file-e615916a9aa07214", + "path": "crates/opentake-agent/tests/tool_argument_contract.rs", + "domain": "opentake-agent", + "kind": "rust-source", + "material": true, + "bytes": 12777, + "sha256": "842cd37e71a2867542515ab11d0edfd8b075db3980f357f76f97df3d2fa735fe" }, { "id": "file-af1a277a729753c5", @@ -547,8 +628,8 @@ "domain": "opentake-core", "kind": "toml", "material": true, - "bytes": 455, - "sha256": "ebf8789d1cb6989c57fc1e44e3ea6d4e3068f04dff4a1c500e75d78ee53dbcf8" + "bytes": 490, + "sha256": "d55806d5b0612ed0fdece6040ca5c1b70a006a7ac2ef1b3eaec0da7f2eca548a" }, { "id": "file-32693b3be33817aa", @@ -556,8 +637,8 @@ "domain": "opentake-core", "kind": "rust-source", "material": true, - "bytes": 59136, - "sha256": "67e8554b1d26b4022593a329475423fc6ec840f5f3285cb0a381c3d0d52d53c5" + "bytes": 87553, + "sha256": "d622a65a1a90364705ad8c6114c0c5abca87140796b41b884273ab8625d843a8" }, { "id": "file-85c6f4503ae834fc", @@ -592,8 +673,8 @@ "domain": "opentake-core", "kind": "rust-source", "material": true, - "bytes": 8633, - "sha256": "d5a6ce889d0044bf44e6b7d84ab467bdde55eba4f2a302db2c1125f64706018b" + "bytes": 8655, + "sha256": "7af010348fe4bca01bab170c33c86c30e7b63ad6d8c2bac69c10818dd075233c" }, { "id": "file-311db8a049c88090", @@ -601,8 +682,8 @@ "domain": "opentake-core", "kind": "rust-source", "material": true, - "bytes": 3275, - "sha256": "c264c11de3acb60878a75ff3df17b56f93f3ae523ac9516c3110f981dd1fb8b1" + "bytes": 3445, + "sha256": "766b333146ac21456c7a069ec040bee62071e9f67cf1e39244b1e7e8b7e193e5" }, { "id": "file-a086b7d54b10f544", @@ -610,8 +691,26 @@ "domain": "opentake-core", "kind": "rust-source", "material": true, - "bytes": 44615, - "sha256": "6bf44d8ecba3488ca4ffa26ec277a46d98e6a34e7e2c31dc18ea18a10966fa6d" + "bytes": 66590, + "sha256": "5abe782b3149d588e81aebc8fd6a7a66098254c0b74c987bbfc3090af61cff45" + }, + { + "id": "file-3182b96162a89305", + "path": "crates/opentake-core/tests/generation_persistence.rs", + "domain": "opentake-core", + "kind": "rust-source", + "material": true, + "bytes": 10795, + "sha256": "f0bca585b58ee694b70b3f8ef31550ad4609341e9e216189a4f5aa38024f3e58" + }, + { + "id": "file-876c83d0b300f941", + "path": "crates/opentake-core/tests/project_open.rs", + "domain": "opentake-core", + "kind": "rust-source", + "material": true, + "bytes": 19688, + "sha256": "d857be9bcd2d1aa11c892272c90a595a97fd81c14f0d77b358a9af04a1ca2ca9" }, { "id": "file-212b09846f91bd29", @@ -646,8 +745,8 @@ "domain": "opentake-domain", "kind": "rust-source", "material": true, - "bytes": 36838, - "sha256": "5176392658f38e6de3ba029322cdc1903a3a24207df5fafd6edc945b1c2d4a29" + "bytes": 43428, + "sha256": "fe6d5f5fe0e689404a2dd89a482f7f924e0ad8b1d933daa453c14f77031effe8" }, { "id": "file-e350e7272c332edd", @@ -658,6 +757,15 @@ "bytes": 4003, "sha256": "6cf5c9084907862a8933826a33ada95c2bcc3c8930e00c7a72d3b73e11a3830b" }, + { + "id": "file-ec25a28e754095ea", + "path": "crates/opentake-domain/src/clip_wire.rs", + "domain": "opentake-domain", + "kind": "rust-source", + "material": true, + "bytes": 3812, + "sha256": "520083763be52d680356e72e30f3a0b663b53d42fb3288f1e6d892c16a93a847" + }, { "id": "file-1f40c2e8dcecaef5", "path": "crates/opentake-domain/src/grade.rs", @@ -673,8 +781,8 @@ "domain": "opentake-domain", "kind": "rust-source", "material": true, - "bytes": 17993, - "sha256": "7570de41a152019926c1aad93da67382d8f0973f495ff8e1c63d5ea0835a1b2a" + "bytes": 18696, + "sha256": "7ebe1229dd22148ec46a2576f391370db1c7607530e27cf3340405e23f4b2bb6" }, { "id": "file-f077249dad8bff35", @@ -682,8 +790,8 @@ "domain": "opentake-domain", "kind": "rust-source", "material": true, - "bytes": 2494, - "sha256": "31a8bfa61f0c5b8440b65e950ca777c3d470107575547916eefc827a8395384e" + "bytes": 2735, + "sha256": "4af870ab4bdb731b784583b45dcd310a9ac39815f265ad730f128a09a5579688" }, { "id": "file-770e89ffe24a8b60", @@ -691,8 +799,8 @@ "domain": "opentake-domain", "kind": "rust-source", "material": true, - "bytes": 35784, - "sha256": "3fdc0261acc7a2a2fd6ad08a07fe6b642e3c12567448ecc4e85c3c76eed217e0" + "bytes": 39278, + "sha256": "43caa73ab184f6a93c5cabfa0e3f45f335782feada0f29aab5ff92c1352b6b5e" }, { "id": "file-ef3fbfe997a280c3", @@ -709,8 +817,8 @@ "domain": "opentake-domain", "kind": "rust-source", "material": true, - "bytes": 12368, - "sha256": "a23373f92b3fed28d547408c50244faed8355b045830c78b59b0a82fdc570b8a" + "bytes": 12409, + "sha256": "914d97cd08725e4531b40e70d2b6f67cbd6480eec0770b2ac1833b3d19606c88" }, { "id": "file-c60900dd2e853ca2", @@ -727,8 +835,17 @@ "domain": "opentake-domain", "kind": "rust-source", "material": true, - "bytes": 17539, - "sha256": "d567b9c6d9682d452206a4c1f3868736e11edde24e3bc974bbfb658012c0133a" + "bytes": 21350, + "sha256": "23759c95832cbe511aac91a5439e7ced9a35b52537b2d091c501fdf509311bf5" + }, + { + "id": "file-3254ae60a738e60e", + "path": "crates/opentake-domain/src/text_wire.rs", + "domain": "opentake-domain", + "kind": "rust-source", + "material": true, + "bytes": 4020, + "sha256": "100cabac860632417ef807ca000a0924c27f2b0d04984db624cb12e303f13535" }, { "id": "file-753f0fa2e016fbdc", @@ -736,8 +853,8 @@ "domain": "opentake-domain", "kind": "rust-source", "material": true, - "bytes": 8299, - "sha256": "441e4ae0f878c496f986e9fdea52c00d98fda7a39bb1ceaa8b99c930a173e082" + "bytes": 10286, + "sha256": "ddbdbcf657790b06912f8d481917c65510f7e227d3640c588ba8f9d6836557a8" }, { "id": "file-1ca2b8d7b5b23159", @@ -745,8 +862,26 @@ "domain": "opentake-domain", "kind": "rust-source", "material": true, - "bytes": 15333, - "sha256": "4d4a4b20852a0664669448d96c1c104d7c525877cbefb652a91ed989a03f3de3" + "bytes": 16442, + "sha256": "3aafa07091ba9a3dc5629c677d7c530bc6ff26cf4e5bd60a8caba150fdb62fd3" + }, + { + "id": "file-add99be3d239236b", + "path": "crates/opentake-domain/src/transition.rs", + "domain": "opentake-domain", + "kind": "rust-source", + "material": true, + "bytes": 782, + "sha256": "8bb3bc021a8b6860daa46f1d36a40732b458e6382a4892a7a6345f22a97eda0c" + }, + { + "id": "file-3047b992dcbf3bcc", + "path": "crates/opentake-domain/tests/wire_schema.rs", + "domain": "opentake-domain", + "kind": "rust-source", + "material": true, + "bytes": 5166, + "sha256": "10b99d7e00a93686bbe1981832fefe1d1ca8db31ccc1dfa476f4070f3bbe87b2" }, { "id": "file-10ad5e3746bf7f6b", @@ -763,8 +898,8 @@ "domain": "opentake-gen", "kind": "rust-source", "material": true, - "bytes": 12307, - "sha256": "1a74d8af6222eaf1d03b20a3bbd5266b3d3648f5565b4667df1908a01fc12779" + "bytes": 13234, + "sha256": "2b8659b6fa564a222544be31227a864b6b2d117a4f505ef1f006d51dc0486747" }, { "id": "file-f6ea76dd4e64c2b8", @@ -925,8 +1060,8 @@ "domain": "opentake-media", "kind": "toml", "material": true, - "bytes": 3672, - "sha256": "88a569f07f80abedb14985e92d5a95aa6da25398cbafb05660b497a11d4508b4" + "bytes": 3744, + "sha256": "3291ae66783cd3f33acee5474fc3ce7551e07333056ba262151936e7d8decc73" }, { "id": "file-7b9f07dc6f209aec", @@ -970,8 +1105,8 @@ "domain": "opentake-media", "kind": "rust-source", "material": true, - "bytes": 6624, - "sha256": "72c93b2a9549af2680e56982d1cbafb6cc5ab1327085c8b23ceb81a730f6542f" + "bytes": 13881, + "sha256": "bc16f9df98dd24ce4085766e95737946be1b6a78e95bcba8ff655c7d45000160" }, { "id": "file-9ae1b2d212b18fc9", @@ -1069,8 +1204,8 @@ "domain": "opentake-media", "kind": "rust-source", "material": true, - "bytes": 4558, - "sha256": "6401275cd3fb6088e5cab84cab43a8dc1504768c2917a31f189c81ee54260eba" + "bytes": 7280, + "sha256": "e86bb51ce35dc186d032844ad18b8c223fa4a5de87bb0afac39b452529bef138" }, { "id": "file-2d8453bed877e1e2", @@ -1096,8 +1231,8 @@ "domain": "opentake-media", "kind": "rust-source", "material": true, - "bytes": 16705, - "sha256": "1413493b2d321958925b446e4b8311adea30fd00890644c53824c835eec1e3bb" + "bytes": 20662, + "sha256": "3fb7a45bdb7fae5430841034e53a99e17ec0c6aa35421fb4f3e8304d55e6f511" }, { "id": "file-5cfee53910ab5ac2", @@ -1105,8 +1240,8 @@ "domain": "opentake-media", "kind": "rust-source", "material": true, - "bytes": 110813, - "sha256": "bad30cb95e9eac2279748d48e135a694a1b8cd588d10b0048163313c20b4f1e3" + "bytes": 111575, + "sha256": "e0fe3d5c74a1af6cfc104e9bcf7f2389b78d393dfa416c9d9cc16227fd597e10" }, { "id": "file-87d6c97ee66c198c", @@ -1132,8 +1267,8 @@ "domain": "opentake-media", "kind": "rust-source", "material": true, - "bytes": 11697, - "sha256": "3b39f9e7c18815148ec5a9ecabcd165be28cd9072fc23023224dea4be0eca0fe" + "bytes": 13363, + "sha256": "89511e647305503c0a3f6cba711ef0c90992628f71e83fdb5358e2f3d61744f7" }, { "id": "file-c4e2ec61a3543273", @@ -1150,8 +1285,8 @@ "domain": "opentake-media", "kind": "rust-source", "material": true, - "bytes": 13179, - "sha256": "f085c65b0a4b76196fbcb37f9190c30ae0b970fc64c39fbefe585157d553bd48" + "bytes": 13151, + "sha256": "b526dc447d8fe1ce0f76ccd891b8a24bea85bf9f68d8ede430db1c50fda0930e" }, { "id": "file-fbdcf5971fc33fa5", @@ -1231,8 +1366,8 @@ "domain": "opentake-media", "kind": "rust-source", "material": true, - "bytes": 7135, - "sha256": "fcfc4871addcb943354d42b4b67956344b2dd9c27daf2af0c224359577fe98f7" + "bytes": 8179, + "sha256": "fc878e040b7608e9f47be9d082a633fea6d44cf43b3c3970d33a9a51000545c3" }, { "id": "file-196938092cc2e60a", @@ -1249,8 +1384,8 @@ "domain": "opentake-media", "kind": "rust-source", "material": true, - "bytes": 11633, - "sha256": "07ba9020e47f56b5fad4c13204092b28d9e8c0cefda9c94d6d8028c311cdb67a" + "bytes": 12594, + "sha256": "7130836ac14e7a1c825fc874e47863e6257ac76a0667bcb2c33cbebc5f5c9de0" }, { "id": "file-a03167acf3a1f231", @@ -1267,8 +1402,8 @@ "domain": "opentake-media", "kind": "rust-source", "material": true, - "bytes": 14158, - "sha256": "060ff0da7b6801f8bb462c08d33a18f895d69e8b2ce867a0297fd9e77d30da93" + "bytes": 14078, + "sha256": "0c0802bbbd843cd866c049a4da788748c71d4cb2c86a49502975d8a8acb857a3" }, { "id": "file-a194bec50f45f143", @@ -1276,8 +1411,8 @@ "domain": "opentake-media", "kind": "rust-source", "material": true, - "bytes": 35160, - "sha256": "19f41801e464f7257d0d7eefdc07dad622175ab245f53cff838fb59b4323a464" + "bytes": 41979, + "sha256": "488d8a3c80f5ad9b39fe3c080ddeeba3ef6695863d14e4d9a878380acb60be68" }, { "id": "file-7753135bb39f0279", @@ -1339,8 +1474,8 @@ "domain": "opentake-media", "kind": "rust-source", "material": true, - "bytes": 6181, - "sha256": "16e7f0e974caa97b76dd53aaa65615f0fdc58cd29806663f1f787fccefe7feac" + "bytes": 17647, + "sha256": "cf5abb961399917d37e2b253e74cc20c31d116917584d6d1dee4d6d7cd28b738" }, { "id": "file-5b7e406b19f7c1b3", @@ -1357,8 +1492,8 @@ "domain": "opentake-media", "kind": "rust-source", "material": true, - "bytes": 4396, - "sha256": "0f95ff9091dee3d6cb5a6d7a5fd305f254a5c76d750f231b88281d9ee6b8f48d" + "bytes": 4382, + "sha256": "eb9ad5d2a184ab46899e2bff7a5b8e204220bbe695ea1c22bf10bee63ef12c8e" }, { "id": "file-37c2917cda66efdf", @@ -1375,8 +1510,8 @@ "domain": "opentake-media", "kind": "rust-source", "material": true, - "bytes": 12872, - "sha256": "882410410076c635ef5977e2650e5e055ff62cfa4194b10ddef4c84a786f23b4" + "bytes": 14267, + "sha256": "2bbffbfbbb95e7b26b20969c586e9d5700781fe065616da0c87092b0b234b246" }, { "id": "file-183b22cd430a725a", @@ -1483,8 +1618,8 @@ "domain": "opentake-ops", "kind": "rust-source", "material": true, - "bytes": 159263, - "sha256": "29af81feb988b97ba27c0d96e43d1357ee3faecae1927e15d4879214610678cc" + "bytes": 167815, + "sha256": "def452ad008fe7dbdbef0067df7bc333eb120ea44fb43d7920a83348d0526102" }, { "id": "file-dd2047dc3a0d231f", @@ -1564,8 +1699,8 @@ "domain": "opentake-ops", "kind": "rust-source", "material": true, - "bytes": 7688, - "sha256": "8722edea6ae271c7add5dc7e30be5d13b703275d0c521056d0e2121d9a9d3467" + "bytes": 7963, + "sha256": "810c6ac589f04316e4f029c20ee8a8ff1f09f69490eb3cc471174534b02dff5d" }, { "id": "file-4961a36676f850a4", @@ -1627,8 +1762,8 @@ "domain": "opentake-ops", "kind": "rust-source", "material": true, - "bytes": 20530, - "sha256": "533741ce8f083d96b206c94c1bde2596bfacc78bf8825124c9ed6b2917b483c4" + "bytes": 23119, + "sha256": "496b508effc41dc72d9daaa957d8c1f3a4ad407f12a9c425b1ee77d2f556b64a" }, { "id": "file-de9d54b47020b10b", @@ -1636,8 +1771,8 @@ "domain": "opentake-ops", "kind": "rust-source", "material": true, - "bytes": 9047, - "sha256": "abc9c8c8e5623a7222dd1c5a657d11a8d366c0f547e4d41c57dc78dae563f54d" + "bytes": 9981, + "sha256": "55dbb926df0fdfbe678eb6801c67324f91be1466b51159d03ed0cf99c5c24d06" }, { "id": "file-90546560cb3950a0", @@ -1681,8 +1816,8 @@ "domain": "opentake-ops", "kind": "rust-source", "material": true, - "bytes": 51212, - "sha256": "a045187a267fef9908c261518bb979045f3bccf7abefbdacf5f6312e6ec9ac83" + "bytes": 54338, + "sha256": "9c23c9c092d169d03c423894dd49143694c01a4eeb64eea367baed102bab6718" }, { "id": "file-61effa008dc46e6a", @@ -1699,8 +1834,8 @@ "domain": "opentake-project", "kind": "toml", "material": true, - "bytes": 707, - "sha256": "0076ae611fb26a8692385a590bff877b4d85e86aaf9e2b1cf299317217da447c" + "bytes": 1032, + "sha256": "02aeab0899a24aca9bb5bd957a8c0a4ed5d4ed23aebdf572f22976a4e438dc62" }, { "id": "file-81f5446a52eed42a", @@ -1717,8 +1852,17 @@ "domain": "opentake-project", "kind": "rust-source", "material": true, - "bytes": 27229, - "sha256": "b355b8f67776363e21b16294ddfc6c5db23f0bd4d282df506b94ed69236d96e2" + "bytes": 35653, + "sha256": "5046afa5c7ee09900bed7873b5b309ec8c9f01397102d2a0a43d5e60f971ce6a" + }, + { + "id": "file-eadce47afb443af3", + "path": "crates/opentake-project/src/compatibility.rs", + "domain": "opentake-project", + "kind": "rust-source", + "material": true, + "bytes": 18852, + "sha256": "d6b6b59f9d68a393ea84d37b156cb8c452d95096c84d2995de8581434e0d9629" }, { "id": "file-0d0cad185e1c6853", @@ -1726,8 +1870,8 @@ "domain": "opentake-project", "kind": "rust-source", "material": true, - "bytes": 15028, - "sha256": "3f00a544c20c824d824e58f066601b10918675891c15e73caed9a80b0a5e3bd8" + "bytes": 16836, + "sha256": "b7903d7590e2db65fa5350ee8f6d2cc62f1f95a6d36d0cc1630b0ac50b83888e" }, { "id": "file-10de10c15dcfc4dd", @@ -1771,8 +1915,8 @@ "domain": "opentake-project", "kind": "rust-source", "material": true, - "bytes": 8167, - "sha256": "5ee4832fb3b933db0557caf20fba061cc7e08f50d2865e5a2c1cbd386e933dd6" + "bytes": 11685, + "sha256": "101567b6eab0a96fe1b4ffe17200da2b5a98e0697500e0386371aba1b455177c" }, { "id": "file-ce914be66d2ae909", @@ -1789,8 +1933,8 @@ "domain": "opentake-project", "kind": "rust-source", "material": true, - "bytes": 3260, - "sha256": "b44aedabd4785281c859cca920234cd9aa47649120321cdbe819ca9c30c8a9a7" + "bytes": 3279, + "sha256": "cc4511de881232786538fa693ab9af2c4f277d04ccb4c68c736c5dc7292db3f3" }, { "id": "file-87454e6f7dfbfce4", @@ -1807,8 +1951,8 @@ "domain": "opentake-project", "kind": "rust-source", "material": true, - "bytes": 103087, - "sha256": "3119cd5562d5f7d1fe5a24a65ad9550afcd1747a3c0e67e86a006df0939e5bc4" + "bytes": 117503, + "sha256": "98c7cf58bceec72a51c71a04a473fd8aa28c4480bafff2e3de3c535677a45d86" }, { "id": "file-26ba2bd33f27241b", @@ -1861,8 +2005,8 @@ "domain": "opentake-project", "kind": "rust-source", "material": true, - "bytes": 7482, - "sha256": "8ce380d1ef6352c7cbcd3a396ba75d66cff47996355ccfa82a3a9510672d7a4c" + "bytes": 7755, + "sha256": "44b19ae28f609a52ebb2b6253fc7654460d6d05b7d952bce08f5286a67d678ef" }, { "id": "file-048086814373a1b6", @@ -1870,8 +2014,8 @@ "domain": "opentake-project", "kind": "rust-source", "material": true, - "bytes": 3974, - "sha256": "1e35580c67e6681b6f662e621670035636a15506090f18a1fc1a782c1c72b479" + "bytes": 47144, + "sha256": "40fb5ffc6b4a9c4429a18b43977c357e6776922b572800935bc738255c3d851e" }, { "id": "file-23f292ad0d1f748f", @@ -1879,8 +2023,8 @@ "domain": "opentake-project", "kind": "rust-source", "material": true, - "bytes": 28, - "sha256": "cf0c78e68a295d25e3c77c1ec1a102bc096d6ea4099c655ef9c77b78b5ad522d" + "bytes": 53564, + "sha256": "6288e09b12ad4e9b55090d22c662024c619c00366f98e297fea85f0ccf083628" }, { "id": "file-5502d48ab1da1b22", @@ -1897,8 +2041,8 @@ "domain": "opentake-project", "kind": "rust-source", "material": true, - "bytes": 28, - "sha256": "cf0c78e68a295d25e3c77c1ec1a102bc096d6ea4099c655ef9c77b78b5ad522d" + "bytes": 84972, + "sha256": "e1a911839d0f6aa7e5614ede23f07e0ed138aef21c1623e0e9f37afeb1853967" }, { "id": "file-b85f7b6d28d86d85", @@ -1933,8 +2077,8 @@ "domain": "opentake-project", "kind": "rust-source", "material": true, - "bytes": 1850, - "sha256": "cf283499c0cff40935a92419054cdc6dbb8f24628637fd2ea69e643d04308a74" + "bytes": 4547, + "sha256": "a8158681923042dd813620991e270c747f3b36b569b620536da9e0ba79d9dca0" }, { "id": "file-fe797fcbf81fa0a8", @@ -1942,8 +2086,8 @@ "domain": "opentake-project", "kind": "rust-source", "material": true, - "bytes": 9286, - "sha256": "3c2c368614c9603a18c4f0c0e8955eca92be09fd45bb33160584c3da434526d4" + "bytes": 10389, + "sha256": "27ddaf1c7d46d7d36d6338d3c6354fd3f2adb6ced6a2b9fc6698542e6132ba97" }, { "id": "file-eea7d53b32b5bf2c", @@ -1951,8 +2095,8 @@ "domain": "opentake-project", "kind": "rust-source", "material": true, - "bytes": 11106, - "sha256": "c15ae393f949d90acf2e46b977872354007a8d5c68278f3c1dc9f5224da42422" + "bytes": 18851, + "sha256": "96b8718240a6fde4372c5be1be6799033b12f7e9dc0cadaac42723c468d95466" }, { "id": "file-bc71245924815035", @@ -1960,8 +2104,8 @@ "domain": "opentake-project", "kind": "rust-source", "material": true, - "bytes": 9483, - "sha256": "a0652d76bccf15e89e6fd0c0f9e5072fba5d77ed31d4eaf649d344af99769d33" + "bytes": 37096, + "sha256": "ea27af6004ba1a6719324f07c2fe97d69c0e8037b3316767abb37efcda5b36ba" }, { "id": "file-5a2cf4662bdd00b6", @@ -2068,8 +2212,8 @@ "domain": "opentake-render", "kind": "rust-source", "material": true, - "bytes": 13840, - "sha256": "745c6f06552c7c49c2c2d13493fb13acd15ee7e3e52a2ae25b9b907a52f606ef" + "bytes": 16818, + "sha256": "53cab0c008bde316ec1337164f4abfb3d6e8592ff242788379966b2d08aa0682" }, { "id": "file-d6d5b63cac3456d6", @@ -2086,8 +2230,8 @@ "domain": "opentake-render", "kind": "rust-source", "material": true, - "bytes": 18464, - "sha256": "e74948c9fb7302b4768b8a0cd0f723d040a4f5a8adb09663499c4899fa450910" + "bytes": 19506, + "sha256": "4ad7cf91c16e4d829c999af7af3c2264d63581eee1ca7ecb0c9b316929bafbfc" }, { "id": "file-4d7f1a6e9efe131b", @@ -2185,8 +2329,8 @@ "domain": "docs", "kind": "markdown", "material": true, - "bytes": 10134, - "sha256": "670c3b17f2321d9b4f1f9cdeed22039192622da54c1590f8d114cb9a7e4bb72a" + "bytes": 10219, + "sha256": "1c005486e0b1115021faf7f5b3234c9d76eeed4ec2f2914942bc5d7ba8c5813c" }, { "id": "file-10b457f2fdd4efd9", @@ -2203,8 +2347,8 @@ "domain": "docs", "kind": "markdown", "material": true, - "bytes": 6940, - "sha256": "dc5d09a08235ec0ee70d2b4d015141519e67975c837ddcb7e87f1057dcc6e1b0" + "bytes": 7288, + "sha256": "223d7e31896d3afe07e3adbae056cc3ba3c4e11242bf454f004d6d7bbb8a7fbc" }, { "id": "file-87758427c64ff1be", @@ -2212,8 +2356,8 @@ "domain": "docs", "kind": "markdown", "material": true, - "bytes": 68622, - "sha256": "649b1597c0357c663a9e38aa3005bc2f88c4827375985507f7ce29433df1e94b" + "bytes": 67681, + "sha256": "4c832b63ba2a547a9f47fd842686a3e49a28d1a75227dbd45fd79c0f4cce835d" }, { "id": "file-50bf195d30eafab2", @@ -2230,8 +2374,8 @@ "domain": "docs", "kind": "markdown", "material": true, - "bytes": 10762, - "sha256": "48c6b9c686f37d16777b0b25792f948ddf556f92c43bb35376d1d76af6fa0b44" + "bytes": 11384, + "sha256": "9ea17fdc7eb1305e57816748367417dad74f406e8a6645df6f9f883e6ee18ba9" }, { "id": "file-ef9fcc3cb79eacf5", @@ -2239,8 +2383,8 @@ "domain": "docs", "kind": "markdown", "material": true, - "bytes": 18354, - "sha256": "d9c1e56a186d05ff7266bbcf533c84fbdc81b075da2b7ca142cc7d249de08a61" + "bytes": 18829, + "sha256": "923b86b9ac7f3a805a6036a882e9cb0e66882d561485a15d218dcc23562b2304" }, { "id": "file-113d5c22cbe456f7", @@ -2257,8 +2401,8 @@ "domain": "docs", "kind": "markdown", "material": true, - "bytes": 377035, - "sha256": "16fae852b08fd005f24b13d9203c61e65995b5f58a9c3d8683e87ae7553f8682" + "bytes": 378121, + "sha256": "53c8c350b220ba794de9477a3c92041f8c706e28e26d29c7676cac6373b3eb95" }, { "id": "file-8fb895ecdd8c9390", @@ -2266,8 +2410,8 @@ "domain": "docs", "kind": "markdown", "material": true, - "bytes": 6830, - "sha256": "7856c853a6a7faa75ad1092d87e830a3cbdb57a3bbaae50fbffa7bca6351de0a" + "bytes": 7312, + "sha256": "5f3b896b523c680c60e98bd9e2ad12f51633fb40a3919756161adf15e12982f5" }, { "id": "file-959fb83f8891cb7b", @@ -2275,8 +2419,8 @@ "domain": "docs", "kind": "markdown", "material": true, - "bytes": 27089, - "sha256": "62893356d57f7641cc3ac4c30fe7718eba5ac6476728df98c4cd445189d15481" + "bytes": 28129, + "sha256": "ef3cf02b2f92d2b529240cddfc7744a2211673c4f3bed1447a8eb9818e63c1d2" }, { "id": "file-5de6d1bbf64c2d34", @@ -2284,8 +2428,8 @@ "domain": "docs", "kind": "markdown", "material": true, - "bytes": 13805, - "sha256": "ab3aa0f41e10b0d308f4e7e1f9f35542f22d93b60d02ede5bda25c3c04e08e22" + "bytes": 14480, + "sha256": "800f34f7eda762d1de9aa5f59af3d76277ac1b0acef2b3c302634ae5b2a434a0" }, { "id": "file-67df688c7efbd94e", @@ -2293,8 +2437,8 @@ "domain": "docs", "kind": "markdown", "material": true, - "bytes": 5187, - "sha256": "c45322fb5d7fb3855c6e6fa963cd45ae94712d3a1b9462ae0bb8466d67029302" + "bytes": 5454, + "sha256": "6673877f5003d46bba30f2bf3ebfcc8536d2655b209bec210ecb6b984efa9633" }, { "id": "file-e43cc13d10d1eb22", @@ -2302,8 +2446,8 @@ "domain": "docs", "kind": "markdown", "material": true, - "bytes": 4169, - "sha256": "d7af49ce40bb93d23ad00fccb77577fa3fef3bb477348e8f9d9957f929473546" + "bytes": 4235, + "sha256": "e723f3ca961c352c7b258acb9d4f074b40d209201245423483f87a7e325c332c" }, { "id": "file-028543244a351492", @@ -2311,8 +2455,8 @@ "domain": "docs", "kind": "markdown", "material": true, - "bytes": 3476, - "sha256": "acf0c3e1f05ed778112b2491d30af973fa65256e06dd2ffe242eeaeb0f112344" + "bytes": 3720, + "sha256": "ee16125198db3fb1da2dbd9424824918941d19d69b7546295c2b9ee44aa0aa14" }, { "id": "file-fa8c17f2d7bf4cdc", @@ -2338,8 +2482,8 @@ "domain": "docs", "kind": "markdown", "material": true, - "bytes": 3452, - "sha256": "4af601143d9e992cf24163a2e48df51070160506ee87eb0f3df87fc54909abbd" + "bytes": 3517, + "sha256": "e8ff09adde85b1acf919e597070653132b6dec557adc7b02b6d72ed428ee6550" }, { "id": "file-9e856e01ab84687a", @@ -2356,10 +2500,8 @@ "domain": "docs", "kind": "json", "material": true, - "bytes": null, - "sha256": null, - "hashStatus": "deterministic-generated-output", - "reason": "content is regenerated and compared byte-for-byte by the all-scope verifier after inventory generation" + "bytes": 2902659, + "sha256": "522dfb873cbadca54d624a0512179938f72a2142d992f7af5bb2ae2e57c86edb" }, { "id": "file-e8e6978cfbfaac20", @@ -2367,10 +2509,8 @@ "domain": "docs", "kind": "markdown", "material": true, - "bytes": null, - "sha256": null, - "hashStatus": "deterministic-generated-output", - "reason": "content is regenerated and compared byte-for-byte by the all-scope verifier after inventory generation" + "bytes": 1485, + "sha256": "9f301906f3cee26d3c8633ad31efb5930a07316477fd9f76ef9b613de18aa36c" }, { "id": "file-591150a2a9d7497f", @@ -2432,10 +2572,8 @@ "domain": "docs", "kind": "json", "material": true, - "bytes": null, - "sha256": null, - "hashStatus": "deterministic-generated-output", - "reason": "content is regenerated and compared byte-for-byte by the all-scope verifier after inventory generation" + "bytes": 3535, + "sha256": "4c15c427ff32ad8015eeba15eb12b4ec48a1feae5eb60337990a7f75e08a272c" }, { "id": "file-1619a9ed4ed640a6", @@ -2471,7 +2609,7 @@ "kind": "markdown", "material": true, "bytes": 186047, - "sha256": "fecd05aa78c270841077ad9acf6b8108b39dc5972187d522c1aa32db25a45d75" + "sha256": "b32dcb48df8e4762bb3ad5fc06b7d7966243a7d3435131560d4e3d801a981640" }, { "id": "file-860727d8859464ce", @@ -2488,8 +2626,8 @@ "domain": "docs", "kind": "markdown", "material": true, - "bytes": 528917, - "sha256": "0010c10967e999bacc30f47ed853da7cb95528146dff7e2785bd6002ce8862c7" + "bytes": 532311, + "sha256": "f1f9d1a3eaed05080baef20fca0671d8bb407f6616bd25ef2fd90e410ee4add6" }, { "id": "file-1cd53949bf9617bb", @@ -2515,8 +2653,8 @@ "domain": "docs", "kind": "markdown", "material": true, - "bytes": 92792, - "sha256": "78fdf5d9b90c89cb5d58e99d8bd0015ce55845f8b33ea8a5b6b3438cb7f97470" + "bytes": 100766, + "sha256": "f0f0678b6270ccb94b966e3583c101e517e0785dd82060a2acf9babccc6d6ab9" }, { "id": "file-9d4338de53f376c7", @@ -2524,8 +2662,8 @@ "domain": "docs", "kind": "markdown", "material": true, - "bytes": 104267, - "sha256": "654eb06308d2bceffc489c53737ee4fcafe0247cc972e24578ac13d268a68c04" + "bytes": 115182, + "sha256": "87f36634d34417f9b329b84e00b66cc58905620254aec887868c4d9cd1e0cdb2" }, { "id": "file-3f35eb757d5d95ba", @@ -2560,8 +2698,8 @@ "domain": "docs", "kind": "markdown", "material": true, - "bytes": 160071, - "sha256": "83082e2ed81f6e39f439dee39f21bf7edb3e8489936963362f213cad28634ba3" + "bytes": 167451, + "sha256": "334c706b804d7f0783ca99b260dab1b25c682f8ea7a4dba4a8f918787ec380fc" }, { "id": "file-55b6b718c705afb7", @@ -2578,8 +2716,8 @@ "domain": "docs", "kind": "markdown", "material": true, - "bytes": 378437, - "sha256": "097592c3db83247c197aea642c06a5bf0d41e21d34452bd79084fd455fc53ce5" + "bytes": 378928, + "sha256": "bc0d9dff57f3285a648d42606c7e8ce1601309401b4fd55ee0db3ae587924916" }, { "id": "file-bb23f423d5ac7f25", @@ -2682,6 +2820,105 @@ "bytes": 421, "sha256": "32a9bfa25bc5381d12a70c0dedd6bbe4daab5188d0d3f32be75620e7a9fe3e21" }, + { + "id": "file-ad76438ac441b869", + "path": "docs/audit/2026-07-14/runtime-artifacts/automated/data-safety-cache-identity-real-device-2026-07-31.md", + "domain": "docs", + "kind": "markdown", + "material": true, + "bytes": 2339, + "sha256": "785a84d611961894cc9c945f37be81c44a5e05183573997d174905d92152016b" + }, + { + "id": "file-b8ed1396f2e51031", + "path": "docs/audit/2026-07-14/runtime-artifacts/automated/data-safety-cross-cutting-security-partial-2026-07-31.md", + "domain": "docs", + "kind": "markdown", + "material": true, + "bytes": 3796, + "sha256": "6ec8fb94e7f571e54270242590c60d8fed0d105afcf5cc108525ce695ae344b4" + }, + { + "id": "file-aed530d612192460", + "path": "docs/audit/2026-07-14/runtime-artifacts/automated/data-safety-generation-seed-real-device-2026-07-31.md", + "domain": "docs", + "kind": "markdown", + "material": true, + "bytes": 3029, + "sha256": "9083fd852001e32c7d816241994189163f49def983f91a4c0dcbafd847846d3e" + }, + { + "id": "file-42b8c3a5c15ec3cc", + "path": "docs/audit/2026-07-14/runtime-artifacts/automated/data-safety-legacy-default-matrix-real-device-2026-07-31.md", + "domain": "docs", + "kind": "markdown", + "material": true, + "bytes": 3768, + "sha256": "227776bb9f990b7915b0c98045c1864b52241bff622c5668c1027d1ba7ebb874" + }, + { + "id": "file-1bc7d8222664f7fe", + "path": "docs/audit/2026-07-14/runtime-artifacts/automated/data-safety-manifest-corruption-real-device-2026-07-31.md", + "domain": "docs", + "kind": "markdown", + "material": true, + "bytes": 3515, + "sha256": "351ca614028d0bcec927fec2507db47c5f97bd3d4a4fb3972fc0192a09dfa2c0" + }, + { + "id": "file-39762481351a763f", + "path": "docs/audit/2026-07-14/runtime-artifacts/automated/data-safety-mcp-redaction-real-device-2026-07-31.md", + "domain": "docs", + "kind": "markdown", + "material": true, + "bytes": 2385, + "sha256": "db70774b38cd2b4eeb8fafbb5e46622ccfd50febd853ed99c63ff8a6b77da508" + }, + { + "id": "file-b6e43611dcc292c0", + "path": "docs/audit/2026-07-14/runtime-artifacts/automated/data-safety-mcp-tool-import-real-device-2026-07-31.md", + "domain": "docs", + "kind": "markdown", + "material": true, + "bytes": 3365, + "sha256": "6d424679f0a9e859fb88185d676ee7a6f3e0b96c8ae9a2856080bfd0e7437362" + }, + { + "id": "file-977846919b675f62", + "path": "docs/audit/2026-07-14/runtime-artifacts/automated/data-safety-mcp-transport-real-device-2026-07-31.md", + "domain": "docs", + "kind": "markdown", + "material": true, + "bytes": 2560, + "sha256": "7f72dc36c3410939120254cc38fe4addf0be391296b42cbc8a6b3c12fb5f9cda" + }, + { + "id": "file-05a49524c1b831a8", + "path": "docs/audit/2026-07-14/runtime-artifacts/automated/data-safety-project-open-composite-real-device-2026-07-31.md", + "domain": "docs", + "kind": "markdown", + "material": true, + "bytes": 4179, + "sha256": "13ae5bd5dae925b7db7c178d213591f7ab30de942cd54523697b748ae35f99e1" + }, + { + "id": "file-96f36aa200c87f57", + "path": "docs/audit/2026-07-14/runtime-artifacts/automated/data-safety-shared-core-command-real-device-2026-07-31.md", + "domain": "docs", + "kind": "markdown", + "material": true, + "bytes": 2814, + "sha256": "84053ece518f8d920fd338c982a8e1fcdeb3a515d74d261bd3f525fc3deef5ba" + }, + { + "id": "file-e3db0e79bd7445ac", + "path": "docs/audit/2026-07-14/runtime-artifacts/automated/five-panel-layout-real-device-2026-07-31.md", + "domain": "docs", + "kind": "markdown", + "material": true, + "bytes": 4000, + "sha256": "f22d2512a6b2070bbc33c325cc4d386fab816c330fa6519d7162e9d664128719" + }, { "id": "file-7ea00bc6a5b00bcb", "path": "docs/audit/2026-07-14/runtime-artifacts/automated/full-suite-process.txt", @@ -2691,6 +2928,168 @@ "bytes": 31772, "sha256": "458f74c7bc9f923b92c6175e07ae9d76e190e6cbc0e48a59a250fb0d45338af4" }, + { + "id": "file-c885ea57086ec985", + "path": "docs/audit/2026-07-14/runtime-artifacts/automated/generation-finalization-2026-07-29.md", + "domain": "docs", + "kind": "markdown", + "material": true, + "bytes": 5612, + "sha256": "dad4f8d6e83bec8ff3b7470d0099fd7d02dbc0f7332ccf959308b558bfddb4f8" + }, + { + "id": "file-d380f5ce38f971b9", + "path": "docs/audit/2026-07-14/runtime-artifacts/automated/home-autosave-metadata-real-device-2026-07-31.md", + "domain": "docs", + "kind": "markdown", + "material": true, + "bytes": 4241, + "sha256": "5591ddc815e1f3462721e20a27225aee75472e3eef3c1d223b65ca232a3c35ec" + }, + { + "id": "file-63d9575de7b05c20", + "path": "docs/audit/2026-07-14/runtime-artifacts/automated/home-component-mapping-real-device-2026-07-31.md", + "domain": "docs", + "kind": "markdown", + "material": true, + "bytes": 5367, + "sha256": "1191d4ba63e2fe38b1188d23919db331b2bc042f7b7151b7ed6fd269e14813a9" + }, + { + "id": "file-5100a64989c60b5b", + "path": "docs/audit/2026-07-14/runtime-artifacts/automated/home-new-project-real-device-2026-07-31.md", + "domain": "docs", + "kind": "markdown", + "material": true, + "bytes": 4498, + "sha256": "85c36937c847659c1385abc4faed113fb7a55731537494cb391ec487d3569daa" + }, + { + "id": "file-73afff5d91e378d7", + "path": "docs/audit/2026-07-14/runtime-artifacts/automated/home-open-project-real-device-2026-07-30.md", + "domain": "docs", + "kind": "markdown", + "material": true, + "bytes": 3667, + "sha256": "7e01cae7c67906fa2f6cfc97b9e08953b5458ed60db334a21ff9be6ac085522c" + }, + { + "id": "file-a98cc4a8041f0ed3", + "path": "docs/audit/2026-07-14/runtime-artifacts/automated/home-project-lifecycle-real-device-2026-07-31.md", + "domain": "docs", + "kind": "markdown", + "material": true, + "bytes": 5004, + "sha256": "c13921c81edda9faad8a72aea1f611ff75f5ea29bdb076732cfef784dd4f0b9e" + }, + { + "id": "file-ae0979bad2fd9d32", + "path": "docs/audit/2026-07-14/runtime-artifacts/automated/home-sample-project-real-device-2026-07-31.md", + "domain": "docs", + "kind": "markdown", + "material": true, + "bytes": 3836, + "sha256": "14d9e1f38bb66555ed468c9b098138ef3cf278564d4fe611c9fb366c949a5a85" + }, + { + "id": "file-e88d648441e169a7", + "path": "docs/audit/2026-07-14/runtime-artifacts/automated/home-secondary-controls-real-device-2026-07-30.md", + "domain": "docs", + "kind": "markdown", + "material": true, + "bytes": 1884, + "sha256": "7938f27967d81e83764d70d27d8d3ed4a629de285617d2e6086328402fe36a2d" + }, + { + "id": "file-e4fba43f259c6d49", + "path": "docs/audit/2026-07-14/runtime-artifacts/automated/home-upstream-composite-real-device-2026-07-31.md", + "domain": "docs", + "kind": "markdown", + "material": true, + "bytes": 4361, + "sha256": "5f3b4043aeb873c091a74e87a1f4b4d8ec82c8eba1a248c09acc2b68491959c6" + }, + { + "id": "file-eb894f034460bde2", + "path": "docs/audit/2026-07-14/runtime-artifacts/automated/interchange-export-real-device-2026-07-30.md", + "domain": "docs", + "kind": "markdown", + "material": true, + "bytes": 5455, + "sha256": "7c4970ae750655ac15c6c04f51d507ed64a84112fc24322cb046b452f50ac67d" + }, + { + "id": "file-3c40cca6c471f31f", + "path": "docs/audit/2026-07-14/runtime-artifacts/automated/media-render-packaged-ffmpeg-real-device-2026-07-31.md", + "domain": "docs", + "kind": "markdown", + "material": true, + "bytes": 6559, + "sha256": "29288fc71aeff2afa528c63dd29946479498e2f2978e75f8d079737275785fd2" + }, + { + "id": "file-969b5b9ef06a22dc", + "path": "docs/audit/2026-07-14/runtime-artifacts/automated/recent-project-card-real-device-2026-07-30.md", + "domain": "docs", + "kind": "markdown", + "material": true, + "bytes": 2279, + "sha256": "1509612c43ba4983db4302af0c525f0e5ccc6211d49e9c4e4ac1ca0502772571" + }, + { + "id": "file-b1b835e0d75c41f0", + "path": "docs/audit/2026-07-14/runtime-artifacts/automated/schema-safe-persistence-real-device-2026-07-31.md", + "domain": "docs", + "kind": "markdown", + "material": true, + "bytes": 2798, + "sha256": "81b48176ccde05df7c9631cfd5575f7a75d42e100213ddcaeb057e996eb69111" + }, + { + "id": "file-eb77a9140aca8c0d", + "path": "docs/audit/2026-07-14/runtime-artifacts/automated/subtitle-export-real-device-2026-07-30.md", + "domain": "docs", + "kind": "markdown", + "material": true, + "bytes": 3852, + "sha256": "cdab73a5ed08eef986a9a41d4283fa140f59746a9555f3135043e622861e3feb" + }, + { + "id": "file-ce49c6bfc7ee6a9b", + "path": "docs/audit/2026-07-14/runtime-artifacts/automated/talking-head-cleanup-2026-07-29.md", + "domain": "docs", + "kind": "markdown", + "material": true, + "bytes": 7783, + "sha256": "a657183bedd399b7dc282e730c9f4f4bb7246457c45fb0c6fb267ad33c2329c0" + }, + { + "id": "file-3fc87d6771511b2b", + "path": "docs/audit/2026-07-14/runtime-artifacts/automated/titlebar-controls-real-device-2026-07-30.md", + "domain": "docs", + "kind": "markdown", + "material": true, + "bytes": 2944, + "sha256": "9b5a3da919a3fe06268a7a4e7be409d0843aeb8a436c71734dd9d4ac80890dcd" + }, + { + "id": "file-d7d1d7e9d40d98b1", + "path": "docs/audit/2026-07-14/runtime-artifacts/automated/view-menu-contract-real-device-2026-07-31.md", + "domain": "docs", + "kind": "markdown", + "material": true, + "bytes": 7809, + "sha256": "cf0403157312b2c2796156e85427665eaafc2f48a5b79db8640f16bb9f2668ef" + }, + { + "id": "file-6ff7841a9c971a60", + "path": "docs/audit/2026-07-14/runtime-artifacts/automated/view-menu-real-device-2026-07-30.md", + "domain": "docs", + "kind": "markdown", + "material": true, + "bytes": 2229, + "sha256": "97ecbc4f02dfb6915178b251e52d1c838ecc5f4317e5d3947c81a499f0d6c936" + }, { "id": "file-8d0ec76268da0668", "path": "docs/audit/2026-07-14/runtime-artifacts/browser/console-2026-07-14T11-11-54-004Z.log", @@ -3093,8 +3492,8 @@ "domain": "docs", "kind": "markdown", "material": true, - "bytes": 10069, - "sha256": "6c44b4cda515a10694ad282fe40a408f7ce1bb7cc9c2d3eb0f0711d38d201201" + "bytes": 10217, + "sha256": "7a29ca4230a44fcba05e5f4a2a38547d0512c0030c2a2405f5349ef4eff59dd3" }, { "id": "file-6e79ea78fc564798", @@ -3102,8 +3501,8 @@ "domain": "docs", "kind": "markdown", "material": true, - "bytes": 80834, - "sha256": "d994c216df3f66fcc3ce0ac997331dfffb4c74f0dc84b35dd599a1ba8877d2ab" + "bytes": 86518, + "sha256": "0709c5f5041ad9448ffd9de83b598721a9c629c340f75b63d8c9a797fb6b6b8b" }, { "id": "file-cfd18ced3f48a451", @@ -3138,8 +3537,8 @@ "domain": "docs", "kind": "markdown", "material": true, - "bytes": 8779, - "sha256": "e317b63a966cfb2924454c9204fc959a12b163c2bce39706635351849a9340f4" + "bytes": 9266, + "sha256": "dbb941d2c5b2d41a382e6d699136bfe3acfc0077f0f31ceb265e07f3c89a31d8" }, { "id": "file-36de69af75ca13ae", @@ -3147,8 +3546,8 @@ "domain": "docs", "kind": "markdown", "material": true, - "bytes": 4765, - "sha256": "d02358946647ab2c39e60ca65198a365d6bc419d97e38f8174d72ca6111f0ccb" + "bytes": 4927, + "sha256": "fb298dcf4f38b21e4342cd03b3f0c2930b8f7d5844dc302394c72d60b70de040" }, { "id": "file-6ee449d7112fb9f0", @@ -3183,8 +3582,8 @@ "domain": "docs", "kind": "markdown", "material": true, - "bytes": 17726, - "sha256": "6504f6519e4aac219f249e8e63f9ecdaa5e7b6fd0f4ed078999fe06b3e18b0f7" + "bytes": 18063, + "sha256": "3c3f4b1a8137279f683ea9618a36da1849747b85baa0da9313fc4c5e9209d79d" }, { "id": "file-4ea218993d069cb2", @@ -3192,8 +3591,8 @@ "domain": "docs", "kind": "markdown", "material": true, - "bytes": 44656, - "sha256": "b277ff6c963445db64146ff80472c5cfe18a864878a854410dce93f7a04495cb" + "bytes": 45683, + "sha256": "ba6704f020cd34ae8841b62b8bb07e2c56b7c37b5f6e10e6698445a94d396974" }, { "id": "file-1af20158beda1e67", @@ -3201,8 +3600,8 @@ "domain": "docs", "kind": "markdown", "material": true, - "bytes": 8498, - "sha256": "f2a739caa9badca74de8a5add35c9e9bd4bdf81f6a2dec2fd63bf44505c220db" + "bytes": 9155, + "sha256": "d769c6c5197e8aeed15b3faead36e9f297a4dbf50dcbe300c1d6056a721c1524" }, { "id": "file-6bdb79ad3ef2746f", @@ -3228,8 +3627,8 @@ "domain": "docs", "kind": "markdown", "material": true, - "bytes": 5140, - "sha256": "4e650bc1848f36c1c59157622eb454a0275fcde262c995ff527b854e0f0f1f7d" + "bytes": 5377, + "sha256": "ad6ba4bcdfa59d563a4c1ac4b121e145d62c257b4df1ffd4abf3def9f0746746" }, { "id": "file-43ff2815bbda63d1", @@ -3399,8 +3798,8 @@ "domain": "docs", "kind": "markdown", "material": true, - "bytes": 71677, - "sha256": "d660d9ca8bd3e78b34dc330dadda92f589e73d0603f1dd356b36b2e457bdf835" + "bytes": 72012, + "sha256": "9c52274ae5e614af29fde8c40d1b6fd52db0d944fb9184926d4e5df9ece5e553" }, { "id": "file-ad3a63f0cbab340c", @@ -3570,8 +3969,8 @@ "domain": "docs", "kind": "markdown", "material": true, - "bytes": 9489, - "sha256": "ab3efd126a63e3ec7caba4cd5fead8bc686fb17d6d954533d818eb3224e9cb48" + "bytes": 9549, + "sha256": "d75c125d327871150bdba3f73c0deb573058faff012d58eebf689e5992ce5b25" }, { "id": "file-859acbc80102a569", @@ -3579,8 +3978,8 @@ "domain": "docs", "kind": "markdown", "material": true, - "bytes": 6679, - "sha256": "c226ad4ec80cd1c48ffae8496630699d799151678861375bba525c858b550c5c" + "bytes": 7143, + "sha256": "67ef8a564695bae527b0631b9de97a2c7f1d31f7c63a1ae6f5bd3558bca96661" }, { "id": "file-5211d3f15216e422", @@ -3606,8 +4005,8 @@ "domain": "docs", "kind": "markdown", "material": true, - "bytes": 8695, - "sha256": "e7d23207d7fdb738087f100b164c12cb258b72d749c51dd88fb307202b4b319e" + "bytes": 8788, + "sha256": "f8723bf6d6e3d1fe6f501856be5c512828b0072ea884ca56444e9646c8de7ec4" }, { "id": "file-b49e100b23dac8a3", @@ -3831,8 +4230,8 @@ "domain": "docs", "kind": "markdown", "material": true, - "bytes": 93013, - "sha256": "9b545b417dc58a4bf710911aeb6fdba6bf32720d4f33419d88430cb424a63111" + "bytes": 93662, + "sha256": "d0584b9914d8a98eb554c93f8740e81c20dcc94b27c14e7041c5a4caa77d70fd" }, { "id": "file-d2c0e61a0d7f9fa2", @@ -4101,8 +4500,8 @@ "domain": "docs", "kind": "markdown", "material": true, - "bytes": 8919, - "sha256": "45951c85a4ddf6f9bb1e5a96fdfc677ea6b8350e8b2d1669a1b08db79db91487" + "bytes": 9211, + "sha256": "76bf71c78f8ba77ea28a905d0dd21f95e05734c39d5a73fa23bccaac9d911aeb" }, { "id": "file-d145e72dcdfaff35", @@ -4110,8 +4509,8 @@ "domain": "docs", "kind": "markdown", "material": true, - "bytes": 13108, - "sha256": "da443d479626f4a9c1504ffc190b7a7752763e877c135d7f4571bf0d2d0d2621" + "bytes": 14205, + "sha256": "be777b6fab9ca32a40821eaf1d0aad37ff1c7c13b62cc99d5e7707bb73811e5c" }, { "id": "file-ce82f13f0b6f2e2d", @@ -4128,8 +4527,8 @@ "domain": "docs", "kind": "markdown", "material": true, - "bytes": 7598, - "sha256": "331e34581082811a0b9db2d7bc0059f888b0a73c4517350805496ccbede62ea9" + "bytes": 8893, + "sha256": "db3b5b4a0336a9c4995d6ea885d2df2762925decfd504249e460946f8b132b5f" }, { "id": "file-9aafdc64ce09390d", @@ -4191,8 +4590,8 @@ "domain": "docs", "kind": "markdown", "material": true, - "bytes": 4613, - "sha256": "1bfb31a1c62ffd598f155abce75a8edf3d9c6f59b248bcb74afe9e6b670347ac" + "bytes": 3850, + "sha256": "5b481868925c04386aa6663375438cf053034637536fdcaca7a7b9364c3df54c" }, { "id": "file-be2fb85c84fd3ddc", @@ -4200,8 +4599,8 @@ "domain": "docs", "kind": "markdown", "material": true, - "bytes": 12450, - "sha256": "50dc3691d26ef7fc4586c006f4861560fde9cbe9551dbb740b46b7069a9346f8" + "bytes": 5910, + "sha256": "ab3192a88e5def8f0527edf29364d0c47d026d1d36c59cadd69d1c4549b8a944" }, { "id": "file-a86f23f3bc6af6af", @@ -4227,8 +4626,8 @@ "domain": "docs", "kind": "markdown", "material": true, - "bytes": 4743, - "sha256": "22acbc3e2e53d0533ccc7d8fbbca15507806bdb54499884ee85524aba69d3407" + "bytes": 10646, + "sha256": "eb2444e167806a412ded18d4df95f0dc41d72445cc876cc144f9e356eb522265" }, { "id": "file-d11e8042e30d85ff", @@ -4308,8 +4707,8 @@ "domain": "docs", "kind": "markdown", "material": true, - "bytes": 7486, - "sha256": "18ce1457fbe91a29af46817a4bfb2622e96836ca3206bec0aec3cb0ddd97a82d" + "bytes": 8135, + "sha256": "c05de4f65e3a4f5b0945b5b70c04db3372a491a3f7ed5ce877fad2815dd435e7" }, { "id": "file-1dfc0f776a5b444b", @@ -4317,8 +4716,8 @@ "domain": "docs", "kind": "markdown", "material": true, - "bytes": 8932, - "sha256": "1ad41dab60433f3681d5c16b8fcf83eafb8fc17b7a7b701bca72c30d0f3e4ac9" + "bytes": 10603, + "sha256": "6eb9c81147c6143a82a49902e7a611e211c61a8e1fe87c5245f338e7c6ab2290" }, { "id": "file-8e724e1c1c6c87de", @@ -4326,8 +4725,8 @@ "domain": "docs", "kind": "markdown", "material": true, - "bytes": 6088, - "sha256": "41ea87d051268fb9dd45fc581a830f047a0f83496d01efe9def0803f07a40748" + "bytes": 8267, + "sha256": "78bf26847ac2c4bb639aad6621063c06d62c306bb962a7ed619964632f37c914" }, { "id": "file-35747fb472fe0e58", @@ -4425,8 +4824,8 @@ "domain": "docs", "kind": "markdown", "material": true, - "bytes": 7520, - "sha256": "f3ba0cd11fa042427fda7e8225ea57285a620f0f0ba85d0938ec1e5fba8c3bcf" + "bytes": 8825, + "sha256": "0fc65b9e67b94b37f4d31e25c04f0c04c5b0026b470da8043841e9355b5d9fcc" }, { "id": "file-12ed6f818f14ff81", @@ -4599,14 +4998,23 @@ "bytes": 38121, "sha256": "511056be3b3dd9ebecd1ea83f66e9b57b20d18f496605af2b069aaf078ce1b64" }, + { + "id": "file-8fe6485efceaebea", + "path": "docs/superpowers/plans/2026-07-18-long-media-playback-handoff.md", + "domain": "docs", + "kind": "markdown", + "material": true, + "bytes": 11432, + "sha256": "f9a9457707a334a25e3338f101d3464c5ab1ea00d9973858054553bc13f8064c" + }, { "id": "file-e5d42b2a62372214", "path": "docs/superpowers/plans/c1b/2026-07-12-c1b-common-unix-normative.md", "domain": "docs", "kind": "markdown", "material": true, - "bytes": 147928, - "sha256": "e6b4ca142cf8ed4c4ce682a5638f352d8a1bd97d1f714557e849ad08631739c8" + "bytes": 158012, + "sha256": "6afe42ab56f9295ec15d8b3f07bebc1a3fae9a772d5d3b6ed6bd36a8403e84f5" }, { "id": "file-7ddb729677321284", @@ -4686,8 +5094,8 @@ "domain": "docs", "kind": "markdown", "material": true, - "bytes": 29482, - "sha256": "8b689173989513e923997f1666ed7d6468638912f23462a8797cdf62f6694d0c" + "bytes": 30119, + "sha256": "bd7687460f54cc7a440c80c6745a4f527dcdaf4f58058727cd4a120431fd9069" }, { "id": "file-6a6a9091058f9ed3", @@ -4734,14 +5142,113 @@ "bytes": 66, "sha256": "a6a0bbd29ffaa8182dc22d1d9149709f1091e47df40ed96eb8a78a711c66a4ce" }, + { + "id": "file-406b1de1dbb0e467", + "path": "scripts/c1b-evidence-policy.json", + "domain": "repository", + "kind": "json", + "material": true, + "bytes": 1639, + "sha256": "8bab67a40c0c4c68e4af484258698ef7f5b5b62223355923c133ee1068dad5c2" + }, + { + "id": "file-92b244dd280fe9ba", + "path": "scripts/check_windows_product_ci.py", + "domain": "repository", + "kind": "py", + "material": true, + "bytes": 8440, + "sha256": "2be095bf7c896fa8345e8eb59e4db1317bd14b50c56ef001a0284305a660d574" + }, + { + "id": "file-6c3ede1b6dc05f8a", + "path": "scripts/ffmpeg-sidecars.lock.json", + "domain": "repository", + "kind": "json", + "material": true, + "bytes": 1728, + "sha256": "c27e2934365952cf7ef7b5d24227b99e418d4f831f7cc617283bbc9951a9f141" + }, + { + "id": "file-e93a076f3ff35274", + "path": "scripts/provision_ffmpeg_sidecars.py", + "domain": "repository", + "kind": "py", + "material": true, + "bytes": 5045, + "sha256": "8fc7a2ad2d89641d654e2131f3f0caa58d82907a30e04a2fbe13119b30b399a9" + }, + { + "id": "file-b507e76382a66cfa", + "path": "scripts/run-c1b-windows-red.ps1", + "domain": "repository", + "kind": "ps1", + "material": true, + "bytes": 6551, + "sha256": "f3579b3b17312daa16f0c1a07a2179263576fab064c188204e4a474023a39f81" + }, + { + "id": "file-db6b5d0dcebd41d9", + "path": "scripts/test_check_windows_product_ci.py", + "domain": "repository", + "kind": "py", + "material": true, + "bytes": 4198, + "sha256": "13e0c7a6703244fc068e997fa4ba5b05b161165cb714cd0b3f8c2b4dbb33d225" + }, + { + "id": "file-3cf66ed3ec1da8d5", + "path": "scripts/tests/packaged-sidecars-test.rb", + "domain": "repository", + "kind": "rb", + "material": true, + "bytes": 6313, + "sha256": "1ec6e4acf8e7cfc1f63dcbdd444e455b6d0f3ed943d2bca3d5e2357688ed73e9" + }, + { + "id": "file-83e5630c0e7aaafc", + "path": "scripts/tests/validate-c1b-ci-test.rb", + "domain": "repository", + "kind": "rb", + "material": true, + "bytes": 8310, + "sha256": "3f3d427e0c86f713a7fcaba763cc448452bd8885a3d56f2cc972f6049ba082b6" + }, + { + "id": "file-bfcb1cd9bc3e3d66", + "path": "scripts/tests/validate-c1b-evidence-test.rb", + "domain": "repository", + "kind": "rb", + "material": true, + "bytes": 16604, + "sha256": "9761a46ca0533eb68228bdae3d8226baf8f8074ac28a85f63690470e301fa46f" + }, + { + "id": "file-bfe40f47416289d4", + "path": "scripts/validate-c1b-ci.rb", + "domain": "repository", + "kind": "rb", + "material": true, + "bytes": 25546, + "sha256": "44ababedff2147ca19aa21029243e91056f10c3d1f1a76ec412529dd93f1950c" + }, + { + "id": "file-b74e067ea1583cd8", + "path": "scripts/validate-c1b-evidence.rb", + "domain": "repository", + "kind": "rb", + "material": true, + "bytes": 25164, + "sha256": "5fcb9279a0c2804fe36c0ea7aed24174697d7d3d3e80be38255d5e1b0adca214" + }, { "id": "file-fe4c6a80a21e8133", "path": "src-tauri/Cargo.toml", "domain": "src-tauri", "kind": "toml", "material": true, - "bytes": 5052, - "sha256": "9c5cc87125dd3468d544d401e8aaf0d83664252cfa8ff1b3e55a090791674d31" + "bytes": 5477, + "sha256": "36d32fb1df2cafc00e93e7de018c00dff8202738fb99aa00cfd325b3f1a1effd" }, { "id": "file-f18b5d0480de73d6", @@ -4767,8 +5274,8 @@ "domain": "src-tauri", "kind": "json", "material": true, - "bytes": 382, - "sha256": "dfb2175923b3755374f6e346b16372e669f67f06dd9c7131ef8eacc8cfca4ef6" + "bytes": 422, + "sha256": "8617455e2f5abf707cfb0ffd07ded32a4054d8c1f9395f9e74d0c439e71ae95d" }, { "id": "file-f59990cf7bab6341", @@ -5256,14 +5763,23 @@ "bytes": 25113, "sha256": "5d5d05b28e5158b7d31958393f27cf232a49820fa90f8b5f7668c295c8907df2" }, + { + "id": "file-33220c83f87dedb1", + "path": "src-tauri/resources/ffmpeg/SOURCE.md", + "domain": "src-tauri", + "kind": "markdown", + "material": true, + "bytes": 764, + "sha256": "3376bc2261e9363ed3b994cc1cec479172eb4cb82508d180569d5800d23ef58f" + }, { "id": "file-d255f2ce76ebbf70", "path": "src-tauri/src/account.rs", "domain": "src-tauri", "kind": "rust-source", "material": true, - "bytes": 41295, - "sha256": "4c47ad709160067ca18c61184ed3d1d4563ccca7e1d8625dd8f3c1f6854798d0" + "bytes": 41855, + "sha256": "cbc4690482e19e79d49571945f2ab2c1979d38689dcb0180911e36d5298c556a" }, { "id": "file-e7f96b59876dbd63", @@ -5271,8 +5787,8 @@ "domain": "src-tauri", "kind": "rust-source", "material": true, - "bytes": 20831, - "sha256": "0bedade0213039c70e0d72fe17386f4b43bb97bd07424baaf65ef223c54e85c6" + "bytes": 22745, + "sha256": "4c597020fa49b74d45cb62e0561fc3c6490d91838feb046b3c6b63b6cf05e497" }, { "id": "file-5e85f18652484a37", @@ -5280,8 +5796,8 @@ "domain": "src-tauri", "kind": "rust-source", "material": true, - "bytes": 10056, - "sha256": "54cc5ab9849b9b54a96eed63da6335ebcae43a0afe42fd80119a4b14c8c1f208" + "bytes": 40779, + "sha256": "82759d3171a128ac59448f5564c02fc4e9b06bbf4e6399454607fb32b7857a0b" }, { "id": "file-59f7096c7cd88d5d", @@ -5289,8 +5805,8 @@ "domain": "src-tauri", "kind": "rust-source", "material": true, - "bytes": 68089, - "sha256": "007588fa90f951f23945d4a28a5a27d090b77dc8692fb088ac57e6dbffab86e1" + "bytes": 82764, + "sha256": "d3cddb2e089c23063383cf52359b88d29ecadeb391cb9272837a709463bb4e00" }, { "id": "file-2217a1d7692b2f2b", @@ -5298,8 +5814,17 @@ "domain": "src-tauri", "kind": "rust-source", "material": true, - "bytes": 110692, - "sha256": "4340ea3fc1563d83122a622e5ea307b9b6b9c97418d378379ae78283a3e1a10f" + "bytes": 110999, + "sha256": "091e628dc4f23724ca48dc1ab6f6b62c81b59fba1100c1ef29e5fe3065d2b183" + }, + { + "id": "file-f9fc2f27bf264d59", + "path": "src-tauri/src/generation.rs", + "domain": "src-tauri", + "kind": "rust-source", + "material": true, + "bytes": 111462, + "sha256": "1b93b03d0a04750c4f38f6656f94a4f1b174f64e9b007af0b44a200eea5bd5d8" }, { "id": "file-3b21d6462693c8f2", @@ -5310,14 +5835,23 @@ "bytes": 1321, "sha256": "600f61b845dd4b9f3ab03f2109019be6b31b25a323c1a9c37d92d544b5046635" }, + { + "id": "file-bdd5ffa7d50672d9", + "path": "src-tauri/src/home.rs", + "domain": "src-tauri", + "kind": "rust-source", + "material": true, + "bytes": 19075, + "sha256": "e34268067bac1021cb8e476520628441af448bf4a0f120d7953997f65e6d9b18" + }, { "id": "file-eabcebd0ae5c9b77", "path": "src-tauri/src/lib.rs", "domain": "src-tauri", "kind": "rust-source", "material": true, - "bytes": 14877, - "sha256": "1183a88ab90f26afca08200e2a3415e7326c55dc52a49940c078db75ad8f6df7" + "bytes": 17973, + "sha256": "6dbb0c63db54c657229ca74b8da78b550314a705e95d988ceea45b71b3c71687" }, { "id": "file-18c6c092770d0fbe", @@ -5325,8 +5859,8 @@ "domain": "src-tauri", "kind": "rust-source", "material": true, - "bytes": 71475, - "sha256": "ff64d2fc02180e8e2284b8e179390e534fd489b636f3fcda5e1881d59dcc961c" + "bytes": 72888, + "sha256": "51f72d0af40fce584a042fd21add477f863ee6c3cc9e1a03f94f7dfd45f8e08c" }, { "id": "file-2f5e0a90d4195e99", @@ -5343,8 +5877,8 @@ "domain": "src-tauri", "kind": "rust-source", "material": true, - "bytes": 48986, - "sha256": "6d7338deb75219ee09df5f0eff489c45bddc71adaf5b98711a6626bda32005c1" + "bytes": 106525, + "sha256": "452ecc644f03fd26a0e07a2e1e34a0f1e7c2a1b941e2400807fd8121f397b8f2" }, { "id": "file-50631421194847f6", @@ -5352,8 +5886,8 @@ "domain": "src-tauri", "kind": "rust-source", "material": true, - "bytes": 165720, - "sha256": "0876cf1ebb2305f1d94612c890adc4e0432d5680953fba2392762d92655dc9b4" + "bytes": 181211, + "sha256": "fc3fa56f07641bba90504ca4e41184adeae640d461afda3279cde5383754712f" }, { "id": "file-00fccdeb612425bd", @@ -5361,8 +5895,8 @@ "domain": "src-tauri", "kind": "rust-source", "material": true, - "bytes": 24102, - "sha256": "21840437fb31d1e2c24cb10547de4c31f864c14ba62527ecaa5dd94bfa68aca1" + "bytes": 32333, + "sha256": "cd385b477676a0fe11eb6c3c2ae45a018d2e498c41e9c82708f6de7ef4c4fc8d" }, { "id": "file-999847d88a2d0b9a", @@ -5370,8 +5904,8 @@ "domain": "src-tauri", "kind": "rust-source", "material": true, - "bytes": 54707, - "sha256": "71b437594257f32919a77f18239f17cdfa6ba2aad7683f75d59a8b09d849719b" + "bytes": 57652, + "sha256": "4e8486d97276c666e6efc682b2090046fefef07cd451731cccf960d06d5000db" }, { "id": "file-66f953856cccc888", @@ -5379,8 +5913,8 @@ "domain": "src-tauri", "kind": "rust-source", "material": true, - "bytes": 67155, - "sha256": "ae4ce868f394f44e2c7418baeb7bad2d33abbf10c11406ddac240fa103019e05" + "bytes": 70089, + "sha256": "f04f6c17e2f8e34f3fd9d8cd485e56e39f20db2d7c5aa14453ee241b8e4d110d" }, { "id": "file-2598bce61e9e2dfc", @@ -5388,8 +5922,8 @@ "domain": "src-tauri", "kind": "rust-source", "material": true, - "bytes": 27395, - "sha256": "ffd6876f5d90bd460c74faecfecf09f06d0c0f1f87b7022253b772507490abe4" + "bytes": 30854, + "sha256": "d17b2024e15184abce32c960855c1016c9b71bb9d18cac085c17212f094de63b" }, { "id": "file-5d4e1d555d02fcc7", @@ -5442,8 +5976,17 @@ "domain": "src-tauri", "kind": "rust-source", "material": true, - "bytes": 33530, - "sha256": "27d02b643ae5f6b9efd1b4bde642ce2121ec6f3d632f4ab8113b5f7d009f3d52" + "bytes": 41711, + "sha256": "6f321af99a4298683ff51044a2709cb3ce5d651796cfcd248e1fc9fdb6fc725e" + }, + { + "id": "file-03f150fc445ab2dc", + "path": "src-tauri/src/samples.rs", + "domain": "src-tauri", + "kind": "rust-source", + "material": true, + "bytes": 19478, + "sha256": "ca3f6f910fb1dcd1a184db685520d632d8071b874cf7b62fda6b7b2d62fd5965" }, { "id": "file-ddf9ce8b65978bce", @@ -5451,8 +5994,8 @@ "domain": "src-tauri", "kind": "rust-source", "material": true, - "bytes": 31768, - "sha256": "9538fa801add2d18884bb487e30617dd50197e0b0bb5448d2af3ed8707676394" + "bytes": 32050, + "sha256": "a1704f73d7411bdae185cf8172f24c962314bccc4a0a649285107d096388c366" }, { "id": "file-a6c368a30424c97c", @@ -5460,8 +6003,8 @@ "domain": "src-tauri", "kind": "rust-source", "material": true, - "bytes": 5198, - "sha256": "b21373f474152f36d5437a34e5af88446c421cd17642f7304bd438554110eef5" + "bytes": 5388, + "sha256": "abc7729d0a0c1b21ce39af5608ed39d3924b557d3a34369a8d0259a9f2e40637" }, { "id": "file-57080efd4faf1903", @@ -5469,8 +6012,8 @@ "domain": "src-tauri", "kind": "rust-source", "material": true, - "bytes": 14408, - "sha256": "d0b654ab6a4af4a8f9a624db087a71d8b307e12bc2fe990857144813c85b2f7f" + "bytes": 14796, + "sha256": "48635db15628e4cc2d49460789cd2280a59fdb6959a859838aed4508243f66dc" }, { "id": "file-fa09f1dac39604a7", @@ -5478,8 +6021,26 @@ "domain": "src-tauri", "kind": "json", "material": true, - "bytes": 1242, - "sha256": "89f1428d2ef298adb9666f15915e4bc6dc6e16c682d6e7659bad41b399432088" + "bytes": 2584, + "sha256": "52702cef2028bca07201ca14567499922d88020ce610a0d6ec485bd8419cff63" + }, + { + "id": "file-95eab2aa2d281ed9", + "path": "src-tauri/tauri.macos.conf.json", + "domain": "src-tauri", + "kind": "json", + "material": true, + "bytes": 99, + "sha256": "00bd4ff49f5b2b4842502c20d54e209400ed8db3059c6d0a94166dab0c0c0401" + }, + { + "id": "file-7a5194f01045763e", + "path": "src-tauri/tauri.windows.conf.json", + "domain": "src-tauri", + "kind": "json", + "material": true, + "bytes": 220, + "sha256": "044e0eb7fe483d5cb7deeeac974b30ec59e01ab35960751c538e4044a688c5dd" }, { "id": "file-c0ae4406ea250661", @@ -5523,8 +6084,8 @@ "domain": "src-tauri", "kind": "rust-source", "material": true, - "bytes": 22577, - "sha256": "98853db5ab46be4de90cccb6b08afda22c356c515651f32024810385f9a7e770" + "bytes": 22586, + "sha256": "4b2d5b3c3f159cf90b86e79c7aff37124c80f450ea69ba639746dbbf814c5bd8" }, { "id": "file-52d189b1203fc821", @@ -5532,8 +6093,8 @@ "domain": "src-tauri", "kind": "rust-source", "material": true, - "bytes": 12702, - "sha256": "d909cc571692faf1e02dccab0625cca212c74bf509c188239a37e57f6a1b8080" + "bytes": 16978, + "sha256": "15cf5d7efd3d6c1f7fb79e0d663ae63dd1f40ec081f6b466672e22e0c362db22" }, { "id": "file-021489b54faed6f9", @@ -5553,6 +6114,15 @@ "bytes": 3678, "sha256": "715ab380a28d558da9f99cc4907e3e45bc85b1f1cf9464979f51e7bc288b6221" }, + { + "id": "file-a9ea0bda9366065f", + "path": "src-tauri/tests/security_config.rs", + "domain": "src-tauri", + "kind": "rust-source", + "material": true, + "bytes": 5691, + "sha256": "c9e978cb7947749b5e38cc4508677b3cee4dfad564cdd37395f054f806953d8b" + }, { "id": "file-f134f174d6014a06", "path": "tools/completion-audit-controls.mjs", @@ -5613,8 +6183,8 @@ "domain": "web", "kind": "json", "material": true, - "bytes": 703, - "sha256": "44b8e2d0d82580a6f243b3f903d730a989620b59a55383dd3142e20c450b5ee1" + "bytes": 729, + "sha256": "25294db641337caff6ae358337915ffffa6124726d3cd7000d4f49d752f21233" }, { "id": "file-f11bfba22b3604b3", @@ -5622,8 +6192,8 @@ "domain": "web", "kind": "yaml", "material": true, - "bytes": 35923, - "sha256": "2ac19dc2da235d2348a7fae9c9b577e8f1ed4856795e5e324ddc66c87f044dfc" + "bytes": 35989, + "sha256": "9b19bf159f1c706d87ecb22d4ea3253694e58ed4a9218b8b4edba4a79a3c5b58" }, { "id": "file-4829df35b6769af4", @@ -5640,8 +6210,17 @@ "domain": "web", "kind": "tsx-source", "material": true, - "bytes": 4049, - "sha256": "2116f9c1b6164b0ceef1fd8192a35ef48630bfec17a3fa753d66ea1978ff916c" + "bytes": 4150, + "sha256": "635b256e45f1aeb04e04f95f0b64d7c8e34d3ca819e4cc7bb4e570ae9172e4f7" + }, + { + "id": "file-66c4f3d81c7cc0d6", + "path": "web/src/components/agent/AgentPanel.persistence.test.tsx", + "domain": "web", + "kind": "tsx-source", + "material": true, + "bytes": 13265, + "sha256": "b2358987521a66c4e4bf77708ee11ba7cdeafe70bcd4c23c05d5192d13683f4c" }, { "id": "file-01475de413560f94", @@ -5649,8 +6228,26 @@ "domain": "web", "kind": "tsx-source", "material": true, - "bytes": 12811, - "sha256": "2ff15bea101796ddcc29987e99f537eb69a4166b70a6f575a70405ad9938eba8" + "bytes": 22823, + "sha256": "7f42c17f75c256f8acfd4bb4d735a8dee6abde9078ab47cbe49baaad890dc78b" + }, + { + "id": "file-71317cb158cfdd21", + "path": "web/src/components/home/HomeView.interaction.test.tsx", + "domain": "web", + "kind": "tsx-source", + "material": true, + "bytes": 13456, + "sha256": "76ca69e64822aaa44d2152836c024a75204581643d8d636fa96c8bd8eede8daf" + }, + { + "id": "file-20d0262d3bce9fa1", + "path": "web/src/components/home/HomeView.test.tsx", + "domain": "web", + "kind": "tsx-source", + "material": true, + "bytes": 10286, + "sha256": "ae3e1b641ceee367cbbcc0e8f7275a1fd73366ce585aff285fdce5237eba86c5" }, { "id": "file-0bae1db2d44e11b0", @@ -5658,8 +6255,8 @@ "domain": "web", "kind": "tsx-source", "material": true, - "bytes": 15348, - "sha256": "27fdd26cc39b6811b21d1449ce724e679c19559029ff64e8ed497153bba9a552" + "bytes": 31945, + "sha256": "904b195ef1de1b2aba20e91cd93b3fafc7d518c0e6589ecfb363b73574c480f2" }, { "id": "file-0d2496801df2e6df", @@ -5667,8 +6264,35 @@ "domain": "web", "kind": "typescript-source", "material": true, - "bytes": 4379, - "sha256": "b6a40f7822e4fa1f48adc431abad75a2ef2fd0a0d92da69dd007a7dd1e5e3e7e" + "bytes": 4403, + "sha256": "055d17ec5adfbfc3d025981930d50fcd81fed45e38e9c996fe95db6738e0d6bc" + }, + { + "id": "file-d808504759f5960a", + "path": "web/src/components/inspector/AiEditTab.test.tsx", + "domain": "web", + "kind": "tsx-source", + "material": true, + "bytes": 5426, + "sha256": "556fa69ea802dbe9e3b259ac4400fb1d49e7ac3752d84dd1e703e58a0c4a0666" + }, + { + "id": "file-1eb7fa0bb326f539", + "path": "web/src/components/inspector/AiEditTab.tsx", + "domain": "web", + "kind": "tsx-source", + "material": true, + "bytes": 12235, + "sha256": "b70d9c2de8740d8a06db613489f1cfe68b5b4e00527f32569704d5305f5ee44f" + }, + { + "id": "file-4c99bc1c92c2ae63", + "path": "web/src/components/inspector/Inspector.test.tsx", + "domain": "web", + "kind": "tsx-source", + "material": true, + "bytes": 3914, + "sha256": "1f13e4fae7da55ed7e9e73fe53ee4a1ea239dd433f369d9b13edefd5d8b2b527" }, { "id": "file-675291e4c2699421", @@ -5676,8 +6300,8 @@ "domain": "web", "kind": "tsx-source", "material": true, - "bytes": 55551, - "sha256": "74b95586764a50b50879694a9bfb33de91bf048157dc9410a3ae9017d606ffad" + "bytes": 56666, + "sha256": "ae3a0953826b8a627dfaa44275f73215ba62eafbdfa02235caab5526b28a11e4" }, { "id": "file-85fd6e55cc553b74", @@ -5766,8 +6390,8 @@ "domain": "web", "kind": "tsx-source", "material": true, - "bytes": 7684, - "sha256": "23aeceac7ece7f85dbb12658c26443c12aaeda6068e7072fa8a004888a17035b" + "bytes": 9677, + "sha256": "ef6cb8021989af9169775bdc3f804180b78925c0312ae483e7ba5f6b14a611fc" }, { "id": "file-dcf82e6012ef7453", @@ -5775,8 +6399,8 @@ "domain": "web", "kind": "tsx-source", "material": true, - "bytes": 41613, - "sha256": "7b078bc0c59f8edd84c440282815200c185dd07cabf3f6b289e4eddb20f18724" + "bytes": 46981, + "sha256": "a09918fccd090417d435db4125d49eb05451e656c43864619c4b023c3d8fa15f" }, { "id": "file-e90b04dae96268a8", @@ -5784,8 +6408,8 @@ "domain": "web", "kind": "tsx-source", "material": true, - "bytes": 20516, - "sha256": "eee013fdeddc9b3df20fbc6dc3a073070ebbb0ddea549dd3943405ec231df1aa" + "bytes": 21322, + "sha256": "deca4f69b193b50c973d7897be4bca5492bb069a0f074f32843aad0ec3a0ec87" }, { "id": "file-4c2341f1cd34504c", @@ -5793,8 +6417,26 @@ "domain": "web", "kind": "tsx-source", "material": true, - "bytes": 5938, - "sha256": "57147948e84caae714ea4fbbf3589c20a1446af7118c58c051d8042f45730e63" + "bytes": 6018, + "sha256": "7a32cbc82650053df30173acf5be3d1a80162ca095d5ba2857f45f0830022096" + }, + { + "id": "file-5ad97148f47525f1", + "path": "web/src/components/media/MusicTab.test.tsx", + "domain": "web", + "kind": "tsx-source", + "material": true, + "bytes": 3982, + "sha256": "720a3603cbe109d19db4d472804c70bb9e3d0ece3d549a6eb6151f94f2fc0e8f" + }, + { + "id": "file-22b051b2f0a8fc02", + "path": "web/src/components/media/MusicTab.tsx", + "domain": "web", + "kind": "tsx-source", + "material": true, + "bytes": 9546, + "sha256": "9124992fcf1ab15d4f4309072bfc505c285aeb685d8dbf7701c0b266c99c5851" }, { "id": "file-26c10bb912126d39", @@ -5802,8 +6444,26 @@ "domain": "web", "kind": "tsx-source", "material": true, - "bytes": 4308, - "sha256": "79a95809921511c6703f3dd58158df2db424fa4c9cd04bf0ca6cb363e7455541" + "bytes": 4379, + "sha256": "91496e956c41f315084cab7721c89e87cefcfd1bc68ccce9a9e4f3ac36abdc73" + }, + { + "id": "file-548e84d4c7481863", + "path": "web/src/components/media/TransitionTab.test.tsx", + "domain": "web", + "kind": "tsx-source", + "material": true, + "bytes": 7101, + "sha256": "df01b7c97e86bf81b33fffdcee22a573c00a14e916ceb773b51e9bbd0770def9" + }, + { + "id": "file-0635887fc0d7cb3d", + "path": "web/src/components/media/TransitionTab.tsx", + "domain": "web", + "kind": "tsx-source", + "material": true, + "bytes": 8148, + "sha256": "d88008d36038bba0a6a1ea2c2f4dd014aa96ecc6d69e088fc6182f488e7fade8" }, { "id": "file-b834e0a1ed863cfe", @@ -5838,8 +6498,8 @@ "domain": "web", "kind": "tsx-source", "material": true, - "bytes": 10945, - "sha256": "95915ae0bec0ccbd031c2d474dde1b3025bcec29195d7e2d569927a0df45682b" + "bytes": 13595, + "sha256": "70389716cfa093417cffcd7dac88d31d905d8b806bcfd055a77ff04030e8fda5" }, { "id": "file-5b23ea46f33052b0", @@ -5847,8 +6507,17 @@ "domain": "web", "kind": "tsx-source", "material": true, - "bytes": 38228, - "sha256": "c4c037b5ae2315ce19c97a22bc92cd2971379b47b7852ebdd883dc0b28b8ed3e" + "bytes": 40554, + "sha256": "38c6d4bc78d1d843e3cb9cd05dff273acd297dd03d3cda0280247a39cf8f59eb" + }, + { + "id": "file-2da2e6e91da15cdc", + "path": "web/src/components/preview/RustFrameBuffer.dom.test.tsx", + "domain": "web", + "kind": "tsx-source", + "material": true, + "bytes": 14596, + "sha256": "9cbb5d9fc884a15092651a40be87e874333185822409138224c5de65f1c75d5a" }, { "id": "file-af4faf0757db4b51", @@ -5856,8 +6525,17 @@ "domain": "web", "kind": "tsx-source", "material": true, - "bytes": 8434, - "sha256": "07761eb1ae27be2bd586d024ce099e4489bb769a065759e071db089ef73b1e5c" + "bytes": 16388, + "sha256": "4c0989f0cffdafd4f79a4fefd8afe3811d6a3fdd2f5cc376d08576ca014fec1d" + }, + { + "id": "file-f4ffde8f268bfa02", + "path": "web/src/components/preview/TimelinePlaybackLayer.dom.test.tsx", + "domain": "web", + "kind": "tsx-source", + "material": true, + "bytes": 2784, + "sha256": "e7af08ef3a70769df2ba86a3c87392bc4f89b8c0a6659d057200a8caaaede8ab" }, { "id": "file-b1478d286a6756e8", @@ -5865,8 +6543,8 @@ "domain": "web", "kind": "tsx-source", "material": true, - "bytes": 5191, - "sha256": "f7a9561bc969035ebfcbda6faf06e85c19c6be331deab094da274a5c4fccb8e2" + "bytes": 5340, + "sha256": "adab2780497cfd9518416eca8ff86d06b2a39a5b890857e0b8360850070f9122" }, { "id": "file-ebd6b5d5a4e08f81", @@ -5910,8 +6588,8 @@ "domain": "web", "kind": "typescript-source", "material": true, - "bytes": 4505, - "sha256": "e61679cf93b749f36bc549985bf0a4cab76b4c9b4762122797cfde0515956f46" + "bytes": 5325, + "sha256": "de133341185184a5a91942525e008ecb858c33f7a1427e7a72d6148d0a93bc52" }, { "id": "file-01a8be8b66b08d08", @@ -5919,8 +6597,8 @@ "domain": "web", "kind": "typescript-source", "material": true, - "bytes": 7429, - "sha256": "0586c156e4bfb875d9483d71edd60049a6d487cc24060e32a8b9964097649579" + "bytes": 7601, + "sha256": "a119588ce2837a658c1b6356d8bdd1234aee67283985f87397afbb42f1764c85" }, { "id": "file-a2b23839b3415fb1", @@ -5928,8 +6606,8 @@ "domain": "web", "kind": "typescript-source", "material": true, - "bytes": 6935, - "sha256": "e00baf4e60ef467edd26ee05f9e4d900886b24d4260c3d28df968522fdd37cd2" + "bytes": 9003, + "sha256": "403a49d23a20f49ca8ad06f4bd8f38025e8de032c85ff0bb53f5ac5f5c219612" }, { "id": "file-6a3175a254123cc7", @@ -5937,8 +6615,8 @@ "domain": "web", "kind": "typescript-source", "material": true, - "bytes": 3306, - "sha256": "9475838ba54cd3125a7c0479d83e78d9eee4e746208f17b2efe45890e98fee80" + "bytes": 4654, + "sha256": "034c010fca8995450296c77559690821ae296394d8d5c03278f61dfb19aa14c3" }, { "id": "file-999803a8e4df7e07", @@ -5946,8 +6624,8 @@ "domain": "web", "kind": "typescript-source", "material": true, - "bytes": 19099, - "sha256": "a86c593e0c8b72cca88fe053a22134c66985beab49ee7a21ac624db2551a3bdd" + "bytes": 19932, + "sha256": "572bd34285e66e40bc1e3c2a37ba39366edf1d8446c2fd62fd6905700dd06f48" }, { "id": "file-95f404ec51f737b4", @@ -5955,8 +6633,8 @@ "domain": "web", "kind": "typescript-source", "material": true, - "bytes": 23708, - "sha256": "059f32da90bab89facebff5129a1c1e5dc0b514457b6ed801d9e860c47a16e45" + "bytes": 24689, + "sha256": "346193ea66535ac40a617c6dc1829029d2925d10a75b4aded6d8da4474e994b9" }, { "id": "file-a7497777dc16c9e9", @@ -6012,14 +6690,32 @@ "bytes": 6018, "sha256": "6164e4c78c6cbcd55c69b00686191dc2c3cafc24b5e306ed6c510965da7d4f63" }, + { + "id": "file-b6e0379305dbdfcc", + "path": "web/src/components/preview/scrubGesture.test.ts", + "domain": "web", + "kind": "typescript-source", + "material": true, + "bytes": 1492, + "sha256": "ec075842a05181b79413b0fb9aae2e5d2ccf519340f0a7ca01534cd493a2b0cb" + }, + { + "id": "file-f964eadf351f5513", + "path": "web/src/components/preview/scrubGesture.ts", + "domain": "web", + "kind": "typescript-source", + "material": true, + "bytes": 1245, + "sha256": "bfe176fe20159608b0f99fc18c7dab619c3f9ca1c89a3561e8cf78436e6098a0" + }, { "id": "file-ec1282ed1bc227a1", "path": "web/src/components/preview/timelinePlayback.test.ts", "domain": "web", "kind": "typescript-source", "material": true, - "bytes": 13029, - "sha256": "77b6fee6ec682c104f1e58fedb599747d102a4e0958edf43511e112907e7c1c9" + "bytes": 13890, + "sha256": "2b62b7b982145e22dababa7a564e8d9ecf9f0320f014fcb0ccf75b6775176504" }, { "id": "file-485cccadb8a9d0bb", @@ -6063,8 +6759,8 @@ "domain": "web", "kind": "tsx-source", "material": true, - "bytes": 24016, - "sha256": "ada82ee56e53aec5ef7a2578b7476b34e82ccf1bfd73f7eb955f493290f514ef" + "bytes": 25088, + "sha256": "4728a6d7cbc31e73fea64bda017d36f23365eac666eb6258fd85503a72da25ba" }, { "id": "file-2223c7b7b503205f", @@ -6093,14 +6789,23 @@ "bytes": 1189, "sha256": "e618542071665b7558a7d9bea7b032ac59fc145d11c2a0942597923714510bdc" }, + { + "id": "file-a3783d441439ab2f", + "path": "web/src/components/shell/EditorSplit.test.tsx", + "domain": "web", + "kind": "tsx-source", + "material": true, + "bytes": 8519, + "sha256": "af655dd777986d2f0b406ac62ff8d89d91b4dd5340c5c759de5293beacdd3806" + }, { "id": "file-c54269cf1dc14bc1", "path": "web/src/components/shell/EditorSplit.tsx", "domain": "web", "kind": "tsx-source", "material": true, - "bytes": 8652, - "sha256": "ccc71c6d81f852b5696136f4c6ab0dae25f01045edc2fb2ad78c76e7ac38b620" + "bytes": 9506, + "sha256": "e372272a3c4bb1646256bc8bd4a41aa2986ab78160293e62f9972ce580b58513" }, { "id": "file-5a95caf8d6841b44", @@ -6138,14 +6843,32 @@ "bytes": 2111, "sha256": "d7cdbca38f6b079233eceada5f9fb3d0689c0e03bcce28c6e355bb4df6c53ee6" }, + { + "id": "file-d08bf01dd15e4abc", + "path": "web/src/components/shell/ShellComponentMapping.test.tsx", + "domain": "web", + "kind": "tsx-source", + "material": true, + "bytes": 3064, + "sha256": "75d9cee3bda04735476d9daf1af6dd65b50eafeef46d96a7cb239258d0308e76" + }, { "id": "file-6b3b8c6ef21c08f8", "path": "web/src/components/shell/SplitPane.tsx", "domain": "web", "kind": "tsx-source", "material": true, - "bytes": 2903, - "sha256": "9c7fe861b62677546ca055214529a655059f2c7d2918c10e7102ddedc6f9b19c" + "bytes": 4979, + "sha256": "00f6234e69770ffa886cc2a685f3d9a23e7df32540aced18936454da6760952c" + }, + { + "id": "file-31a4638adebb8775", + "path": "web/src/components/shell/TitleBar.interaction.test.tsx", + "domain": "web", + "kind": "tsx-source", + "material": true, + "bytes": 15947, + "sha256": "d1848f451c5a7d65c405462ccf9cde070794fbaff7cf900bccc218c8fb936aa6" }, { "id": "file-55eb01164e2b8a0d", @@ -6153,8 +6876,8 @@ "domain": "web", "kind": "tsx-source", "material": true, - "bytes": 16416, - "sha256": "2b1cc35c17904b3b538b88d4ebc4ceba56c023eb6836af1ce872e3f085682443" + "bytes": 18121, + "sha256": "60565ae18f70f45eef0444f42b4cc5137c81533f4386b54b0d3462b4508f8d9a" }, { "id": "file-4a48b89ebd289857", @@ -6162,8 +6885,26 @@ "domain": "web", "kind": "typescript-source", "material": true, - "bytes": 2432, - "sha256": "10f95af1add2bc8b52987b038dd6d9993c7063ffd14945c1b45cedc6da9a6a81" + "bytes": 2840, + "sha256": "22de0a38a5bdc940ad12884cb5396476a06c0e2ecc54448db802d29c5617bed2" + }, + { + "id": "file-b93ea61a0e676f1c", + "path": "web/src/components/shell/ViewMenu.interaction.test.tsx", + "domain": "web", + "kind": "tsx-source", + "material": true, + "bytes": 4599, + "sha256": "d35d786820bd34d5312d45687eea76d2682374ff37b6cf0632692d6f4980bbaf" + }, + { + "id": "file-b39756f012a0f028", + "path": "web/src/components/shell/ViewMenu.test.tsx", + "domain": "web", + "kind": "tsx-source", + "material": true, + "bytes": 12127, + "sha256": "f1af45462979c3aab9fbcb33ea61072a88e55cf31065418a06d6762d4dea528e" }, { "id": "file-bea78307e6c1f77f", @@ -6171,8 +6912,8 @@ "domain": "web", "kind": "tsx-source", "material": true, - "bytes": 6558, - "sha256": "e36204b148e1e32bec4242a6a667d78183ab92ee0b144de632d07aefec6de756" + "bytes": 26821, + "sha256": "8962a84d3bc7600bb3da5c7b66deb90b14c79c60b482bdc366741805f2d8f348" }, { "id": "file-7bca5461921c41c3", @@ -6225,8 +6966,8 @@ "domain": "web", "kind": "typescript-source", "material": true, - "bytes": 13438, - "sha256": "d123887e4aea8fd04839a6c32abe446b99c2b1763e0ae39a55f1fb1e3688f434" + "bytes": 15973, + "sha256": "5561ae8bbdab7844c2494b3023f27cfdb0b166828e3e5a67dc0b0c0bc5e3f312" }, { "id": "file-75d719c24e13273c", @@ -6234,8 +6975,8 @@ "domain": "web", "kind": "tsx-source", "material": true, - "bytes": 84258, - "sha256": "408abd877ba052c743176e26ec076d5925000bb1c77ad1a07ed0e6dc180bb99b" + "bytes": 87905, + "sha256": "5b01ac1e4b12be7cd89b80c36ba12b9acbf77b8745ef67b181d2f9047b6134fe" }, { "id": "file-c9cbf95e2414ae9d", @@ -6279,8 +7020,8 @@ "domain": "web", "kind": "typescript-source", "material": true, - "bytes": 8879, - "sha256": "c4518f1e66f903eda78a65ab6000c33d5b8ba03dc4277c77595d7ce5f74d6bbe" + "bytes": 11201, + "sha256": "221521cdb8f5171a02c1f5379702ac54236b9ff4c3c364ef7f3498e50d53a879" }, { "id": "file-17be501ac069880b", @@ -6288,8 +7029,8 @@ "domain": "web", "kind": "typescript-source", "material": true, - "bytes": 25995, - "sha256": "bfa600299657dbfe9de3fc2c735608875856bb6864465107ec074267ba3cc871" + "bytes": 27355, + "sha256": "8b2fb8462bb941269d8f5b82d3f61fd7f0d56c3d9e585003c716fec8948d678e" }, { "id": "file-e08fad6fb6b117d7", @@ -6324,8 +7065,8 @@ "domain": "web", "kind": "typescript-source", "material": true, - "bytes": 16504, - "sha256": "e21d2badd50a3cd5a1af8de04841ef4681da7273cb4c28dacd3cb81fee08f8c5" + "bytes": 17590, + "sha256": "0fdb76b6cf648efb0d77ba5f7a24450819c6755a74589cd9fb2b77c327fef4f4" }, { "id": "file-62445fc9c3b24134", @@ -6333,8 +7074,8 @@ "domain": "web", "kind": "typescript-source", "material": true, - "bytes": 7203, - "sha256": "024498389bbc78476dae073295139aa79778f94230916883f52f928472b42ea5" + "bytes": 8364, + "sha256": "e5e0e05e01823fd209445f6482db58af8f6609d9412d99033aaeb3a78c52a54e" }, { "id": "file-650d4950ca665282", @@ -6396,8 +7137,8 @@ "domain": "web", "kind": "tsx-source", "material": true, - "bytes": 2531, - "sha256": "d4a28feb7bc9cb1bb6abd96e42efdbbd20e70d7a43e7d3aa3af33c2139ad7f35" + "bytes": 2111, + "sha256": "cb2bec0065030b28927a3e0ca9374254d3f398b1c803135c0aa2d3cb6070582c" }, { "id": "file-436997b0a341f594", @@ -6414,8 +7155,8 @@ "domain": "web", "kind": "typescript-source", "material": true, - "bytes": 3314, - "sha256": "b94219a8b0f1573cc3843e211ed8539a693253169b0e8be3866f637c1e2bb7c0" + "bytes": 4617, + "sha256": "e61733924df38ec6f307d8ac7affb5eedc8df63c184cd0750bf422f5b5f1d3d5" }, { "id": "file-2b922617af696aa3", @@ -6423,8 +7164,8 @@ "domain": "web", "kind": "typescript-source", "material": true, - "bytes": 10335, - "sha256": "bf8c67f84343b8b2ea898f26ace03f78ec7b817a10d08c5720a52d8e41153d24" + "bytes": 11597, + "sha256": "964266195c372b8a19e04337c3e9afff3bfd342a5f3705dffd5a27a0ba89a15f" }, { "id": "file-86a5e8f04367ae61", @@ -6432,8 +7173,8 @@ "domain": "web", "kind": "typescript-source", "material": true, - "bytes": 56987, - "sha256": "908150142828885d368f16c66a481e5df144ab3061676c1860cf052d5cc47fc8" + "bytes": 67015, + "sha256": "be402166f4dec3eaafbf33a56e18620f28528b1d4b09e9642d9f3437af3515d3" }, { "id": "file-4965754bcf377f58", @@ -6477,8 +7218,8 @@ "domain": "web", "kind": "typescript-source", "material": true, - "bytes": 41487, - "sha256": "39c4e471568221e0b8434fc8b6fa31c4ce34367273c9787f33976c57af8549b0" + "bytes": 47994, + "sha256": "97c743dce8bd978bb8113a0c88040d6cfd16caf67c92d2ffb4a57033723ecb80" }, { "id": "file-d9a7857e58914645", @@ -6495,8 +7236,8 @@ "domain": "web", "kind": "typescript-source", "material": true, - "bytes": 21362, - "sha256": "faca029b9738470dc8291be076ebb6f454093028b618b10c6f7dc160e60a295a" + "bytes": 22276, + "sha256": "a778f36192087e44479ca657f97a6104751c8d0c227526fef244283997133dea" }, { "id": "file-75f3d7ef4167b919", @@ -6504,8 +7245,8 @@ "domain": "web", "kind": "typescript-source", "material": true, - "bytes": 32105, - "sha256": "7139a15a8d3d2d0a387ebcc6e0b4c47252b7b066992cfcbdfbe7f0c3b722ba62" + "bytes": 33317, + "sha256": "9ab4fa120c57855b27373f46dce49a6e0a3ec5d8dff1c8605ef315e94a175e70" }, { "id": "file-1761c08dde5f5547", @@ -6540,8 +7281,8 @@ "domain": "web", "kind": "typescript-source", "material": true, - "bytes": 11368, - "sha256": "cff33eb6ce2ccd0cfd4e1369c986ee66d128c8f80e2e69ab05376b2a175200a6" + "bytes": 13682, + "sha256": "fd94c9c2b80a6b3afc789fb3f87352c2a20db119751469f1b4438853f4900e9e" }, { "id": "file-0ec6e1299efa4645", @@ -6549,8 +7290,8 @@ "domain": "web", "kind": "typescript-source", "material": true, - "bytes": 23207, - "sha256": "c0f9d3ac53c8c270d8b3b56f204839ce7e4ecbcd0013fd94572d6766b2eeaf90" + "bytes": 25331, + "sha256": "94274a45caa8b3528e6ed421117e57b5735ecadb24436bb21e98d03d9921535a" }, { "id": "file-0fe9809ddf4f271b", @@ -6873,8 +7614,8 @@ "domain": "web", "kind": "typescript-source", "material": true, - "bytes": 20303, - "sha256": "67d1733b88ea257a1111dddcccad16b28780069b8b928b02280bac7bc7cd7dc3" + "bytes": 21946, + "sha256": "73700fd5eabd9adac8357747efefc6b541c8ced6eb70ebfa55bfd43b965476ac" }, { "id": "file-3de9c2f92acb8585", @@ -6918,8 +7659,8 @@ "domain": "web", "kind": "typescript-source", "material": true, - "bytes": 4085, - "sha256": "e3877dde3d80cc3ae486595399a3c0d40ac5db54105f373327d133f4d77c05bd" + "bytes": 4422, + "sha256": "19912afa61feb8fe8c675b201fdeb4e03dc65f08bc2ebf796975a62f853ac0dc" }, { "id": "file-eb034552744995ae", @@ -6963,8 +7704,8 @@ "domain": "web", "kind": "typescript-source", "material": true, - "bytes": 48406, - "sha256": "e21e1e0a2dbaaca7ce827a344b7d929c4374b6c14385ff37c5301c151325d174" + "bytes": 48686, + "sha256": "06a612688edfa984ca8fd67972c7f3c6df76543f37dc63d500659be9b89a83f9" }, { "id": "file-903803ae7a765e50", @@ -6972,8 +7713,8 @@ "domain": "web", "kind": "typescript-source", "material": true, - "bytes": 3036, - "sha256": "2998cc32503b9854c3ce3ea13b64cd3a184a6e49318ae16f5aa996b710349104" + "bytes": 3139, + "sha256": "130b1b13b67f8dd0be14b54b0826d370563fc5aac62112564bda66f1c3c4ddf6" }, { "id": "file-44ed6aea208dbf05", @@ -6990,8 +7731,8 @@ "domain": "web", "kind": "typescript-source", "material": true, - "bytes": 8505, - "sha256": "b16366885cc51fe4838c941f2e9e1b41f31fa63ade6eafee5b72cce1330efcbf" + "bytes": 8525, + "sha256": "1850fba933204a645d3781b75310835b66042c61a9acc8f242b96ebe7304285f" }, { "id": "file-6699b4f37461d13e", @@ -7035,8 +7776,8 @@ "domain": "web", "kind": "typescript-source", "material": true, - "bytes": 13247, - "sha256": "296eb08bd5e72d4b5435b1c44c7cd67b6d08b75711e72c156f3128c7ff9d5263" + "bytes": 20133, + "sha256": "223a28479ec731f5019e80a2f20120be4d3f20ceb1c736db43437c20701f46d8" }, { "id": "file-ae123ba03cd6ac29", @@ -7044,8 +7785,8 @@ "domain": "web", "kind": "typescript-source", "material": true, - "bytes": 8004, - "sha256": "cad849b40ed4473a28df3bea683f357dae6b96e6e0930692bd1c29e8427fe320" + "bytes": 11950, + "sha256": "023ac8064222c8100dcd4ce4df50d21393eb40b51762d02d7816b3075881aaad" }, { "id": "file-6c82a868b1fb4916", @@ -7071,8 +7812,17 @@ "domain": "web", "kind": "typescript-source", "material": true, - "bytes": 1744, - "sha256": "6a1e459c937279b7104b904ac289c07515c183456c709e71ba50549af2628974" + "bytes": 1756, + "sha256": "d5f0226a95d645c376e1b1e3e2f3e2c238abd26ce63e3f2f07089ee18e056760" + }, + { + "id": "file-5c4516cda894cd83", + "path": "web/src/store/recentStore.test.ts", + "domain": "web", + "kind": "typescript-source", + "material": true, + "bytes": 2093, + "sha256": "2aaf44fef7957b25df854fc3ef61a23f6ea6b2a31ea1d19be32369198acd67af" }, { "id": "file-5318270e4188d9b7", @@ -7080,8 +7830,8 @@ "domain": "web", "kind": "typescript-source", "material": true, - "bytes": 3165, - "sha256": "80a0d96b4b9c82668e77ef8c3f534249c1dbb5e0507fc1c1e0acdca6d0e940df" + "bytes": 5308, + "sha256": "4c5b0ed9f56531483267ac7a7fd7e703216a5d498d94d722f94bdd98ead31cb6" }, { "id": "file-341f4d669a84a7d9", @@ -7089,8 +7839,8 @@ "domain": "web", "kind": "typescript-source", "material": true, - "bytes": 4381, - "sha256": "1419b2e0427ddef1d4cf750b8d82dc94b6d13325d8bcedf723f9735991e0bd20" + "bytes": 4514, + "sha256": "84e31e19636d1435d545d4aaafe89fbb884acfd2e3baf2d7e92ed211f1763c82" }, { "id": "file-1cee16a36c760ade", @@ -7110,6 +7860,15 @@ "bytes": 3682, "sha256": "e2a15117b1c2aab824cd369e798a08395f2abf20a65de0c1690b5301aa542fec" }, + { + "id": "file-df23b317193b321b", + "path": "web/src/store/uiStore.persistence.test.ts", + "domain": "web", + "kind": "typescript-source", + "material": true, + "bytes": 5151, + "sha256": "33b9660d6fb14d80d548365e7138718faca502a9d4702f213859b8d10c59c51d" + }, { "id": "file-16b56f7f0491b365", "path": "web/src/store/uiStore.test.ts", @@ -7125,8 +7884,8 @@ "domain": "web", "kind": "typescript-source", "material": true, - "bytes": 18619, - "sha256": "c68e0d195829b7cf402930738600c6c0664d67a0ae801255be7983cf88c51aca" + "bytes": 23086, + "sha256": "7345932cec4987dc09321b45991f0b40f67564d94dc4b2bac0434b5cddfada6c" }, { "id": "file-962ac4047b1046f3", @@ -7134,8 +7893,8 @@ "domain": "web", "kind": "css", "material": true, - "bytes": 2705, - "sha256": "35105a71a4f9351dc28d2cb63b9308f5966b53a02609643b4cb5f20d35aeda3a" + "bytes": 3768, + "sha256": "9305547097c51b51a6678c614593983c5a1fd71444ceb44aba83ab9edafb5f25" }, { "id": "file-5dea34a364b2d988", diff --git a/docs/audit/2026-07-14/runtime-artifacts/automated/agent-lottie-inspect-real-device-2026-08-01.md b/docs/audit/2026-07-14/runtime-artifacts/automated/agent-lottie-inspect-real-device-2026-08-01.md new file mode 100644 index 00000000..c0a109ea --- /dev/null +++ b/docs/audit/2026-07-14/runtime-artifacts/automated/agent-lottie-inspect-real-device-2026-08-01.md @@ -0,0 +1,57 @@ +# Agent Lottie inspection real-device verification — 2026-08-01 + +Scope: Agent Settings Generation Task 1 / advertised MCP tool reachability. This +receipt covers the native Lottie source-inspection and composited-timeline +materialization paths. Packaged desktop GUI verification remains part of the +later plan-by-plan UI gate. + +## Runtime + +- Host: macOS, Apple Silicon, Asia/Shanghai +- GPU: Apple M4, 8 cores, Metal 4 +- Display: built-in Liquid Retina, 2560×1664 +- Renderer: Velato/Vello on the shared `wgpu` device path +- Test fixture: deterministic two-frame, 16×16, 2 fps Lottie document + +## RED + +The owning end-to-end test was added first and executed against the production +`TauriMediaBridge`. It failed with the baseline typed result: + +```text +inspect_media: Lottie rendering is not available in this build +``` + +No manifest or source bytes were mutated. + +## GREEN acceptance + +Command: + +```text +CARGO_INCREMENTAL=0 cargo test -p opentake-tauri mcp::tests::inspect_media_renders_lottie_frames_over_gray_end_to_end -- --exact --nocapture +``` + +Result: PASS, one exact GPU-backed test executed. It verified: + +- the project-relative Lottie source is resolved through the live project + snapshot and rejected if it is not a retained regular file; +- two evenly sampled source times (`0.25`, `0.75`) produce two different JPEG + frames through real Velato/Vello rasterization; +- the first sample is red-dominant and the second green-dominant; +- transparent pixels are composited over neutral gray; +- returned width, height, frame rate, duration, MIME type, and source byte size + are authoritative; +- no paid provider or network access is involved. + +The same `LottieMaterializer` is now used by `inspect_timeline`; Lottie layers +are no longer silently omitted from Agent timeline inspection. + +## Gates + +- `cargo test -p opentake-agent hidden_tool_is_rejected_as_unadvertised`: PASS. +- `cargo test -p opentake-agent --test advertised_tool_acceptance every_advertised_tool_is_live_or_absent -- --exact`: PASS. +- `cargo clippy -p opentake-tauri -p opentake-agent --all-targets -- -D warnings`: + PASS. +- `cargo fmt --all -- --check`: PASS. +- `CARGO_INCREMENTAL=0 cargo test --workspace --no-fail-fast`: PASS. diff --git a/docs/audit/2026-07-14/runtime-artifacts/automated/ai-matting-vertical-2026-08-01.md b/docs/audit/2026-07-14/runtime-artifacts/automated/ai-matting-vertical-2026-08-01.md new file mode 100644 index 00000000..b7ae0f5a --- /dev/null +++ b/docs/audit/2026-07-14/runtime-artifacts/automated/ai-matting-vertical-2026-08-01.md @@ -0,0 +1,52 @@ +# AI Matting Vertical — 2026-08-01 + +## Delivered surface + +- The capability-gated `ai_matte` Agent tool is advertised only after the + pinned RVM model passes regular-file, exact-byte-size, and SHA-256 checks. +- The desktop host runs the official RVM MobileNetV3 FP32 ONNX graph through + the shared ORT worker with recurrent state preserved across frames. +- Preview produces a content-addressed ProRes 4444 cache without modifying the + timeline or manifest. The Inspector shows that transparent result over a + checkerboard and reuses it for Apply. +- Apply copies through a retained no-follow project-media capability, registers + generation provenance, replaces the selected clip in one durable undo entry, + and keeps the source media in the manifest. +- Generated derivatives retain source audio, preserve their alpha plane, and + request straight-alpha premultiplication in still preview, continuous + playback, MCP timeline inspection, and export render plans. +- The model installer is explicit, cancellable, reports byte progress, leaves + no partial model after cancellation/failure, and verifies the official + 14,975,696-byte payload against + `88d4531297118f595bf2fd60f6f566aec2e559393802d1f436c380f0cbbd2828`. +- `NOTICE` records the official Robust Video Matting project, authors, + ByteDance origin, and GPL-3.0 license. The model is downloaded on demand and + is not embedded in the application bundle. + +## Automated evidence + +- `OPENTAKE_TEST_RVM_MODEL=... CARGO_INCREMENTAL=0 cargo test -p opentake-tauri advanced::tests::official_matting_preview_apply_undo_and_reopen -- --nocapture` + - official ONNX inference on a real H.264/AAC fixture; + - preview leaves project state unchanged; + - apply preserves audio and provenance; + - undo/redo and save/reopen restore the correct media reference; + - a pre-cancelled cached request still returns the typed cancellation result. +- `CARGO_INCREMENTAL=0 cargo test -p opentake-media --test ffmpeg_integration prores_4444_roundtrip_preserves_alpha_plane -- --exact` + - alpha samples survive the real FFmpeg encode/decode round trip. +- `CARGO_INCREMENTAL=0 cargo test -p opentake-media --features ort-backend,model-download analysis::matting::tests::pre_cancelled_download_never_creates_a_partial_model -- --exact` + - cancellation completes before network access and publishes no partial file. +- `CARGO_INCREMENTAL=0 cargo clippy -p opentake-media --features ort-backend,model-download --all-targets -- -D warnings` + - passed. +- `CARGO_INCREMENTAL=0 cargo clippy -p opentake-tauri --all-targets -- -D warnings` + - passed. +- `pnpm test -- MattingSection.test.tsx` + - the repository runner executed all 112 test files: 863 tests passed. +- `pnpm build` + - TypeScript build and production Vite bundle passed; existing chunk-size and + ineffective-dynamic-import warnings remain non-fatal. + +## Remaining acceptance evidence + +Packaged macOS GUI verification and final delivery export inspection are still +required before the planning checkbox is closed. Those steps intentionally run +after the complete code gate so the evidence represents the Beta candidate. diff --git a/docs/audit/2026-07-14/runtime-artifacts/automated/avatar-voice-clone-vertical-2026-08-01.md b/docs/audit/2026-07-14/runtime-artifacts/automated/avatar-voice-clone-vertical-2026-08-01.md new file mode 100644 index 00000000..a5168c19 --- /dev/null +++ b/docs/audit/2026-07-14/runtime-artifacts/automated/avatar-voice-clone-vertical-2026-08-01.md @@ -0,0 +1,43 @@ +# Avatar and voice-clone vertical evidence — 2026-08-01 + +Scope: advanced AIGC identity workflows for digital avatar and custom voice. This artifact records the production contracts, deterministic provider fixtures, persistence/export checks, and UI component tests. It does not claim that a paid provider request was consumed. + +## Production contracts + +- Avatar generation uses the fixed fal `fal-ai/sync-lipsync/v3/image-to-video` endpoint with one project image and one project audio asset. Consent and paid-cost confirmation are mandatory. The fal request id, canonical request SHA-256, provider/model, source asset ids and source digests persist in generation provenance. +- fal queue polling is bounded to 30 minutes. User cancellation requests the official remote queue cancellation endpoint and prevents local import. Result URLs use the existing redirect-disabled, public-HTTPS-only, bounded generation downloader. +- Avatar output must probe as video with audio and match the narration duration within one project frame before a single durable Register + Place transaction. Failure/cancellation removes staging output. +- Voice enrollment uses ElevenLabs Instant Voice Cloning multipart upload; cloned speech uses the fixed `eleven_multilingual_v2` model. Provider resource ids are restricted to safe path-segment characters before they enter an authenticated endpoint. +- Voice reference digest, consent id, provider voice id and request hash persist without credentials. Enrollment and permanent revocation are external-identity audit mutations outside ordinary document undo/redo. Duplicate enrollment is rejected before paid submission; cancellation or local persistence failure deletes the newly created remote voice. +- Remote voice deletion is idempotent (`404` means already absent). A deleted voice cannot be revived by undo and is rejected before any later provider generation call. + +Official provider references: + +- +- +- + +## Automated evidence + +The avatar fixture generates a real H.264/AAC result and covers consent/cost failure, pre-cancellation, atomic import/placement, provenance, save/reopen, one-step generated-media undo, and six-frame export with audio. + +The voice fixture covers invalid consent, enrollment, cancellation before provider use, provider failure with zero import, generated-audio provenance and audition path, generated-media undo, permanent provider/local revocation, undo resistance, save/reopen, and rejection after revocation without calling the provider. + +Passing gates: + +```text +CARGO_INCREMENTAL=0 cargo test -p opentake-domain -p opentake-ops -p opentake-core -p opentake-tauri +CARGO_INCREMENTAL=0 cargo clippy -p opentake-domain -p opentake-ops -p opentake-core -p opentake-tauri --all-targets -- -D warnings +CARGO_INCREMENTAL=0 cargo clippy -p opentake-tauri --all-targets --no-default-features -- -D warnings +cargo fmt --all -- --check +npm test # 117 files, 876 tests +npm run build +``` + +Rust results include 64 core unit tests, 234 domain unit tests, 202 ops unit tests, 416 Tauri unit tests, all selected integration tests, and their doc tests. Real-device-only playback/export probes remain explicitly ignored by their existing test annotations. + +## Remaining Beta gate + +- Enter user-owned fal and ElevenLabs keys in the packaged application and run one explicitly authorized paid request per provider. +- Verify the packaged Smart Pack tabs, consent/cost controls, cancel/retry, avatar preview, voice audition, undo and permanent revoke interactions visually. +- Retain the resulting packaged-app screenshots, media probes and project reopen evidence in the sequential Beta validation report. diff --git a/docs/audit/2026-07-14/runtime-artifacts/automated/beta-1-sequential-validation-2026-08-01.md b/docs/audit/2026-07-14/runtime-artifacts/automated/beta-1-sequential-validation-2026-08-01.md new file mode 100644 index 00000000..ad6d4829 --- /dev/null +++ b/docs/audit/2026-07-14/runtime-artifacts/automated/beta-1-sequential-validation-2026-08-01.md @@ -0,0 +1,101 @@ +# OpenTake 1.0.0-beta.1 顺序验收记录 + +- 验收时间:2026-08-01 22:48 CST(Asia/Shanghai) +- 平台:Apple Silicon macOS +- 候选版本:`1.0.0-beta.1` +- 候选应用:`target/release/bundle/macos/OpenTake.app` +- 候选 DMG:`target/release/bundle/dmg/OpenTake_1.0.0-beta.1_aarch64.dmg` +- 验证工程:`~/Documents/OpenTake/未命名.opentake/OpenTake-Beta1-Sequential-QA.opentake` + +## 结论 + +候选包按照发布清单 1 → 11 顺序完成了代码门禁、真实桌面 GUI 操作、项目保存重开、 +本地 Motion 渲染、官方 Codex Agent 编辑和最终媒体导出。验收中发现并修复了一个真实 +阻断问题:旧工程缺少可选 `voiceModels` 字段时,声音克隆页的 Zustand selector 每次 +返回新的空数组,导致 React 无限重渲染和 WebView 空白。新增回归测试先复现失败,改为 +稳定空集合后通过;重建候选包后在原旧工程上重新打开声音克隆页成功。 + +## 代码门禁 + +- `cargo fmt --all -- --check`:通过。 +- `git diff --check`:通过。 +- `CARGO_INCREMENTAL=0 cargo clippy --workspace --all-targets -- -D warnings`:通过。 +- `CARGO_INCREMENTAL=0 cargo clippy -p opentake-tauri --no-default-features --all-targets -- -D warnings`:通过。 +- `CARGO_INCREMENTAL=0 cargo test --workspace`:通过;所有执行测试无失败,真实设备专用 probe + 按测试声明保持 ignored。 +- `npm test`:119 个测试文件、882 项测试通过。 +- `npm run build`:通过。 +- `web/node_modules/.bin/tauri build --bundles app,dmg`:通过。 + +已知非阻断警告:`block v0.1.6` 的 Rust future-incompatibility 提示,以及 Vite 的大 chunk / +动态导入提示;两者均未造成编译、测试或运行失败。 + +## 候选包哈希与签名边界 + +- DMG SHA-256:`394ae88d0d47ffe1f1286b34903fceb79cb38acf0b73b70fe0c3ec86a1e1b1cf` +- 应用主程序 SHA-256:`c015e1af2a9369e6e5fb030818e8c1e32648dca400d578faddd3cfda3105721e` +- 应用架构:Mach-O arm64。 +- 当前签名:ad-hoc / linker-signed,`TeamIdentifier` 为空;没有 Developer ID Application + 身份和 Apple 公证凭据,因此本 Beta 不宣称已 Developer ID 签名或公证。 + +## 1 → 11 顺序桌面验收 + +1. **Home / 工程生命周期**:保存、返回主页、完全退出应用、重新启动、从最近工程选择并 + 按 Return 打开均成功;聊天、时间线、媒体、文本和历史状态恢复正确,撤销/重做栈按重开 + 规则清空。 +2. **素材库**:视频缩略图、音频波形素材、收藏开关、“我的”全局库、音频声部分离素材、 + 文件/文件夹导入原生选择器均可见并可操作;720p 代理实际生成并持久化,重开后代理文件 + 存在;离线重链接由条件 UI 与 Rust 回归测试覆盖。 +3. **时间线**:链接选择同时选中视频/音频;播放头移动至 138 帧后对链接组实际分割为两组, + 撤销、重做、再次撤销恢复原状;复制/粘贴和出点裁剪实际执行后全部撤销。吸附、拖放与嵌套 + 序列由相同候选包的时间线控件观察及 workspace 集成测试覆盖。 +4. **预览与画布**:真实播放/暂停从 0 推进到 138 帧(00:04:18),逐帧/seek 控件可用; + Transform、Crop、Mask、运动追踪和关键帧控件可见。保存工程中的 X/Y 位置关键帧重开后仍 + 标记为“已在关键帧面板动画化”;A/V 同步由实际导出媒体 probe 共同验证。 +5. **文本与字幕**:文本片段内容 `OpenTake Beta 1`、字体、字号、颜色和样式重开后正确; + 字幕转写在无语音测试素材上明确返回“未检测到语音”,翻译保持 Provider 同意/费用门禁; + SRT / VTT 导出入口和原生保存流程实际执行。 +6. **特效与调色**:视频检查器实际观察并可进入曝光、色温、Lift/Gamma/Gain、对比、饱和、 + 3D LUT、HSL 二级调色、参考画面色彩匹配、绿幕、蒙版、智能擦除、效果/滤镜、防抖和运动 + 追踪;相邻片段转场面板正确识别切点并显示 15 帧交叉溶解。对应 GPU / LUT / HSL / 蒙版 / + 防抖 / 补帧 / 擦除测试全部通过。 +7. **音频**:声部分离已实际产出 `Vocals` 与 `Accompaniment` 两个独立 WAV 素材并落入独立 + 轨道;响度目标 `-16 LUFS`、降噪预览/重置、分离控件可见;最终 H.264/H.265/ProRes 导出 + 均含 48 kHz 单声道音频。 +8. **Agent / MCP / Codex**:官方 `codex-cli 0.144.1` 显示“已通过官方 Codex 登录: + ChatGPT”。真实 Agent 指令仅把唯一文本改为 `OpenTake Codex Beta Verified`,Codex 通过 + `get_timeline` 和 `set_clip_properties` 完成 MCP 编辑;撤销/重做验证后最终恢复原文并保存。 + Anthropic 未配置时明确引导设置,不伪装成功。图文成片计划 UI、同意/费用边界和对应原子性 + 测试通过。 +9. **Motion Canvas**:实际用本地浏览器渲染 3 秒标题卡,生成 `Motion Graphic` MP4 并在 + 138 帧加入新视频轨;保存、重开后媒体和片段仍存在。再次运行 1 秒渲染正常留在编辑器并 + 显示“动效已添加到时间线”,随后撤销测试用第二片段。 +10. **数字人 / 声音克隆**:数字人页面必须同时选择素材并勾选本人/声音授权和付费授权; + 实际触发后因无 fal key 明确拒绝。声音克隆空白页缺陷修复后,参考音频、名称、同意、费用、 + 注册、生成试听、永久撤销控件均正常显示;实际注册因无 ElevenLabs key 明确拒绝并显示重试, + 未发起付费请求。取消、试听、撤销和撤销不可逆边界由组件及 Rust workflow 测试覆盖。 +11. **交付导出与最终重开**:实际导出 H.264、H.265/HEVC、ProRes 422、SRT、VTT、XMEML、 + FCPXML、OTIO 和 CMX3600 EDL;随后保存、完全退出、重启并重开工程,最终文本、Motion、 + 代理、图片、媒体和时间线均恢复。 + +## 导出媒体 probe + +| 输出 | 视频 | 音频 | 尺寸 / 帧率 | 时长 | SHA-256 | +| --- | --- | --- | --- | --- | --- | +| H.264 MP4 | `h264` | AAC, 48 kHz, mono | 1280×720 / 30 fps | 12.5 s | `e6c21d87a2de852a0cd1f73288db39defafc17edccd9e79dd428ea8f1ce282d4` | +| H.265 MP4 | `hevc` | AAC, 48 kHz, mono | 1280×720 / 30 fps | 12.5 s | `4bf347462eef62160260c6bc4173eee4078870d0161e5c813af9ac799f064156` | +| ProRes MOV | `prores` | PCM s16le, 48 kHz, mono | 1280×720 / 30 fps | 12.5 s | `57fba9c267f89cd097be8497f2f6cc6f438ca012fcc0e641d9422ef479d7cb80` | + +- `xmllint --noout` 验证 XMEML 与 FCPXML:通过。 +- `jq -e` 验证 OTIO JSON:通过。 +- EDL 含三个视频事件和 12 帧交叉溶解记录。 +- 当前时间线没有字幕 cue,因此 SRT 为 0 字节,VTT 为合法的 8 字节 `WEBVTT` 头;这是后端 + 空字幕导出的既定契约,不是静默失败。 + +## 外部发布门槛 + +- 最终 Git 标签必须指向包含本记录和声音克隆回归修复的精确提交。 +- GitHub Actions 必须针对该精确提交 SHA 成功完成 Windows / Web / Rust 构建测试后才可 + 创建 `v1.0.0-beta.1` prerelease。 +- macOS 资产可用于本地 Beta 测试,但因缺少 Developer ID 与公证,不适合向不愿绕过 + Gatekeeper 的普通终端用户宣称为正式安装包。 diff --git a/docs/audit/2026-07-14/runtime-artifacts/automated/bounded-audio-streaming-real-device-2026-08-01.md b/docs/audit/2026-07-14/runtime-artifacts/automated/bounded-audio-streaming-real-device-2026-08-01.md new file mode 100644 index 00000000..cf90d061 --- /dev/null +++ b/docs/audit/2026-07-14/runtime-artifacts/automated/bounded-audio-streaming-real-device-2026-08-01.md @@ -0,0 +1,114 @@ +# Bounded audio streaming packaged real-device evidence — 2026-08-01 + +Parent: `MR-bounded-audio-streaming` + +Environment: macOS arm64, packaged release application, bundled FFmpeg 6.0, +Chinese UI. Desktop actions were performed through the packaged GUI. Shell +probes only generated fixtures, inspected processes, signatures, and completed +media artifacts. + +## Packaged artifact and process identity + +- Application executable SHA-256: + `a92c3e31d503300b87806ecd36aec48ba3128c49f202c9c78167e301b6f75711`. +- DMG SHA-256: + `040d44146f224c0aedc45cafe0cf333bae5fa10eaad3ae8c820e1f2b0fc99e57`. +- The whole `.app`, signed DMG, and the `.app` mounted from that DMG pass + strict/deep code-signature verification. The mounted executable has the same + SHA-256 as the source bundle. +- The signature is ad hoc (`Signature=adhoc`, `TeamIdentifier=not set`), so this + is local package-integrity evidence, not Developer ID/notarization evidence. +- An already-running pre-build process was detected and explicitly quit. The + final probes below ran only after PID `10645` was launched from the rebuilt + executable above. + +## Fixture + +- Source: + `/private/tmp/opentake-task16-bounded-audio-60s-20260801.mp4`. +- SHA-256: + `98c2f4eb83acf1a18628b008434bf129f961bef090b6ba711aaacdc23c827daa`. +- H.264 640×360 at 30 fps plus AAC 48 kHz mono, exactly 60.000 seconds. +- Project: + `/private/tmp/opentake-task16-bounded-audio-real-device-20260801.opentake`. + +## Packaged playback + +The rebuilt application opened the 60-second project at `00:00:00 / 01:00:00`. + +- Playback advanced across the two-second scheduling boundary to + `00:03:28 / 01:00:00`, then settled at `00:04:06` after pause. A second + observation 2.2 seconds later still reported `00:04:06`. +- Jump-to-end reported `01:00:00`; jump-to-start reported `00:00:00`. +- Resume after that seek advanced to `00:03:17`, proving the new generation was + decoded and consumed after the stale queue was discarded. +- The preview continued rendering changing test-pattern frames while its audio + device clock advanced; no frozen playhead or visible black fallback occurred. + +## Packaged full export + +The final rebuilt process exported the entire 60-second timeline through the +GUI at the 720p preset: + +- Output: + `/private/tmp/opentake-task16-final-binary-export-20260801.mp4`. +- SHA-256: + `13fc06a151f07b85cdfe30ad2d497d4893a1afe94e247a70d73e90fe8bb5d381`. +- Size: 12,670,639 bytes; duration: exactly 60.000 seconds. +- Video: H.264, 1280×720. +- Audio: AAC, 48 kHz, mono. + +During export, the encoder owned a private `audio.pcm` spool and received only +bounded mix windows. Final AAC mux succeeded after all 1,800 video frames were +rendered; the public output remained zero bytes until atomic completion. + +## Packaged WAV save-as-media + +An 8-second audio clip backed by the same source was selected in the final +packaged timeline and `另存为媒体` was invoked from its context menu. The media +panel changed from two to three entries. After normal project save, +`media.json` persisted the new result as a project-relative audio source. + +- Output: + `/private/tmp/opentake-task16-audio-save-real-device-20260801.opentake/media/clip_22d2704d-929f-41b4-a46e-2cc1ef9f7ec2_18c780113066a660_0.wav`. +- SHA-256: + `4444ced67f9b66ac37e1e4e7d537e75f12ac4b38f40322c2ed3d1e190e8378ee`. +- PCM s16le, 48 kHz, mono, exactly 8.000 seconds and 768,044 bytes including + the WAV header. + +This path writes each bounded window directly after a pre-sized WAV header; it +does not collect the full mix in a production `Vec`. + +## Automated ownership and regression gates + +- RED was recorded when + `long_timeline_mix_has_constant_peak_allocation_and_matches_short_reference` + could not compile because `mix_stereo_windows` did not exist. +- Reviewed focused tests: + `large_mix_observes_cancellation_between_chunks` and + `long_timeline_mix_has_constant_peak_allocation_and_matches_short_reference` + both pass. +- Added ownership covers stale seek generations, underrun-as-silence, pause + retaining the next valid chunk, decode cancellation, and encoder PCM spool + growth without a retained timeline mix. +- Export integration `export_with_audio_clip_mux_aac_stream` passes with a real + AAC output. The real save-as-media Rust test also writes and imports WAV. +- `cargo fmt --all -- --check` passes. +- `cargo test --workspace --no-fail-fast` passes. Environment-dependent tests + remain explicitly ignored; no executed test failed. +- `cargo clippy --workspace --all-targets -- -D warnings` passes. +- Web: 93 files / 824 tests pass; TypeScript and production Vite build pass. + +Cancellation was verified at the owned decode/mix/encoder boundaries by the +focused and workspace tests. A separate attempt to drive the packaged cancel +button during the saturated full-resolution GPU export could not obtain a +responsive accessibility window before that export completed, so it is not +claimed as packaged-GUI cancellation evidence. + +## Result + +`MR-bounded-audio-streaming` is **PASS** for its declared implementation slice: +long playback, full video export, and WAV save-as-media no longer retain the +whole timeline PCM mix; seek, pause/resume, underrun, cancellation, and teardown +have explicit owned tests, and the successful package probes cover the actual +release application. diff --git a/docs/audit/2026-07-14/runtime-artifacts/automated/caption-translation-vertical-2026-08-01.md b/docs/audit/2026-07-14/runtime-artifacts/automated/caption-translation-vertical-2026-08-01.md new file mode 100644 index 00000000..9c0f7680 --- /dev/null +++ b/docs/audit/2026-07-14/runtime-artifacts/automated/caption-translation-vertical-2026-08-01.md @@ -0,0 +1,17 @@ +# Caption translation vertical — 2026-08-01 + +`requirement-dbe026f6228381a6` is implemented as a production desktop and Agent vertical. + +- Captions are addressed by persisted clip ID. Provider output is rejected on unknown or duplicate IDs, and omitted/empty items become per-caption failures. +- The production bridge uses the existing Settings → AI BYOK keychain boundary for OpenAI or Anthropic, requires explicit cost authorization, redacts provider bodies from errors, and supports cancellation before and after the network request. +- Preview returns the exact source/translated diff with the project epoch/version. Captions lets the user accept or reject each change, retry the provider, apply the accepted subset, cancel, and undo. +- `ApplyCaptionTranslations` validates the complete batch before mutation, keeps clip ID, caption group, track, start frame, and duration unchanged, and stores source text, source/target locale, provider, and model. Manual text editing clears that provenance. +- Provider-wide failure leaves the timeline unchanged. Partial success changes only successful captions; failed caption text remains original. + +Focused verification: + +- `CARGO_INCREMENTAL=0 cargo test -p opentake-ops caption_translation -- --nocapture` — 2 passed. +- `CARGO_INCREMENTAL=0 cargo test -p opentake-tauri caption_translation -- --nocapture` — 2 passed, including success/save-reopen/undo and partial/failure atomicity. +- `npm test -- CaptionsTab.test.tsx` — 1 passed, exercising review, individual rejection, apply, and undo. +- `npm test` — 115 files / 872 tests passed. +- `npm run build` — TypeScript and production Vite build passed (existing chunk-size/dynamic-import warnings only). diff --git a/docs/audit/2026-07-14/runtime-artifacts/automated/cli-sidecar-boundary-real-device-2026-08-01.md b/docs/audit/2026-07-14/runtime-artifacts/automated/cli-sidecar-boundary-real-device-2026-08-01.md new file mode 100644 index 00000000..b94e5261 --- /dev/null +++ b/docs/audit/2026-07-14/runtime-artifacts/automated/cli-sidecar-boundary-real-device-2026-08-01.md @@ -0,0 +1,42 @@ +# CLI sidecar boundary packaged evidence — 2026-08-01 + +Parent: `MR-cli-sidecar-boundary-complete` + +## Code boundary + +- RED: corrected owners first failed because `resolve_cli_path` was absent. +- GREEN: environment override, PATH fallback, regular packaged-sidecar + selection, and Unix symlink rejection pass without mutating process-wide + environment variables. +- `ffmpeg-sidecar` is built with default features disabled, so OpenTake does not + use its download stack and does not link a libav ABI binding. + +## Final packaged macOS application + +- App executable SHA-256: + `a92c3e31d503300b87806ecd36aec48ba3128c49f202c9c78167e301b6f75711`. +- Bundled `ffmpeg` SHA-256: + `68249fcf774472381c5ee0fd7bb91065ab8a8cd8ed3ab6d0672ebc2f33974190`. +- Bundled `ffprobe` SHA-256: + `acf7b76a64dddcc099f272d2dfee4ae9b185244bd532ca54dc7d4d2379765940`. +- Both sidecars report version 6.0 and execute from + `OpenTake.app/Contents/MacOS` beside `opentake`. +- `otool -L` on `opentake` reports no `libavcodec`, `libavformat`, `libavutil`, + `libswscale`, or `libswresample` dynamic dependency. +- `codesign --verify --deep --strict` passes for the whole app. The signature is + ad hoc; this is not Developer ID/notarization evidence. +- Packaged GUI probe/decode/playback/export paths used these sidecars in the + immediately preceding Task15/16 real-device receipts. + +## Distribution limitation + +The current FFmpeg 6.0 binary reports a configuration containing both GPL +components and `--enable-nonfree`. This proves the technical CLI boundary, but +it is not a public-distribution license clearance. Replace it with a pinned, +license-compatible build and publish the matching notices before a public Beta. + +## Result + +`MR-cli-sidecar-boundary-complete` is **PASS** for the declared implementation +slice. Public release packaging remains blocked on the separately owned +sidecar-license/signing acceptance. diff --git a/docs/audit/2026-07-14/runtime-artifacts/automated/color-match-vertical-2026-08-01.md b/docs/audit/2026-07-14/runtime-artifacts/automated/color-match-vertical-2026-08-01.md new file mode 100644 index 00000000..0502b8e1 --- /dev/null +++ b/docs/audit/2026-07-14/runtime-artifacts/automated/color-match-vertical-2026-08-01.md @@ -0,0 +1,54 @@ +# Reference Color Match Vertical — 2026-08-01 + +## Delivered surface + +- The capability-gated `match_color` Agent tool is backed by the desktop host + and accepts an ordinary forward 1x image/video target, target timeline frame, + reference image/video asset, and reference source frame. +- Both frames are decoded at bounded resolution and sampled in linear BT.709. + The `opentake-luma-preserving-mean-match` v1 algorithm normalizes reference + chromaticity to the target luma, then emits an ordinary editable per-channel + gain grade. +- Preview performs no project mutation and reports target/reference/matched + linear means, CIE Delta E 1976 before/after, target luma before/after, the + exact generated grade, and algorithm version. +- The Inspector lists visual media references, exposes reference/target frame + inputs, preview, cancellation, retry, color swatches, measured Delta E/luma, + Apply, and Undo. The ordinary Color Grade controls remain the editor for the + applied result. +- Apply commits the grade and persisted `ColorMatchInput` together in one + optimistic-revision edit. Provenance includes reference asset/frame, target + frame, algorithm/version, sampled means, Delta E, and luma. Any later manual + grade edit clears that provenance so the project cannot mislabel an altered + grade as the original sampled match. +- The existing shared render plan carries the same `Clip.color_grade` into + interactive preview and export; there is no separate export approximation. + +## Automated evidence + +- `CARGO_INCREMENTAL=0 cargo test -p opentake-tauri color_match_improves_delta_e_preserves_luma_and_persists_editable_grade --lib -- --nocapture` + - passed with fixed PNG target/reference/black fixtures; + - preview reduced CIE Delta E to below 0.01 and below its input value while + keeping absolute target-luma drift below 0.01; + - black low-confidence analysis failed without changing timeline/version; + - Apply persisted an editable grade and complete provenance; + - Undo/redo and save/reopen restored the exact grade and provenance; + - pre-cancelled analysis returned typed cancellation. +- `CARGO_INCREMENTAL=0 cargo test -p opentake-ops color_match_command_tests --lib` + - the grade/provenance pair forms one undo entry, and a later manual grade + edit clears sampled-match provenance. +- `CARGO_INCREMENTAL=0 cargo clippy -p opentake-domain -p opentake-ops -p opentake-tauri --all-targets -- -D warnings` + - passed. +- `npm test -- --run` + - all 114 web test files passed: 870 tests; + - preview/measurement, Apply/Undo, cancellation with stale-result rejection, + missing-reference refusal, and reversed-clip refusal passed. +- `npm run build` + - TypeScript and the production Vite bundle passed; existing chunk-size and + ineffective-dynamic-import warnings remain non-fatal. + +## Remaining acceptance evidence + +Packaged macOS GUI verification and a final preview/export inspection remain +required before `requirement-7d79665fbcb91584` is closed. These run against the +assembled Beta candidate after the complete code gate. diff --git a/docs/audit/2026-07-14/runtime-artifacts/automated/data-safety-cache-identity-real-device-2026-07-31.md b/docs/audit/2026-07-14/runtime-artifacts/automated/data-safety-cache-identity-real-device-2026-07-31.md new file mode 100644 index 00000000..4685eeb4 --- /dev/null +++ b/docs/audit/2026-07-14/runtime-artifacts/automated/data-safety-cache-identity-real-device-2026-07-31.md @@ -0,0 +1,40 @@ +# Data safety cache identity real-device evidence — 2026-07-31 + +## Scope + +- Plan: `data-safety-implementation.md`, Task 6 `DS-cache-identity-complete`. +- Requirement: `requirement-52da354b46176a99`. +- Boundary: stable lowercase 32-hex file identity from path, Foundation/Swift modification time, and size, with no identity for missing metadata. + +## Exact code evidence + +- `identity_hex_is_stable_and_lowercase`: PASS, 1/1. +- `identity_hex_matches_swift_for_whole_second_mtime`: PASS, 1/1. +- `file_identity_key_missing_file_is_none`: PASS, 1/1. + +The full cache-key module additionally covers every identity component, Foundation subsecond rounding, pre-epoch times, Swift closest-shortest and exponential formatting, independent SHA-256 prefixes, visual versus transcript/embedding seed order, real file metadata, and missing files. + +These owning tests already passed at the initial baseline, so Task 6 required runtime evidence closure rather than a production patch; no artificial RED failure was introduced. + +## Packaged application result + +During Task 3, the packaged application imported `/private/tmp/opentake-task8-music.wav` and visibly rendered its waveform in the media panel. The production `MediaVisualCache` wrote: + +`~/Library/Caches/com.opentake.desktop/media-cache/MediaVisualCache/67f1356ac2a7dc98b08d839f7cbb38a5.waveform` + +The source file had: + +- byte size: `88278`; +- Foundation modification time: `1785475345.638414`; +- absolute path: `/private/tmp/opentake-task8-music.wav`. + +An independent Swift process used `Foundation.FileManager` for the same size/date values and `CryptoKit.SHA256`, retaining the first 32 lowercase hex characters. It produced: + +- transcript/embedding order `path|mtime|size`: `246a0ff48bba2d5609aff5bd6fa3e7f6`; +- visual order `path|size|mtime`: `67f1356ac2a7dc98b08d839f7cbb38a5`. + +The independently calculated visual identity exactly matched the packaged application's real cache filename. The filename is 32 lowercase hex characters, representing the first 16 SHA-256 bytes as required. + +## Outcome + +Task 6 is complete: focused Rust vectors, missing-file behavior, and an actual packaged waveform cache agree with the independent Swift/Foundation implementation. This closes one data-safety record only; it does not reclassify the remaining data-safety tasks or authorize Beta publication. diff --git a/docs/audit/2026-07-14/runtime-artifacts/automated/data-safety-cross-cutting-security-partial-2026-07-31.md b/docs/audit/2026-07-14/runtime-artifacts/automated/data-safety-cross-cutting-security-partial-2026-07-31.md new file mode 100644 index 00000000..6d20efd4 --- /dev/null +++ b/docs/audit/2026-07-14/runtime-artifacts/automated/data-safety-cross-cutting-security-partial-2026-07-31.md @@ -0,0 +1,72 @@ +# Data safety cross-cutting security partial evidence — 2026-07-31 + +## Status + +Task 10 `DS-cross-cutting-security-headings` remains **open**. This checkpoint closes the concrete CSP, asset-scope, packaged-sidecar supply, Windows installed-package, and offline WebView2 configuration defects discovered during audit, but does not claim the task's release-signing criteria or a native Windows WebView UI smoke. + +## Defect found and corrected + +The audited `src-tauri/tauri.conf.json` had `csp: null` and asset scope `['**']`. That allowed the WebView asset protocol to request arbitrary readable paths and left production content loading without an explicit CSP. + +The corrected packaged boundary now has: + +- production CSP with `default-src 'self'`, `object-src 'none'`, `frame-ancestors 'none'`, no remote HTTPS source, and only loopback HTTP for the native preview transport; +- separate development CSP for local Vite WebSocket traffic, so production does not retain a WebSocket allowance; +- asset protocol static scope limited to app cache, app-owned global-library data, and packaged resources; +- explicit deny precedence for `.ssh`, `.gnupg`, and `.aws` home trees; +- native dialog runtime grants persisted across restart via `tauri-plugin-persisted-scope` with its `protocol-asset` feature; +- no shell, filesystem, HTTP, or process plugin command permission exposed to the main WebView. +- the Windows NSIS/MSI bundle embeds the silent WebView2 offline installer, so a fresh installation does not depend on network access; +- each Windows CI job that compiles the Tauri crate provisions the checksum- and version-pinned FFmpeg/FFprobe sidecars before compilation. + +## Automated evidence + +`src-tauri/tests/security_config.rs` proves: + +- packaged CSP is enabled, local-only, and carries the required deny directives; +- asset scope contains exactly the three application-owned allow patterns and no global/home wildcard; +- the main capability contains no shell/fs/http/process command permission; +- persisted-scope is enabled for the asset protocol and initializes after the required fs plugin. +- the Windows platform bundle selects `offlineInstaller` with silent installation; +- all three Windows Tauri CI jobs provision the pinned sidecars before their first Tauri compile/build step. + +Result: PASS, 6/6. The four existing cross-cutting owning tests also pass 1/1 each, the Web suite passes 82 files / 774 tests, and the complete Rust workspace regression passed after the hardening patch. + +The exact-source Windows product run also passed: + +- source SHA: `9eeeb6ffe3088a16f19946ceb7db5e90090356ac`; +- workflow: [run 30614001607](https://github.com/appergb/OpenTake/actions/runs/30614001607), success; +- product job: [job 91102897263](https://github.com/appergb/OpenTake/actions/runs/30614001607/job/91102897263), success; +- installed-package owning test: `PASS: packaged_macos_windows_sidecars_resolve_and_execute`; +- artifact: ID `8787369512`, server digest `sha256:f287f5a9309245b9e415b55d52b72c75ddaaae1651fccfe03bfc8c15514b3a63`; +- MSI SHA-256: `2dfe332521214fab8892be21bd5800e2708cf38161fa0c401c3602be507c6dd7`; +- NSIS SHA-256: `d85002098bdf6883811251261947d399800140fa3cc2c2850bc7e3fbceedb95c`. + +That job bundled the offline WebView2 installer, built both native installers, +silently installed NSIS, ran the media owning test from the installed directory +with an empty `PATH`, and bound the uploaded installer receipt to the exact +source SHA. + +## Packaged application evidence + +The exact hardened tree built successfully with: + +`./web/node_modules/.bin/tauri build --debug --bundles app` + +The rebuilt `/target/debug/bundle/macos/OpenTake.app`: + +1. launched to Home under the production CSP; +2. opened the legacy project through the native directory picker; +3. visibly rendered the persisted text, four media entries, a waveform cache, and the project-relative HTTPS-imported image; +4. exited and relaunched; +5. reopened the same project from Recents without another picker; +6. again rendered the same timeline and local-resource surfaces. + +The persisted runtime files `.persisted-scope` and `.persisted-scope-asset` exist under the application's support directory after the picker/restart cycle. + +## Remaining Task 10 blockers + +- Native Windows packaged-WebView launch and interactive UI smoke evidence is not yet attached. The installer itself and its installed sidecars are covered by the exact-SHA native CI receipt above. +- macOS distribution identity signing and Apple notarization evidence is not yet available; the current debug `.app` is only a local QA artifact. + +Accordingly, no Task 10 plan checkbox is marked complete and this checkpoint does not authorize Beta publication. diff --git a/docs/audit/2026-07-14/runtime-artifacts/automated/data-safety-generation-seed-real-device-2026-07-31.md b/docs/audit/2026-07-14/runtime-artifacts/automated/data-safety-generation-seed-real-device-2026-07-31.md new file mode 100644 index 00000000..329ae301 --- /dev/null +++ b/docs/audit/2026-07-14/runtime-artifacts/automated/data-safety-generation-seed-real-device-2026-07-31.md @@ -0,0 +1,51 @@ +# Data safety generation-log seed real-device evidence — 2026-07-31 + +## Scope + +- Plan: `data-safety-implementation.md`, Task 5 `DS-generation-seed`. +- Requirements: `requirement-b9010e6717b5d5ea`, `requirement-1f35cc4131f8f0b7`. +- Boundary: missing or malformed optional generation log, deterministic manifest-provenance seed, save, reopen, and idempotence. + +## Exact code evidence + +- `malformed_generation_log_is_ignored`: PASS, 1/1. +- `missing_generation_log_seeds_manifest_provenance_once`: PASS, 1/1. + +The core owning test covers empty and mixed manifests, imported versus generated assets, duplicate provenance, manifest reorder, duplicate asset identities, signed zero timestamps, edit/save/reopen, existing-log precedence, malformed optional logs, compatibility read-only protection, and byte-stable repeated saves. + +Both exact tests already passed at the initial baseline, so this task required packaged-app evidence closure rather than a new production patch; no artificial RED failure was introduced. + +## Packaged application fixture + +`/private/tmp/opentake-ds-generation-seed-real-device.opentake` started with: + +- no `generation-log.json`; +- one imported media entry without generation provenance; +- two video entries carrying identical legacy provenance; +- one audio entry carrying different legacy provenance; +- an empty timeline. + +The packaged application opened the bundle and visibly rendered all four offline media entries with relink recovery controls. Before editing, a filesystem check confirmed `generation-log.json` was absent. + +## Edit, save, and reopen + +Using the packaged UI, the run added a text clip with `Generation seed verified` and returned Home to save. The new `generation-log.json` contained exactly two entries: + +- one deterministic `legacy-generation:{sha256}` id for `legacy-video-model` at `700000000.0`; +- one different deterministic id for `legacy-audio-model` at `700000010.0`. + +The imported asset was excluded and the duplicate video provenance collapsed to one event. The application then reopened the project from Recents: the text rendered in the preview, all four media entries remained, and the timeline contained the saved clip. Returning Home again produced no duplicate event and no byte changes. + +Stable SHA-256 values after both the first and second save: + +| File | SHA-256 | +|---|---| +| `generation-log.json` | `9aaecae32ecef935036afc9dc918617cc80451912092623384897834ee593702` | +| `project.json` | `ebf598a4150763d70a473cfd47ba5250952f3f1be5ac0a54af5587dbe4cccd8b` | +| `media.json` | `ea8a7397f7f247a3bf6f71c03ff881e102b8adec9ca02c1ed0af7259eb46ddb8` | + +The persisted log count remained two and all ids remained unique after the second process-level open/save cycle. + +## Outcome + +Task 5 is complete: exact tests and a packaged legacy-project round trip agree on deterministic one-time seeding, deduplication, safe persistence, and idempotence. This closes two data-safety records only; it does not reclassify the remaining data-safety tasks or authorize Beta publication. diff --git a/docs/audit/2026-07-14/runtime-artifacts/automated/data-safety-legacy-default-matrix-real-device-2026-07-31.md b/docs/audit/2026-07-14/runtime-artifacts/automated/data-safety-legacy-default-matrix-real-device-2026-07-31.md new file mode 100644 index 00000000..36296df5 --- /dev/null +++ b/docs/audit/2026-07-14/runtime-artifacts/automated/data-safety-legacy-default-matrix-real-device-2026-07-31.md @@ -0,0 +1,55 @@ +# Data safety legacy/default matrix real-device evidence — 2026-07-31 + +## Scope + +- Plan: `data-safety-implementation.md`, Task 1 `DS-legacy-default-matrix`. +- Requirement: `requirement-365ac4943b157d3e`. +- Boundary: exhaustive current/legacy/missing/malformed/future compatibility, plus a representative packaged-app open/edit/save/reopen round trip. + +## Exact code evidence + +All four named owning tests existed at the initial baseline and passed without a production change: + +- `applies_clip_defaults_for_omitted_fields`: PASS, 1/1. +- `migrates_legacy_transform_xy_to_center`: PASS, 1/1. +- `migrates_generation_log_legacy_cost_and_version`: PASS, 1/1. +- `exhaustive_legacy_default_matrix`: PASS, 1/1. + +The exhaustive test covers current and legacy decoding, omitted defaults, malformed and unknown-future fail-closed branches, read-only/save-as guards, and open/edit/save/reopen persistence. Because the reviewed production boundary was already complete, the initial baseline was GREEN; no artificial failing implementation was introduced merely to satisfy the generated RED wording. + +## Packaged application fixture + +The debug macOS application opened `/private/tmp/opentake-ds-legacy-real-device.opentake`, created specifically for this acceptance run. The input intentionally omitted project dimensions, frame rate, settings state, track and clip identifiers, track flags, media type/source type, media-manifest version/folders, and generation-log version. It also used legacy transform `x/y` and legacy generation `cost` fields, and pointed at a missing external movie. + +The first native-open attempt deliberately selected `/private/tmp` itself and the application visibly rejected it with `missing required project.json in bundle at /private/tmp`, then returned safely to Home. Selecting the actual package opened successfully and rendered: + +- 1920 × 1080 at 30 fps; +- a three-second offline-media placeholder with relink recovery; +- synthesized track and clip identities; +- a migrated timeline clip on V1. + +## Edit, save, and reopen + +Using only the packaged application UI, the run added a text clip and set its content to `Legacy migration verified`, returned to Home to save, and reopened the project from the recent-project card. The reopened preview rendered the exact text, the timeline retained both V1 and V2 clips, and the offline-media recovery surface remained available. + +The persisted JSON then proved the full representative migration: + +- project defaults: `width=1920`, `height=1080`, `fps=30`, `settingsConfigured=false`; +- track defaults: generated UUIDs, `muted=false`, `hidden=false`, `syncLocked=true`; +- clip defaults: generated UUIDs, `mediaType/sourceClipType=video`, zero trims/fades/crop, unit speed/volume/opacity; +- transform migration: legacy `x=0.1/y=0.2` became `centerX≈0.1/centerY≈0.2`, while width/height remained `0.5`; +- media manifest: `version=1`, `folders=[]`; +- generation log: `version=1`, legacy `cost=0.42` became `costCredits=42`; +- real edit: the added text clip persisted `textContent="Legacy migration verified"` and reopened visibly. + +Persisted artifact SHA-256 values after the successful round trip: + +| File | SHA-256 | +|---|---| +| `generation-log.json` | `2b802e37cc1eeb8ae818de7d77f3ea6e57300232050c24765704cb879fc71cae` | +| `media.json` | `0d56801a702905b9450fa8ff0854a224560693397f81f67d66394b488b86a8c9` | +| `project.json` | `1ce3f603dd4a12de83733817e7b6aab97a91474ae90b66ed4ca0aa461cc78986` | + +## Outcome + +Task 1 is complete: exact compatibility tests and the packaged real-device round trip agree on the same migration behavior, including visible invalid-bundle recovery. This closes one data-safety record only; it does not reclassify the remaining data-safety tasks or authorize Beta publication. diff --git a/docs/audit/2026-07-14/runtime-artifacts/automated/data-safety-manifest-corruption-real-device-2026-07-31.md b/docs/audit/2026-07-14/runtime-artifacts/automated/data-safety-manifest-corruption-real-device-2026-07-31.md new file mode 100644 index 00000000..333c5ba0 --- /dev/null +++ b/docs/audit/2026-07-14/runtime-artifacts/automated/data-safety-manifest-corruption-real-device-2026-07-31.md @@ -0,0 +1,58 @@ +# Data safety manifest-corruption real-device evidence — 2026-07-31 + +## Scope + +- Plan: `data-safety-implementation.md`, Task 9 `DS-manifest-corruption-conflict`. +- Requirement: `requirement-7908bd026b5a91f6`. +- Boundary: keep `project.json` and present malformed `media.json` strict; allow only malformed optional `generation-log.json` to open in an explicit write-blocked compatibility session. + +## Exact code evidence + +- `malformed_manifest_is_an_error`: PASS, 1/1. +- `malformed_manifest_contract_matches_authoritative_source`: PASS, 1/1. + +The schema contract covers complete current-version open/edit/save/reopen, missing and malformed required timeline, syntactically and structurally malformed media manifests, missing-manifest default and safe creation, legacy `{}` preservation, malformed optional generation-log recovery, error priority, same-path write rejection, Save As rejection before destination creation, and full nofollow tree-receipt equality. + +The plan's expected-RED note described the state before the reviewed test was introduced. On the audited branch the exact test already exists (landed with the cross-cutting data-safety gates), so both focused commands are now green; no artificial failure was introduced. + +## Packaged malformed-media result + +The application was already displaying the valid generation-seed project with four media entries and two text clips. Opening `/private/tmp/opentake-ds-task9-malformed-media.opentake`, whose `media.json` contains invalid JSON, did not replace the live session: + +- the same four media entries remained visible; +- the preview still rendered `Generation seed verified`; +- both original timeline clip IDs remained present; +- no new project, journal, staging, or sibling artifact appeared. + +The rejected fixture remained exactly two files with SHA-256 values: + +- `project.json`: `b3973a664bd87f47496250b9a32cbbd489b2d7d14e0c43fd3ff777415a2bc1e8`; +- `media.json`: `85c606726a48c5b18880fda848fffe4cbce09090f3b96210b37b1c415fb520c1`. + +## Packaged malformed-generation result + +Opening `/private/tmp/opentake-ds-task9-malformed-generation.opentake` succeeded only as an explicit compatibility recovery session. The UI displayed: + +- `兼容性只读模式`; +- one compatibility issue; +- `generation-log.json:invalid-or-unreadable`; +- editing and saving disabled. + +Clicking **添加文本** caused no accessibility-tree or timeline change. The empty timeline stayed empty. The preserved file SHA-256 values were: + +- `project.json`: `b3973a664bd87f47496250b9a32cbbd489b2d7d14e0c43fd3ff777415a2bc1e8`; +- `media.json`: `8faebdcf603b7f74817d302fe63613b7547581e03e80da3fb31977abe16df1fe`; +- damaged `generation-log.json`: `36b98fe44f4a237ff39f862cc1083270583b9bcb3127d7bf32b67bd4d4bbea46`. + +No rejected Save As destination or related sibling artifact existed after the run. + +## Regression gate + +- both Task 9 focused commands: PASS. +- `cargo fmt --all -- --check`: PASS on the same source tree. +- `cargo test --workspace --no-fail-fast --quiet`: PASS on the same source tree immediately before this evidence-only change; all executed tests passed, with only explicitly ignored tests skipped. +- `git diff --check`: PASS. + +## Outcome + +Task 9 is complete. Exact persistence tests and the packaged application agree on strict manifest failure, incumbent-session preservation, explicit generation-log recovery, disabled mutation, and unchanged damaged bytes. This closes one data-safety record only; it does not reclassify later tasks or authorize Beta publication. diff --git a/docs/audit/2026-07-14/runtime-artifacts/automated/data-safety-mcp-redaction-real-device-2026-07-31.md b/docs/audit/2026-07-14/runtime-artifacts/automated/data-safety-mcp-redaction-real-device-2026-07-31.md new file mode 100644 index 00000000..08be29fc --- /dev/null +++ b/docs/audit/2026-07-14/runtime-artifacts/automated/data-safety-mcp-redaction-real-device-2026-07-31.md @@ -0,0 +1,38 @@ +# Data safety MCP redaction real-device evidence — 2026-07-31 + +## Scope + +- Plan: `data-safety-implementation.md`, Task 4 `DS-mcp-redaction`. +- Requirement: `requirement-2e9e6066655d5846`. +- Boundary: typed actionable MCP errors without paths, credentials, authorization headers, signed queries, provider bodies, nested source detail, or internal stack content. + +## Exact code evidence + +The exact owning test `llm_errors_redact_paths_credentials_headers_provider_bodies` passed 1/1. The complete `mcp_error_redaction` runner passed 2/2, including the multi-block test that proves text and image blocks cannot be recombined to recover private content. + +The matrix independently injects: + +- a full user-home media path; +- an API-key-shaped value; +- bearer and Basic authorization values; +- a signed URL query; +- a provider/customer response body; +- nested decoder and stack detail; +- split multi-block text and image content. + +Every wire result retains a typed code and retry remediation while removing the injected content. + +## Packaged application result + +The rebuilt debug macOS application's production MCP server received two real `import_media` calls containing the same adversarial categories: + +1. A nonexistent `/Users/alice/.../sk-live-.../secret.mp4` path with an `Authorization: Bearer ...` display name. +2. A signed HTTPS URL containing `token=signed-secret&expires=999999`, with a provider-style customer/quota body in the display name. + +Both operations failed as intended and returned HTTP 200 MCP envelopes whose tool results were marked `isError=true`. Response bodies contained `MCP_TOOL_ERROR_REDACTED`, a unique `errorId`, and actionable retry guidance. A byte-level search across both retained responses found none of these strings: the home path, API key, bearer token, authorization label, signed token, expiry, quota text, or customer identity. + +The same packaged session had immediately before this check returned non-redacted success content for valid path, HTTPS, and bytes imports. This proves the sanitizer is conditional at the LLM boundary rather than suppressing all useful tool output. + +## Outcome + +Task 4 is complete: adversarial automated coverage and the packaged MCP wire agree on typed, actionable, fail-closed redaction. This closes one data-safety record only; it does not reclassify the remaining data-safety tasks or authorize Beta publication. diff --git a/docs/audit/2026-07-14/runtime-artifacts/automated/data-safety-mcp-tool-import-real-device-2026-07-31.md b/docs/audit/2026-07-14/runtime-artifacts/automated/data-safety-mcp-tool-import-real-device-2026-07-31.md new file mode 100644 index 00000000..1f7e59e8 --- /dev/null +++ b/docs/audit/2026-07-14/runtime-artifacts/automated/data-safety-mcp-tool-import-real-device-2026-07-31.md @@ -0,0 +1,47 @@ +# Data safety MCP tool import real-device evidence — 2026-07-31 + +## Scope + +- Plan: `data-safety-implementation.md`, Task 3 `DS-mcp-tool-import`. +- Requirement: `requirement-d317ca3e45fba737`. +- Boundary: strict tool arguments plus path, inline-byte, and HTTPS media import with fail-closed staging and publication. + +## Exact code evidence + +Three dispatcher tests and the production URL-import test passed: + +- `import_media_requires_exactly_one_source`: PASS, 1/1. +- `import_media_rejects_unknown_nested_source_key`: PASS, 1/1. +- `import_media_bytes_rejects_oversized_base64_before_bridge`: PASS, 1/1. +- `https_url_import_enforces_scheme_mime_and_decoded_limit`: PASS, 1/1. + +The initial command for `all_tool_schemas_reject_unknown_missing_wrong_type -- --exact` reported success but executed zero tests because the owning test had an extra `_and_nonfinite` suffix. The test was renamed to the exact plan-declared name without weakening its assertions; the corrected command then passed 1/1 and still covers non-finite numbers in addition to unknown, missing, wrong-root, wrong-type, and nested-field cases. + +The URL test covers HTTPS/userinfo/redirect restrictions, response and override MIME resolution, extension/container conflicts, Content-Length and streamed decoded-byte ceilings, cancellation, probe failure, manifest-writer failure, retained staging identity, successful publication, and reopen persistence. + +## Packaged application result + +The rebuilt debug macOS application opened the retained Task 3 fixture and exposed the production MCP bridge at `127.0.0.1:19789`. + +Invalid live calls used no source, two sources, an unknown nested source key, and a plain-HTTP URL. Every request returned an MCP error and the media manifest remained at one entry. The unknown nested key was reported as `MCP_INVALID_ARGUMENTS`; private operational failures were redacted with an error identifier. + +The valid paths then succeeded through the same live server: + +| Source | Runtime result | +|---|---| +| Absolute path WAV | Imported as `Task3 Path Music`; one-second waveform appeared in the media panel | +| HTTPS PNG | Downloaded from `raw.githubusercontent.com`, probed as `image/png`, retained 12,746 bytes in the project, and appeared as `Task3 HTTPS Rust` | +| Base64 PNG | Decoded 68 bytes, retained in the project, and appeared as `Task3 Inline PNG` | + +An initial Wikimedia image request received a source-server HTTP 403. OpenTake returned a redacted failure and left the manifest unchanged. Repeating the acceptance run with an HTTPS source that permits programmatic downloads succeeded, distinguishing external rejection from an import defect. + +After returning Home to save, `media.json` contained four entries: the original offline fixture, the external path WAV, and two project-retained images. The retained files were present at their declared relative paths: + +| Artifact | SHA-256 | +|---|---| +| HTTPS PNG | `cf78cef9ba96a43bda7254c0ffb10b9faffd075997ca6b7bd89df1c64e3c5605` | +| Inline PNG | `e4bff65a73fef402fa8fbd4f4cc20d774df30ad45315617efec31455aa3fe1f0` | + +## Outcome + +Task 3 is complete: exact schemas, failure isolation, three real import modes, retained publication, disk persistence, and the visible media panel agree. This closes one data-safety record only; it does not reclassify the remaining data-safety tasks or authorize Beta publication. diff --git a/docs/audit/2026-07-14/runtime-artifacts/automated/data-safety-mcp-transport-real-device-2026-07-31.md b/docs/audit/2026-07-14/runtime-artifacts/automated/data-safety-mcp-transport-real-device-2026-07-31.md new file mode 100644 index 00000000..b4a72e84 --- /dev/null +++ b/docs/audit/2026-07-14/runtime-artifacts/automated/data-safety-mcp-transport-real-device-2026-07-31.md @@ -0,0 +1,42 @@ +# Data safety MCP transport real-device evidence — 2026-07-31 + +## Scope + +- Plan: `data-safety-implementation.md`, Task 2 `DS-mcp-transport`. +- Requirement: `requirement-473d4379da3bd4cc`. +- Boundary: loopback-only startup, DNS-rebinding guards, request-size limit, and MCP protocol-version validation. + +## Exact code evidence + +All four named HTTP integration tests existed at the initial baseline and passed independently: + +- `non_local_origin_is_rejected`: PASS, 1/1. +- `oversized_request_body_is_rejected`: PASS, 1/1. +- `serve_rejects_non_loopback_bind`: PASS, 1/1. +- `unsupported_protocol_version_is_400`: PASS, 1/1. + +The tests drive the actual axum/Streamable HTTP router. Their mutation counters prove rejected Origin, Host, protocol-version, and oversized-body requests never reach tool dispatch. The production implementation also rejects a caller-supplied non-loopback bind before creating a listener. + +Because the reviewed production boundary was already complete, the initial baseline was GREEN; no artificial failing implementation was introduced merely to satisfy the generated RED wording. + +## Packaged application result + +The rebuilt debug macOS application exposed its MCP instructions in Settings, including the exact endpoint `http://127.0.0.1:19789/mcp`, client setup commands, and the visible statement that the server binds only to `127.0.0.1` while the application is running. + +The running packaged process was then checked through its real TCP endpoint: + +| Probe | Result | +|---|---| +| Listener inspection | `opentake` listening on `TCP 127.0.0.1:19789`, IPv4 loopback only | +| Valid MCP `initialize` with JSON + SSE accept types | HTTP 200, protocol `2025-06-18`, server `opentake` version `1.0.0` | +| Valid session `tools/list` | HTTP 200 and production tool catalog returned | +| `Origin: http://evil.example.com:19789` | HTTP 403 | +| `Host: 127.0.0.1.evil:19789` | HTTP 403 | +| `MCP-Protocol-Version: 1900-01-01` | HTTP 400 | +| Body larger than the 16 MiB request ceiling | HTTP 413 | + +The live `tools/list` response included timeline reads, media inspection/search, clip/track edits, undo, text/captions, and beat-detection tools. This demonstrates that the valid path reached the packaged server while each invalid transport path was rejected at the boundary. + +## Outcome + +Task 2 is complete: the exact owning tests and the packaged TCP server agree on loopback binding and every required request guard. This closes one data-safety record only; it does not reclassify the remaining data-safety tasks or authorize Beta publication. diff --git a/docs/audit/2026-07-14/runtime-artifacts/automated/data-safety-project-open-composite-real-device-2026-07-31.md b/docs/audit/2026-07-14/runtime-artifacts/automated/data-safety-project-open-composite-real-device-2026-07-31.md new file mode 100644 index 00000000..81c52f38 --- /dev/null +++ b/docs/audit/2026-07-14/runtime-artifacts/automated/data-safety-project-open-composite-real-device-2026-07-31.md @@ -0,0 +1,53 @@ +# Data safety project-open composite real-device evidence — 2026-07-31 + +## Scope + +- Plan: `data-safety-implementation.md`, Task 8 `DS-project-open-composite-headings`. +- Requirements: `requirement-6748d221ef0d9a4c`, `requirement-9335bc98b18f8d8d`, `requirement-706e744a85684655`, and `requirement-b38818cf815e0f1e`. +- Boundary: validate and prepare a complete project candidate, admit playback/prewarm, atomically publish one runtime snapshot, then expose it consistently to media, render, playback, captions, and Agent/MCP consumers. + +## Exact code evidence + +All nine reviewed owning tests passed exactly: + +- `exhaustive_legacy_default_matrix`: PASS, 1/1. +- `missing_generation_log_seeds_manifest_provenance_once`: PASS, 1/1. +- `project_open_composite_acceptance`: PASS, 1/1. +- `deferred_apply_rejects_version_and_project_drift_without_mutation`: PASS, 1/1. +- `app_core_media_path_stress_never_mixes_project_snapshots`: PASS, 1/1. +- `caption_commit_rejects_stale_project_revision`: PASS, 1/1. +- `project_open_mapped_boundaries_composite_acceptance`: PASS, 1/1. +- `prewarm_rejection_restores_active_playback_without_project_publish`: PASS, 1/1. +- `transcript_batch_resolution_uses_one_snapshot_and_authoritative_types`: PASS, 1/1. + +Together these tests exercise current and legacy input, missing optional files, malformed media rejection without mutation, generation-log seeding, byte-stable save/reopen, prepared-versus-committed publication, stale deferred writes, concurrent Agent path reads, caption revision drift, prewarm rejection and incumbent playback recovery, plus consistent UI-media/render/playback/Agent projections from one production runtime snapshot. + +The owning implementation and tests were already green at the audited baseline; no artificial RED failure was introduced. + +## Packaged application evidence + +The packaged debug application supplied complementary user-visible coverage through two independent real bundle round trips: + +1. `/private/tmp/opentake-ds-legacy-real-device.opentake` opened with omitted legacy fields, synthesized defaults and IDs, migrated transforms and generation cost, an offline-media recovery surface, and a visible timeline. A text edit saved and reopened with exact persisted semantics. +2. `/private/tmp/opentake-ds-generation-seed-real-device.opentake` opened with four manifest assets and no generation log. The UI exposed every media entry, deterministically seeded two unique generation records, excluded imported provenance, saved a text edit, and reopened byte-stably without duplicate history. + +The deliberately invalid selection of `/private/tmp` failed visibly with `missing required project.json in bundle at /private/tmp` and returned safely to Home. The malformed generation fixture also failed without replacing the live session; after correcting the fixture, the same packaged process opened successfully. + +While the second project remained open, the live production MCP server read the same two desktop timeline clips, added a third clip, and undid it. The desktop UI immediately converged back to those same two clips. This directly verifies that desktop and Agent consumers observe the committed shared core session, not separate placeholder state. + +Detailed child evidence is retained in: + +- `data-safety-legacy-default-matrix-real-device-2026-07-31.md`; +- `data-safety-generation-seed-real-device-2026-07-31.md`; +- `data-safety-shared-core-command-real-device-2026-07-31.md`. + +## Regression gate + +- all nine Task 8 focused commands: PASS. +- `cargo fmt --all -- --check`: PASS on the same source tree. +- `cargo test --workspace --no-fail-fast --quiet`: PASS on the same source tree immediately before this evidence-only change; all executed tests passed, with only explicitly ignored tests skipped. +- `git diff --check`: PASS. + +## Outcome + +Task 8 is complete. Deterministic failure injection and two packaged-app round trips agree that project open is validated before publication, failure preserves the incumbent session and bytes, and every mapped consumer reads one committed snapshot. This closes four composite records only; it does not reclassify later data-safety tasks or authorize Beta publication. diff --git a/docs/audit/2026-07-14/runtime-artifacts/automated/data-safety-shared-core-command-real-device-2026-07-31.md b/docs/audit/2026-07-14/runtime-artifacts/automated/data-safety-shared-core-command-real-device-2026-07-31.md new file mode 100644 index 00000000..463307ba --- /dev/null +++ b/docs/audit/2026-07-14/runtime-artifacts/automated/data-safety-shared-core-command-real-device-2026-07-31.md @@ -0,0 +1,49 @@ +# Data safety shared core command real-device evidence — 2026-07-31 + +## Scope + +- Plan: `data-safety-implementation.md`, Task 7 `DS-shared-core-command-complete`. +- Requirements: the 10 records mapped to the shared `EditorState` / `EditCommand` / `AppCore` / `EventBus` command path. +- Boundary: versioned command application, unchanged-command handling, undo/redo, and convergence between the packaged desktop client and live MCP client. + +## Exact code evidence + +- `commit_undo_redo_cycle_restores_and_versions`: PASS, 1/1. +- `apply_bumps_version_and_emits_once`: PASS, 1/1. +- `unchanged_command_does_not_emit_or_bump`: PASS, 1/1. +- `undo_redo_through_core_bumps_version_and_emits`: PASS, 1/1. + +These owning tests prove that committed edits bump the shared revision and emit once, unchanged commands neither bump nor emit, and undo/redo restore state while producing versioned results. They were already green at the audited baseline; no artificial RED failure was introduced. + +## Packaged desktop command result + +The debug packaged application opened `/private/tmp/opentake-ds-generation-seed-real-device.opentake`, initially containing the persisted text clip `Generation seed verified`. Using the production desktop controls: + +1. **Add Text** created a second clip with ID `29f96e16-4133-440c-acf5-2733b28f23a8`. +2. **Undo** removed that clip and enabled Redo. +3. **Redo** restored the same clip ID and disabled Redo. + +The resulting timeline visibly contained exactly the two expected text clips. + +## Live MCP command result + +Against the same running packaged process and project, the production MCP transport returned: + +- initial `get_timeline`: 90 total frames and two clips (`29f96e16`, `fd55ad76`); +- `add_texts`: added `Agent shared path` as clip `99b61c61` at frame 90 for 30 frames; +- next `get_timeline`: 120 total frames and three clips; +- `undo`: returned `Undid last edit`; +- final `get_timeline`: restored 90 total frames and exactly the original two clip IDs. + +The already-open desktop UI updated live after the MCP undo: it showed the same two clips and enabled Redo. This demonstrates that desktop and Agent/MCP edits converge through the shared production command/history path rather than maintaining isolated client state. + +## Regression gate + +- `cargo fmt --all -- --check`: PASS. +- all four focused owning tests: PASS. +- `cargo test --workspace --no-fail-fast --quiet`: PASS; all executed workspace tests passed, with only the repository's explicitly ignored tests skipped. +- `git diff --check`: PASS. + +## Outcome + +Task 7 is complete. The exact Rust version/event contracts and two real packaged clients agree on edit, undo, redo, identity restoration, and visible state. This closes the 10 mapped records only; it does not reclassify later data-safety tasks or authorize Beta publication. diff --git a/docs/audit/2026-07-14/runtime-artifacts/automated/data-safety-windows-safe-fs-native-2026-07-31.md b/docs/audit/2026-07-14/runtime-artifacts/automated/data-safety-windows-safe-fs-native-2026-07-31.md new file mode 100644 index 00000000..d3f025ca --- /dev/null +++ b/docs/audit/2026-07-14/runtime-artifacts/automated/data-safety-windows-safe-fs-native-2026-07-31.md @@ -0,0 +1,58 @@ +# Data Safety Task 12 — native Windows safe-fs receipt (2026-07-31) + +Status: **native implementation and focused Windows acceptance complete; whole-workflow and independent-review closure pending.** + +## Bound contract + +- Plan: `data-safety-implementation.md`, Task 12 + (`implementation-slice-c7f1cd8463f97ad5`). +- Exact tests: + `crates/opentake-project/src/safe_fs/tests.rs#windows_contract` and + `crates/opentake-project/src/safe_fs/tests.rs#synchronous_nt_pending_is_invariant_error`. +- Exact source SHA: `a1e9fd0ba30ac3471dd106dbe5307f18c49cf5ee`. + +## Native Windows receipt + +- Workflow: [run 30617000877](https://github.com/appergb/OpenTake/actions/runs/30617000877). +- Job: [Safe filesystem (windows-x86_64)](https://github.com/appergb/OpenTake/actions/runs/30617000877/job/91112395644), success. +- Artifact: `c1b-native-windows-x86_64-a1e9fd0ba30ac3471dd106dbe5307f18c49cf5ee`, ID `8787791443`, 3,197 bytes, server digest `sha256:ed079ecf16dacc28a3f1f6775d295ddf5a5ed699869994591f90d5387991ed60`. +- Receipt binds both `requested_sha` and `checked_out_sha` to the exact source + SHA, run attempt 1, Windows Server 2022 x64, and aggregate exit 0. + +All retained command exits were zero: + +```text +cargo fmt --all --check 0 +cargo clippy -p opentake-project --lib --tests -- -D warnings 0 +cargo test -p opentake-project --lib safe_fs -- --test-threads=1 0 +cargo test -p opentake-project --test archive_security -- --test-threads=1 0 +``` + +The native safe-fs runner executed 26 tests with 26 passed, 0 failed. This +includes both exact Task 12 tests plus retained-handle I/O, owner-only DACL +validation and malformed descriptor rejection, same-handle rollback at every +post-create validation point, quarantine/publish without self-conflict, +no-replace rename against every target kind, recursive reparse-point cleanup, +access non-escalation, and retained deletion without following a rebound source +name. + +## Pre-GREEN failure and correction + +The prior exact run +[30616574158](https://github.com/appergb/OpenTake/actions/runs/30616574158) +retained all four command exits and failed the aggregate because one newly +added test required a leaf rename even when the filesystem rejected the +same-handle simulation with `STATUS_SHARING_VIOLATION`. A later parallel suite +also proved that Windows can allow that same simulation after the retained open. +The corrected test accepts both native outcomes while asserting the security +invariant in each: if rebinding is blocked, the original name disappears; if it +succeeds, consuming deletion removes the retained original and preserves the +new replacement at that name. Production share or deletion behavior was not +weakened to make the test pass. + +## Remaining closure + +The focused native contract is GREEN. Task 12 remains open until the remaining +Windows jobs and the whole exact-SHA workflow finish successfully and the +plan's independent-review criterion has a valid receipt. This file therefore +does not authorize Beta publication by itself. diff --git a/docs/audit/2026-07-14/runtime-artifacts/automated/denoise-real-device-2026-08-01.md b/docs/audit/2026-07-14/runtime-artifacts/automated/denoise-real-device-2026-08-01.md new file mode 100644 index 00000000..3a4328a1 --- /dev/null +++ b/docs/audit/2026-07-14/runtime-artifacts/automated/denoise-real-device-2026-08-01.md @@ -0,0 +1,62 @@ +# MR-denoise real-device evidence — 2026-08-01 + +## Scope and acceptance + +- Plan task: `Task 12: MR-denoise (implementation-slice-3d159672cfd1fc67)`. +- Contract: persist mode/strength without modifying the source; expose preview, apply/reset, cancellation and undo/redo; use the same processing owner in preview and export; improve a deterministic speech-plus-noise fixture by at least 3 dB without clipping. +- Runtime target: rebuilt release bundle at `target/release/bundle/macos/OpenTake.app`. + +## TDD and code verification + +- Initial RED: the three reviewed owning tests could not compile because the shared denoise processing owner and domain contract did not exist. +- Regression RED: after the first packaged export exposed an AAC peak near 0 dBFS, `deterministic_noise_fixture_and_bypass` gained a no-new-peak assertion and failed with `input=0.388362`, `output=1.000000`. +- GREEN: unpadded STFT boundary samples now crossfade to the immutable dry input, and processed samples are bounded by the input peak. Focused media, playback-owner and export-owner tests passed. +- `CARGO_INCREMENTAL=0 cargo test --workspace --no-fail-fast --quiet`: PASS. +- `cargo clippy --workspace --all-targets -- -D warnings`: PASS. +- `cargo fmt --all -- --check`: PASS. +- `git diff --check`: PASS. +- `pnpm -C web test`: PASS, 91 files / 818 tests. +- `pnpm -C web build`: PASS; only the pre-existing chunk-size and ineffective-dynamic-import warnings were reported. + +The first full gate attempt stopped because the regenerable dev target cache filled the disk. `cargo clean --profile dev` recovered 16 GiB; the complete gate was then rerun from the beginning and passed. + +## Package identity + +- Executable SHA-256: `d9bac8eb031345b02239e9ddb63588fd780eebba70652134ee1e98947e5506a1`. +- Bundle identifier: `com.opentake.desktop`. +- CDHash: `cffb5e7d81e609ce5d1092ee28af718fc8f70988`. +- `codesign --verify --deep --strict --verbose=2`: PASS for the app and bundled `ffmpeg`/`ffprobe` sidecars. +- Signature: ad hoc (`TeamIdentifier=not set`). This proves package integrity for local runtime validation, not Developer ID distribution readiness or Beta publication eligibility. + +## Deterministic fixtures + +- Clean reference: `/private/tmp/opentake-denoise-clean-20260801.wav`, SHA-256 `3b0fd01951c94ceea1536c9a8d5e6af53b716fba1c5260e5f03ad10ef88c33b4`. +- Noisy input: `/private/tmp/opentake-denoise-noisy-20260801.wav`, SHA-256 `a6011c2dd045e7222cd91c3d4b64916c51f4d2210a685334f71070a0a14084ed`. +- Cancellation input: `/private/tmp/opentake-denoise-noisy-long-20260801.wav`, 300 seconds, SHA-256 `5b766d758f3b1edfb9dacf36c2f3019c029829a03d22931d3a3d704f5469fda5`. +- All inputs are mono 48 kHz PCM float. The five-second fixture combines a speech-envelope sine signal with deterministic white noise. + +## Packaged desktop workflow + +The exact release bundle was launched through the macOS accessibility-driven desktop path. In project `/private/tmp/opentake-denoise-real-device-20260801.opentake`: + +1. Imported the deterministic noisy fixture and added it to A1. +2. Applied adaptive mode at 90%; toggled preview off/on; confirmed native playback advanced; verified undo/redo restored the preview state. +3. Saved, quit, relaunched the exact bundle, opened the recent project, and confirmed adaptive/90% persisted in a writable project. +4. Reset denoise and undid reset, restoring the prior configuration. +5. Added the 300-second fixture, observed asynchronous progress at 56%, cancelled, and confirmed the clip remained unapplied with no error or stale denoise configuration; then removed the long timeline clip. +6. In the rebuilt fixed package, applied voice mode at 84%, confirmed the result label, and undid it back to adaptive/90%. +7. Exported while preview was deliberately disabled, proving preview bypass does not bypass export processing. + +## Independent output measurements + +- Final export: `/private/tmp/opentake-denoise-real-device-v2-20260801.mp4`. +- SHA-256: `2ab36d02bbf2cbb4a1af74df901741b75d7d9ea9bcd9f749e89c02f033701d07`. +- `ffprobe`: H.264 1920×1080 at 30 fps; mono AAC at 48 kHz; duration 5.000 seconds; size 62,215 bytes. +- FFmpeg `asdr`, clean reference versus noisy input: `10.414 dB`. +- FFmpeg `asdr`, clean reference versus exported AAC: `16.2872 dB`. +- Improvement: `+5.8732 dB`, exceeding the required `+3 dB`. +- FFmpeg `astats` exported peak: `-8.645324 dBFS`; no clipping and no boundary peak regression. + +## Result + +PASS for Task 12's code and packaged macOS runtime acceptance contract. This evidence closes only MR-denoise; it does not remove repository-wide Beta blockers or authorize publication. diff --git a/docs/audit/2026-07-14/runtime-artifacts/automated/ffmpeg-license-replacement-2026-08-01.md b/docs/audit/2026-07-14/runtime-artifacts/automated/ffmpeg-license-replacement-2026-08-01.md new file mode 100644 index 00000000..05aedbe7 --- /dev/null +++ b/docs/audit/2026-07-14/runtime-artifacts/automated/ffmpeg-license-replacement-2026-08-01.md @@ -0,0 +1,48 @@ +# Apple Silicon FFmpeg license replacement — 2026-08-01 + +## Scope + +This evidence closes the Apple Silicon `--enable-nonfree` sidecar blocker for the +first local Beta candidate. It does not close the native Windows or Intel macOS +sidecar checks, Developer ID signing, notarization, or final packaged-app +verification. + +## Pinned supply + +The Apple Silicon lock records pin both the downloaded archive and the exact +extracted executable: + +| Tool | Source | Archive SHA-256 | Executable SHA-256 | +| --- | --- | --- | --- | +| FFmpeg 7.0 | `https://www.osxexperts.net/ffmpeg7arm.zip` | `563111a239fe70d2e5c84a5382204a7d0bf0a332385a92a44baff36d313e27f2` | `326895b16940f238d76e902fc71150f10c388c281985756f9850ff800a2f1499` | +| FFprobe 7.0 | `https://www.osxexperts.net/ffprobe7arm.zip` | `e5ae34ee2f0b3594892a695fd733646904bbc7eb40af3b359ed91538ddcb5513` | `307e09bc01bd72bde5f441a1a6df68769da3b2b6e431accfbfc9cf3893ad00c4` | + +Both executables report a GPL configuration without `--enable-nonfree`; their +`-L` output does not contain FFmpeg's non-redistributable warning. + +## Fail-closed supply verification + +`scripts/provision_ffmpeg_sidecars.py` now verifies the archive hash, requires +the one pinned ZIP member, enforces an extraction-size ceiling, verifies the +executable hash, and runs the executable license/configuration checks before it +can replace the destination. Verification rejects `--enable-nonfree` and the +phrase `not legally redistributable`. + +The Python regression suite passed all four cases covering lock validation, +pinned ZIP extraction, archive mismatch rejection, and license rejection. + +## Media compatibility gate + +With the two pinned Apple Silicon executables selected explicitly through +`OPENTAKE_FFMPEG` and `OPENTAKE_FFPROBE`, `cargo test -p opentake-media +--no-fail-fast` passed. This covered 401 unit tests (one separately gated test +ignored), 13 FFmpeg integration tests (one external-fixture test ignored), plus +the denoise, facade, HDR, loudness, proxy, and stems test binaries. The real +sidecar paths exercised probing, RGBA frame decoding, PCM extraction, +thumbnails, waveforms, H.264, H.265, ProRes, HDR, and proxy generation. + +The final Beta gate must still recheck the pinned source hashes before signing, +then prove the embedded executables retain the expected version and license +metadata, pass code-sign validation, and work with an empty `PATH`. macOS code +signing mutates Mach-O bytes, so the post-signing executable hash is not expected +to equal the pre-signing supply hash. diff --git a/docs/audit/2026-07-14/runtime-artifacts/automated/five-panel-layout-real-device-2026-07-31.md b/docs/audit/2026-07-14/runtime-artifacts/automated/five-panel-layout-real-device-2026-07-31.md new file mode 100644 index 00000000..32f2185c --- /dev/null +++ b/docs/audit/2026-07-14/runtime-artifacts/automated/five-panel-layout-real-device-2026-07-31.md @@ -0,0 +1,71 @@ +# Five-panel layout packaged macOS evidence — 2026-07-31 + +## Scope + +- Plans: `home-shell-implementation.md` Task 5 `HS-layout-geometry + CC-layout-misgrouped`; `accessibility-polish-implementation.md` Task 9 `five-panel-layout-focus` +- App: `target/debug/bundle/macos/OpenTake.app` +- Project: `/private/tmp/opentake-beta-qa-20260729/TalkingHeadQA.opentake` +- Host: packaged macOS app, exercised through the native accessibility tree and captured screenshots +- Locale/theme/fixture: Chinese, dark theme, the same TalkingHeadQA project throughout +- Executable SHA-256: `3c7b66247716f17ba38ca4ae1d46320c6f3211273112c3be22e21bdcd267862f` +- Debug DMG SHA-256: `d15d7dc7f77f16fac3b27da37abd8276c07c73813287cf7a1977aded30a4d435` + +## Deterministic code contract + +The two exact owning names execute the same full matrix so the Home Shell and +Accessibility ledgers cannot drift: + +- `all_presets_match_geometry_visibility_maximize_and_focus_shell` +- `all_presets_ratios_gutters_surfaces_focus` + +At 1600×1000 the matrix proves Default 70%, Media 30%/55%, Vertical 50%/55%, +500px Media, 260px Inspector, 320px Agent, leaf order, visibility, focus-ring +transition, click side effects, maximize, and separator keyboard resizing. At +960×600 it proves the compact boundary keeps Preview and Timeline available +with both collapsible side panels hidden. PanelShell uses the shared 5px gap, +6px radius, surface/base tokens, and 1.5px accent focus ring at opacity 0.6. + +## Gates + +- True focused RED: the Home Shell owning name failed while all prior 747 Web + tests passed because the layout had no declared preset boundary, semantic + panel/splitter contract, or complete collapse behavior. +- Both exact focused GREEN names passed independently (1 executed / 1 skipped + in the shared two-name matrix). +- Full Web regression: 75 files / 749 tests passed. +- TypeScript/Vite production build passed. Its existing bundle-size and + ineffective-dynamic-import warnings were unchanged and non-blocking. +- `./web/node_modules/.bin/tauri build --debug` passed and produced the exact app + and DMG identified above. +- `git diff --check` passed. + +## Packaged sequence + +1. Quit the prior process and launched the exact rebuilt app bundle, then opened + TalkingHeadQA from Home. +2. Default exposed Media / Preview / Inspector above Timeline. The accessibility + splitters reported 500px Media, 260px right anchoring through the measured + center split, and a 70% top split; the screenshot showed uninterrupted 5px + base grooves and rounded surface cards. +3. Clicking Media moved the 0.6-opacity accent ring from Timeline to Media. +4. `⌘2` applied Media layout: Media occupied 30% at left; Preview / Inspector + occupied the 55% upper-right region; Timeline filled the lower-right region. +5. `⌘3` applied Vertical layout: the left subtree occupied 50%, its upper + Media / Inspector region occupied 55%, Timeline remained below, and Preview + filled the complete right side. +6. `⌘⌥0` collapsed Inspector. Media expanded across the complete upper-left + region with no empty sibling split. `⌘0` then collapsed Media; only the + non-collapsible Timeline and Preview regions remained. +7. Restored Media and Inspector and enabled Agent with `⌘⌥A`. The accessibility + tree exposed all five localized regions plus four localized splitters, + including the 320px Agent column. +8. Clicked Preview and pressed backquote. Only Preview remained and filled the + editor body. Escape restored all five panels. +9. Restored Default layout, Agent hidden, Media and Inspector visible, and + non-maximized state for the final packaged app state. + +The host display constrains the native window below an unscaled 1600×1000 +capture, so exact 1600×1000 and 960×600 geometry is owned by the deterministic +component fixtures; the packaged screenshots cover the same three presets and +state transitions at the largest host-available window. No project, timeline, +media, or external file was mutated by this verification. diff --git a/docs/audit/2026-07-14/runtime-artifacts/automated/generation-finalization-2026-07-29.md b/docs/audit/2026-07-14/runtime-artifacts/automated/generation-finalization-2026-07-29.md new file mode 100644 index 00000000..ece025f2 --- /dev/null +++ b/docs/audit/2026-07-14/runtime-artifacts/automated/generation-finalization-2026-07-29.md @@ -0,0 +1,45 @@ +# Generation dispatch/finalization runtime evidence — 2026-07-29 + +Scope: `AG-generation-dispatch-finalization + generation-upscale-finalization` (`implementation-slice-f60bdd3e656a7cb0`). All provider responses are deterministic local `MockTransport` fixtures; no paid provider request or user credit was consumed. + +## Production-path artifact assertions + +- `generate_image`: production Dispatcher → shared Tauri GenerationBridge → configured fal adapter; two durable placeholders are returned before background work, paired in output-index order, probed as 2×2 PNG, persisted `ready`, and resolve to real project media files. +- `generate_video`: configured fal adapter returns a valid 16×16 MP4 fixture; the original placeholder identity becomes a durable video asset with probed dimensions. +- `generate_audio`: configured OpenAI adapter returns a valid WAV fixture; the placeholder becomes a durable audio asset with `hasAudio=true` and positive probed duration. +- `upscale_media`: configured Replicate adapter uploads a 2×2 source fixture and returns 4×4 PNG; the output is exactly 2x and the source file bytes remain byte-for-byte identical. +- Cancellation leaves no imported result; ready outputs in a partially finalized N-output job remain ready while non-terminal siblings become cancelled. +- Restart with provider id resumes and finalizes; restart before provider id persists `GENERATION_RESTART_RETRY_REQUIRED` without any resubmission. Retry requires fresh cost authorization and creates a new job. +- Authentication/rate-limit failures persist fixed safe codes. Local/private result targets are rejected. Provider body, credentials, and signed result URLs are absent from project persistence. + +## Verification commands + +```text +cargo test -p opentake-agent --test generation_dispatch placeholder_persist_finalize_all_results_and_failures -- --exact +cargo test -p opentake-agent --test generation_dispatch placeholder_persists_and_every_terminal_result_finalizes_once -- --exact +cargo test -p opentake-gen upscale_uses_first_upload_as_source +cargo test -p opentake-gen byok_submit_then_watch_to_succeeded +cargo test -p opentake-tauri generation::tests +cargo test -p opentake-core --test generation_persistence +cargo test -p opentake-media trim_video_range_materializes_only_visible_window_and_honors_cancel +cargo fmt --all -- --check +cargo clippy --workspace --all-targets -- -D warnings +cargo test --workspace --no-fail-fast -q +pnpm build +pnpm test +git diff --check +``` + +Final result: all commands passed. Workspace tests reported zero failures; repository-declared ignored tests remained ignored. Web result: 70 test files, 703 tests passed. Vite emitted only the pre-existing chunk-size/dynamic-import optimization warnings. + +## Windows CI correction + +The first exact-tree Windows full-product run (`30459985870`, job `90603221524`) failed all three `generation_persistence` tests with Win32 error 32 while replacing `Generation.opentake`. The durable generation transaction had correctly staged a complete sibling bundle, but the live session still retained an open `ProjectRoot` directory handle when the journaled publisher tried to rename the old target. Windows forbids that rename even though macOS permits it. + +The corrected path copies the complete bundle through the retained root, explicitly consumes and closes that root, and only then invokes the existing journal/backup/restore publication commit. Save-As continues to retain its distinct source root. `complete_publish_replaces_the_owned_source_root` pins this same-target Windows requirement, and the three generation persistence tests plus full format/Clippy/workspace gates pass locally after the correction. The replacement keeps whole-bundle atomicity; it does not fall back to two independently visible JSON writes. + +The next exact-tree Windows run (`30462600088`, job `90612253763`) proved that two Tauri-layer handle lifetimes also had to be corrected. Six generation tests failed: four finalization flows held an uncommitted project-media leaf without `FILE_SHARE_DELETE` while complete-bundle publication renamed the project, and two restart tests kept the pre-restart `AppCore` alive while independently opening the same bundle. The import handle now shares deletion on Windows while retaining delete access, identity validation, and handle-relative rollback. The restart fixtures explicitly drop the old core before constructing the new process-equivalent core. + +A later full-product Windows run (`30538580323`, job `90857715826`) narrowed the remaining error-32 failures to five successful-provider finalization paths. Sharing deletion on the leaf was insufficient because `ProjectMediaCapability` also retained root and `media/` directory handles across the live bundle rename. Finalization now streams the downloaded artifact directly into the unpublished sibling stage through `ProjectRoot`; the generated media leaf, `media.json`, and `generation-log.json` therefore share the existing journaled whole-bundle commit. The stream is bounded to the downloader-recorded byte size, and an undersized, oversized, or failed stream aborts without changing the live bundle. `generated_media_stream_failure_preserves_the_live_bundle_byte_exact` pins that rollback guarantee. + +After this correction, local `generation::tests` pass 9/9, the complete workspace test run reports zero failures (only repository-declared hardware probes remain ignored), workspace Clippy with warnings denied passes, and formatting/diff checks pass. The next exact-tree Windows CI rerun is the authoritative remaining platform confirmation. diff --git a/docs/audit/2026-07-14/runtime-artifacts/automated/generic-effects-export-2026-07-31.mp4 b/docs/audit/2026-07-14/runtime-artifacts/automated/generic-effects-export-2026-07-31.mp4 new file mode 100644 index 00000000..ce85ce36 Binary files /dev/null and b/docs/audit/2026-07-14/runtime-artifacts/automated/generic-effects-export-2026-07-31.mp4 differ diff --git a/docs/audit/2026-07-14/runtime-artifacts/automated/generic-effects-export-frame-2026-07-31.png b/docs/audit/2026-07-14/runtime-artifacts/automated/generic-effects-export-frame-2026-07-31.png new file mode 100644 index 00000000..45ff166e Binary files /dev/null and b/docs/audit/2026-07-14/runtime-artifacts/automated/generic-effects-export-frame-2026-07-31.png differ diff --git a/docs/audit/2026-07-14/runtime-artifacts/automated/generic-effects-packaged-ui-2026-07-31.png b/docs/audit/2026-07-14/runtime-artifacts/automated/generic-effects-packaged-ui-2026-07-31.png new file mode 100644 index 00000000..59f09b91 Binary files /dev/null and b/docs/audit/2026-07-14/runtime-artifacts/automated/generic-effects-packaged-ui-2026-07-31.png differ diff --git a/docs/audit/2026-07-14/runtime-artifacts/automated/generic-effects-preview-frame-2026-07-31.png b/docs/audit/2026-07-14/runtime-artifacts/automated/generic-effects-preview-frame-2026-07-31.png new file mode 100644 index 00000000..957d0284 Binary files /dev/null and b/docs/audit/2026-07-14/runtime-artifacts/automated/generic-effects-preview-frame-2026-07-31.png differ diff --git a/docs/audit/2026-07-14/runtime-artifacts/automated/generic-effects-real-device-2026-07-31.md b/docs/audit/2026-07-14/runtime-artifacts/automated/generic-effects-real-device-2026-07-31.md new file mode 100644 index 00000000..918202e6 --- /dev/null +++ b/docs/audit/2026-07-14/runtime-artifacts/automated/generic-effects-real-device-2026-07-31.md @@ -0,0 +1,111 @@ +# Generic Effects Real-Device Verification — 2026-07-31 + +## Scope and contract + +- Plan item: `MR-generic-effects` / Task 6. +- Closed persisted registry: `grayscale`, `sepia`, `invert`. +- Each effect accepts an optional finite `amount` in `0.0..=1.0` (default `1.0`). +- Unknown effect names, unknown parameters, non-finite values, out-of-range values, and chains longer than eight return typed validation errors. They do not silently render unchanged. +- Inspector add, reorder, parameter, enable/disable, and remove mutations route through the undoable `SetEffects` command. +- Preview and export use the same ordered GPU effect implementation. + +## Reviewed RED/GREEN owning test + +Owning test: + +```text +crates/opentake-render/tests/gpu_effects.rs#advertised_effect_registry_has_preview_export_golden_fixtures +``` + +The required focused command was first run before implementation and failed to compile because `effect_registry` and `Effect::validate` did not exist. After implementation, the exact command passed: + +```sh +cargo test -p opentake-render --test gpu_effects advertised_effect_registry_has_preview_export_golden_fixtures -- --exact +``` + +The test asserts the exact registry, default and non-default golden pixels for every advertised effect, observable chain order, fresh preview/export byte equality, and typed rejection of an unknown effect. + +## Code and package gates + +All gates completed successfully on the source used for this app bundle: + +- `npm test`: 89 test files and 807 tests passed. +- `npm run build`: passed; only the repository's existing chunk-size warnings were emitted. +- `cargo fmt --all -- --check`: passed. +- `cargo clippy --workspace --all-targets -- -D warnings`: passed; Cargo only reported the pre-existing `block 0.1.6` future-incompatibility notice. +- `cargo test --workspace --no-fail-fast`: passed; explicit real-device tests remained ignored as designed. +- `web/node_modules/.bin/tauri build --bundles app --no-sign`: passed. + +Packaged binary: + +```text +target/release/bundle/macos/OpenTake.app/Contents/MacOS/opentake +SHA-256 a4b77109d9b59a264c7657fa71d3aaaad8655d73669b7f922604e5dfeb1f17f3 +``` + +`codesign --verify --deep --strict --verbose=2` passed for the app and bundled `ffmpeg`/`ffprobe`. The inspected signature is ad hoc (`Signature=adhoc`, no TeamIdentifier); this is local integrity evidence only, not Developer ID signing or notarization evidence. + +## Packaged macOS GUI workflow + +The packaged app was operated through macOS accessibility/Computer Use against an isolated copy of the real `TalkingHeadQA` project: + +```text +/private/tmp/opentake-generic-effects-real-device-20260731.opentake +``` + +Observed sequence: + +1. Selected visual clip `id-4` and added grayscale at 41%, then sepia. +2. Moved sepia above grayscale; undo restored the prior order and redo restored the new order. +3. Disabled and re-enabled grayscale. +4. Removed sepia and used undo to restore it. +5. Added invert, producing the ordered enabled chain `sepia(1.0) -> grayscale(0.41) -> invert(1.0)`. +6. Native playback advanced the playhead without an unsupported-effect error and the rendered preview visibly changed from the blue source to the expected light grayscale/inverted result. +7. Saved, returned home, and reopened the project from Recent. The exact chain, parameter, and enabled states persisted; undo and redo were disabled on this fresh project session. +8. Captured frame 0 through the app's native “capture current frame” action and exported the full timeline through the packaged H.264/1080p UI path. + +Persisted `project.json` SHA-256: + +```text +138ce3e2a7f7fb409a742119e987f8b1b70c9b5e65a967cf8d4fbfc427226aac +``` + +The persisted clip payload was inspected directly and contained: + +```json +{ + "id": "id-4", + "mediaRef": "id-1", + "effects": [ + { "name": "sepia", "params": {}, "enabled": true }, + { "name": "grayscale", "params": { "amount": 0.41 }, "enabled": true }, + { "name": "invert", "params": {}, "enabled": true } + ] +} +``` + +## Export and preview parity + +Bundled `ffprobe` reported the UI-exported artifact as H.264 video plus AAC audio, 1920×1080, 30 fps, 920 video frames, and 30.666667 seconds. SHA-256: + +```text +7af20a51688ff3d2522e318ffeb16f126d1fd55d8bcf5df461de1f2696609fab +``` + +The native preview capture and decoded export frame 0 are both 1920×1080. Bundled `ffmpeg` comparison produced: + +```text +SSIM All: 0.999838 (37.902042) +PSNR average: 39.679823 dB +``` + +The small non-zero delta is consistent with H.264 encoding; both frames show the same ordered effect result. Exact uncompressed preview/export byte equality is separately enforced by the owning GPU test. + +## Evidence artifacts + +- `generic-effects-packaged-ui-2026-07-31.png` — reopened packaged UI showing the persisted ordered chain and 41% parameter. +- `generic-effects-preview-frame-2026-07-31.png` — native packaged-app frame capture. +- `generic-effects-export-2026-07-31.mp4` — full packaged-app H.264 export. +- `generic-effects-export-frame-2026-07-31.png` — decoded export frame 0 used for parity measurement. + +This evidence completes Task 6 only. It does not close the project-wide Developer ID/notarization, Windows real-device, CI-dispatch, or Beta-release gates. diff --git a/docs/audit/2026-07-14/runtime-artifacts/automated/hdr-proxy-account-real-device-2026-08-01.md b/docs/audit/2026-07-14/runtime-artifacts/automated/hdr-proxy-account-real-device-2026-08-01.md new file mode 100644 index 00000000..e5d23ce0 --- /dev/null +++ b/docs/audit/2026-07-14/runtime-artifacts/automated/hdr-proxy-account-real-device-2026-08-01.md @@ -0,0 +1,195 @@ +# HDR / proxy / account packaged real-device evidence — 2026-08-01 + +Parent: `MR-hdr-proxy-account-composite` + +Environment: macOS arm64, packaged `target/release/bundle/macos/OpenTake.app`, +bundled FFmpeg/FFprobe, Chinese UI. All desktop interaction below was performed +through the packaged GUI; shell probes only generated fixtures or inspected the +resulting project/output artifacts. + +## Packaged artifact under test + +- Executable SHA-256: `38c3c1c2aec593d8b9fea79691cec0eb69fdad5d5eae6e193897fcc4ae65cf95` +- DMG SHA-256: `8a317e64d60ec78a67a096bff7eab5b57d56117a68c863563e5e8020eaefeae2` +- Whole `.app` and the app copied from the rebuilt DMG pass + `codesign --verify --deep --strict`. +- Signature is ad hoc (`Signature=adhoc`, `TeamIdentifier=not set`). This is + local verification evidence, not Developer ID/notarization evidence. + +## HDR child + +Fixture: + +- `/private/tmp/opentake-task15-hdr-pq-20260801.mp4` +- SHA-256: `52cd239ae9f29dd9e990a914b00c01e075d66e3283d1644372aaa942f818ce11` +- HEVC Main10, 640x360, `bt2020nc / smpte2084 / bt2020`. + +Packaged GUI evidence: + +- Import persisted the exact source metadata in `media.json`. +- Source Inspector displayed `HDR 色彩` and + `smpte2084 · 预览与导出映射为 BT.709 SDR`. +- Double-click insertion created a 48-frame clip at 24 fps. +- Timeline preview rendered the color test frame and playback advanced from + `00:00:00` to `00:01:06 / 00:02:00`. +- GUI export completed as `1280×720 · 48 帧`. + +The first packaged export exposed a real defect: it produced a 4,215-byte, +all-black H.264 file (SHA-256 +`97a4035e2f35429609e7ba5ad69cafce6521581866cc45a86ad1b482f1eb812f`, +Y=16..16). The active developer FFmpeg exposes `scale_vt`, while the bundled +sidecar exposes `zscale`; the old implementation selected by OS instead of the +actual binary capability. A full-timeline regression test reproduced RGB +`0..=0` with the bundled sidecar. Runtime filter detection fixed the path and +the same test passed. + +Final packaged GUI output: + +- `/private/tmp/opentake-task15-hdr-export-fixed-20260801.mp4` +- SHA-256: `58a40abad454a5126360f9f7cd3d9a87716f6587958fb8c28ba8ae68a223b13e` +- H.264, 1280x720, 2.000 s, yuv420p. +- `color_space=bt709`, `color_transfer=bt709`, + `color_primaries=bt709`. +- Decoded signal range Y=15..249 with non-zero saturation; the fixed delivery + retains picture contrast and is not a black fallback. + +Focused checks: + +- `cargo test -p opentake-media --test hdr` — 3 passed. +- `cargo test -p opentake-media color::tests::` — 2 passed. +- Full HDR timeline export with `OPENTAKE_FFMPEG` and `OPENTAKE_FFPROBE` + pointed at the packaged sidecars — passed after reproducing the RED failure. + +HDR child result: **PASS** for the declared v1 SDR-delivery policy. This is not +an HDR-passthrough claim. + +## Proxy child + +Fixture and generated proxy: + +- Original source: + `/private/tmp/opentake-task15-proxy-source-20260801.mp4` +- Original SHA-256: + `633043ad47858461c8939a3e57634c1070fbb219fa66e0021d434cd1836a8ca4` +- Source: H.264 1920x1080 plus AAC, 3.000 s. +- Project proxy after the final packaged remove/recreate probe: + `media/proxies/89e91938-3ce0-49b8-b955-775a1871ad40.mp4` +- Proxy SHA-256: + `88c645547be8b47f05383301cbbec3465f227c18b5b2501082e2bacb2fdc12b6` +- Proxy: H.264 1280x720 plus AAC. +- `media.json` stores only the project-relative path, the exact original-source + digest, and 1280x720 dimensions. + +Packaged GUI evidence: + +- Inspector moved from `生成 720p 代理` to + `代理媒体 已就绪 · 1280 × 720` and `移除代理`. +- Project close/reopen retained the proxy metadata and file. +- General Settings persisted `优先使用代理媒体播放` across app restart. + +The first packaged playback attempt exposed a second real defect: the proxy was +outside the static asset-protocol scope, so enabling proxy playback produced +`00:00 / 00:00` while the original poster remained visible. The fix grants only +the exact regular proxy file at creation/catalog load, rejects symlinks, and +durably denies removed proxy paths. Focused scope tests pass (2/2). + +Final code review exposed a third boundary before publication: a regular proxy +leaf under a symlinked `media/proxies` ancestor could still escape the project. +A regression first failed because nested proxy paths were accepted. Creation, +playback, catalog authorization, relink, and removal now share an exact +`media/proxies/.mp4` resolver that rejects symlink/reparse ancestors and +confirms both directory parents remain inside the project. Project replacement +also cancels the single in-flight transcode while holding the identity lease. +The core nested-path and Tauri symlinked-ancestor regressions both pass. +Relink and removal now retain the project-identity workflow lease through old +proxy deletion and scope revocation. The concurrency regression starts a +project replacement during file cleanup and proves it cannot complete until +cleanup returns. + +Path-switch proof: + +- The generated proxy was backed up, then temporarily replaced with a compatible + pure-magenta H.264/AAC file (SHA-256 + `4f6d6b76d31737d30c8adfd021d55154f1a85d35ebf9bd0c459d0f0981010d82`). +- With proxy preference on, packaged preview played the magenta file and + advanced to `00:00:14 / 00:03:00`. +- Turning proxy preference off immediately restored the original moving color + test source and advanced to `00:00:13 / 00:03:00`. +- The original generated proxy was restored afterward; its SHA-256 again equals + `88c645...12b6`. + +Original-only export proof: + +- Proxy preference stayed on and packaged preview visibly showed the magenta + proxy before export. +- The GUI exported the five-second HDR+proxy-source timeline as 1280x720, + 120 frames. +- Output: + `/private/tmp/opentake-task15-proxy-export-original-proof-20260801.mp4` +- Output SHA-256: + `6cc5479a3559b64da503290890668d913ebea09c366b2ac04c49e32a6264e5d0` +- Output has H.264 + AAC, duration 5.000 s, and BT.709 tags. +- At 2.5 s (inside the proxy-source clip), decoded Y=15..235 with the moving + source pattern. A pure-magenta proxy would have a near-constant luma plane. + Export therefore used the original source, not the enabled proxy. +- The generated proxy was restored after this probe and its original SHA-256 + was re-verified. + +Latest-package repeat after the security fix: + +- The process path was the rebuilt bundle under test, not the older installed + `/Applications` copy. +- Settings showed proxy playback `on`; packaged playback inside the proxy-backed + clip advanced from `00:03:17` to `00:04:16`. +- Inspector executed `移除代理`, returned to `生成 720p 代理`, then regenerated + to `已就绪 · 1280 × 720`. +- The new UUID leaf probes as H.264 1280×720 plus AAC. Its SHA-256 is again + `88c645547be8b47f05383301cbbec3465f227c18b5b2501082e2bacb2fdc12b6`, + and `media.json` still binds it to the exact original-source digest. + +Final packaged repeat after the identity-lease fix: + +- The rebuilt bundle was quit and relaunched before the probe so the test used + executable SHA-256 `38c3c1c2...cf95`, not an already-running prior inode. +- Inspector executed `移除代理`, returned to `生成 720p 代理`, then regenerated + to `已就绪 · 1280 × 720`. +- Packaged playback advanced from `00:00:00` to `00:01:20 / 00:03:00`. +- The regenerated UUID leaf is the path recorded above, probes as H.264 + 1280×720 plus AAC, has the deterministic proxy SHA-256 above, and remains + bound to source digest `633043ad...ca4`. + +Proxy child result: **PASS**. + +## Account child + +Packaged GUI evidence: + +- Initial state: no backend address, login disabled, `未登录`. +- Saving `http://example.com` failed with + `Remote account backends must use HTTPS`; login remained disabled. +- Saving `http://127.0.0.1:9` succeeded as the explicitly allowed local + development exception. +- A non-secret dummy token attempted only + `http://127.0.0.1:9/api/auth/verify` and failed closed with a controlled + connection error because no service was listening. +- During that error state, the saved project reopened, the three timeline clips + remained present, export stayed enabled, and local playback advanced to + `00:00:18 / 00:05:00`. +- The test backend was cleared afterward; the UI returned to an empty address, + disabled login button, and `未登录`. The token field was cleared after the + failed attempt. + +Focused Rust check: `cargo test -p opentake-tauri account::tests::` — 21 passed. +The full Web suite also covers AccountPane state and error rendering. + +Account child result: **PASS**. Local editing remains the default and is not +gated by account availability. + +## Composite conclusion + +`HDR child PASS + proxy child PASS + account child PASS` closes one composite +acceptance for `MR-hdr-proxy-account-composite`, subject to the repository-wide +gates and the project-level external release blockers recorded in the handoff. +The functional Rust/Web gates pass. The completion-audit runner has 205/206 +passing; its sole failure is the protected file inventory omitting previously +tracked files, so that ledger step remains explicitly open. diff --git a/docs/audit/2026-07-14/runtime-artifacts/automated/headless-chromium-real-device-2026-08-01.md b/docs/audit/2026-07-14/runtime-artifacts/automated/headless-chromium-real-device-2026-08-01.md new file mode 100644 index 00000000..fe4bc6ab --- /dev/null +++ b/docs/audit/2026-07-14/runtime-artifacts/automated/headless-chromium-real-device-2026-08-01.md @@ -0,0 +1,79 @@ +# Headless Chromium real-device verification — 2026-08-01 + +Scope: Task 24 / `MR-native-chromium`. This receipt closes the optional native +HTML/CSS/JS renderer only. It does not claim that the desktop app has wired a +motion-graphic timeline tool, and it is not Beta-release approval. + +## Runtime + +- Host: macOS, Apple Silicon, Asia/Shanghai +- Browser: Google Chrome `150.0.7871.187` +- Executable: `/Applications/Google Chrome.app/Contents/MacOS/Google Chrome` +- Backend: direct Chrome DevTools Protocol over a local WebSocket +- Cargo feature: `opentake-motion/chromium` + +## RED + +The reviewed-planned integration owner was added first and run with the live +feature. Compilation failed because the baseline had no +`MotionCancellationToken`, browser discovery/path override, cancellation API, +`MotionError::Cancelled`, or live renderer. The baseline feature branch still +returned `RendererUnavailable` unconditionally. + +## Live acceptance + +Command: + +```text +cargo test -p opentake-motion --features chromium --test chromium virtual_time_network_csp_timeout_cleanup_and_frame_identity -- --exact --nocapture +``` + +Result: PASS, one exact test executed. It verified all of the following through +a real Chrome process: + +- The same three-frame animation rendered into separate cache roots twice with + byte-identical PNGs. +- Frame 0 and frame 2 differed visibly, proving `OpenTake.seek(i/fps)` advanced + virtual time rather than returning a static screenshot. +- A real browser PNG decoded through the injected `MotionClipSource` decoder as + a `48x32` RGBA frame. +- A loopback HTTP origin explicitly added to `SandboxPolicy` was requested and + served; the render completed. +- A non-allowlisted HTTPS resource and a `file:///etc/passwd` resource both + failed closed as `MotionError::Sandbox`. +- A runaway `while(true)` author script hit the 500 ms budget and returned + `MotionError::Timeout`. +- A deliberately crashing browser executable returned `RenderFailed`; empty + source returned `InvalidSource`. +- An in-flight runaway render was cancelled from another thread and returned + `MotionError::Cancelled`. +- Policy failure removed partial `frame_*.png` output. Across success, sandbox + failure, timeout, crash, malformed input, and cancellation, no process-scoped + `opentake-chromium-*` profile directory remained. + +The allowlist pure tests additionally prove that an origin prefix lookalike such +as `https://cdn.jsdelivr.net.evil.example` and a loopback lookalike such as +`http://localhost.evil.example` are rejected. + +## Gates + +- Default focused owner: PASS; one exact fail-closed test executed. +- Default planned integration owner: PASS; one exact test executed and asserted + `RendererUnavailable` without launching a browser. +- Feature-enabled motion package: PASS — 55 unit + 1 Chromium integration + 3 + pipeline tests. +- `cargo clippy -p opentake-motion --all-targets -- -D warnings`: PASS. +- `cargo clippy -p opentake-motion --all-targets --features chromium -- -D warnings`: + PASS. +- `cargo fmt --all -- --check`: PASS. +- `cargo test --workspace --no-fail-fast`: PASS. The first attempt stopped at + archive creation with `No space left on device`; only generated + `target/debug/deps` and `target/debug/incremental` were deleted, preserving + `target/release`, and the identical command then passed. + +## Remaining boundary + +`HeadlessChromiumRenderer` remains feature-gated and `MotionClipSource` remains +an unconnected library adapter. Desktop/core/agent motion materialization, +timeline placement, packaged browser provisioning on Windows, and the planned +Motion Canvas plugin are separate owned tasks and remain open. diff --git a/docs/audit/2026-07-14/runtime-artifacts/automated/home-autosave-metadata-real-device-2026-07-31.md b/docs/audit/2026-07-14/runtime-artifacts/automated/home-autosave-metadata-real-device-2026-07-31.md new file mode 100644 index 00000000..3edb2323 --- /dev/null +++ b/docs/audit/2026-07-14/runtime-artifacts/automated/home-autosave-metadata-real-device-2026-07-31.md @@ -0,0 +1,47 @@ +# Home autosave and metadata real-device verification (2026-07-31) + +## Scope + +- Contract: `HS-autosave-metadata-mixed` +- Owning requirement: `requirement-034f4a07aa8a3c47` +- Exact final application: `target/debug/bundle/macos/OpenTake.app` +- Exact final DMG: `target/debug/bundle/dmg/OpenTake_1.0.0_aarch64.dmg` + +## RED and GREEN evidence + +- The planned test `autosave_and_home_metadata_have_separate_owners` first failed because a successful save left `modifiedAt` at `750`, left `thumbnailPath` null, and therefore exposed no persisted Home metadata update. +- The native metadata test first failed to compile because `HomeProjectEntry` had no `modified_at` or `thumbnail_path` fields. +- The Home interaction test first failed because the project card rendered no image and no relative-time label. +- Planned exact test: PASS (`1 passed`). +- Native metadata exact test: PASS (`1 passed`). +- Focused project/recent/Home regression set: PASS (`4` files, `27` tests). +- Web full suite: PASS (`77` files, `755` tests). +- Web production build: PASS with only the existing Vite mixed-import and bundle-size advisories. +- Rust Clippy: PASS with `-D warnings`. +- `cargo fmt --all -- --check && cargo test --offline --workspace --no-fail-fast`: PASS; the Tauri library ran `376` unit tests and hardware-only probes retained their explicit owning-runner ignores. +- `git diff --check`: PASS. + +## Artifact identity + +- Final app executable SHA-256: `adf7b04897ef0704b9cc43c4ef565b5ccdc06ce31cfafb67528c97374eecb219`. +- Final DMG SHA-256: `f2098fd08970230569f085302f862e51f5642156ce01fd7b1aa2d38562af4420`. +- Both bundles were rebuilt after the final visual contract changed from path-plus-time to relative-time-only metadata. + +## Packaged edit → save → close → reopen walkthrough + +All mutations used `/tmp/OpenTake-Autosave-QA-20260731-1231.opentake`, a temporary copy of the built-in tutorial. A valid 320×180 JPEG cover was copied into that QA bundle. No pre-existing user project was modified. + +1. Opened the QA bundle through the native directory picker. Its baseline `project.json` SHA-256 was `e4352964a8bcd331f5f25db19a26c63121890d92f68d8a2cee512f7ad677e421` and the timeline contained three clips. +2. Activated `添加文本`. After the 1.5-second debounce, the persisted project hash changed to `8ddaaecd6d2ffc3f117db9e3d3b67492bdd25ce69065285da5c5cdf4a847ca01`, proving silent autosave completed. +3. Activated `添加文本` again and immediately requested window close, before the debounce could fire. The native CloseRequested flush changed the persisted hash to `5571da3a4878af4d19a6ab37fc3947cdb9d40ea9026bc5a2dbef068c21d0a2d7`; the saved project contained three tracks and five total clips. +4. After the close-to-Home event settled, reopened the resident app. Home contained four recent projects and the QA card showed its 16:9 cover plus `今天`; the absolute path was no longer used as the visible subtitle. +5. Double-opened the QA card. The editor exposed both newly persisted clip IDs plus the original three tutorial clips, proving the autosave and close-flush writes both survived reopen. +6. Returned Home, removed only the QA recent entry (`4 recent` → `3 recent`), then moved the temporary QA bundle to `~/.Trash/OpenTake-Autosave-QA-20260731-1231.opentake` for recoverable cleanup. +7. Rebuilt the final app and DMG after the relative-time-only visual correction. Launched the exact final `.app` as the sole OpenTake instance and confirmed all three retained user cards display covers/placeholders and localized relative time without visible filesystem paths. + +## Ownership and recovery assertions + +- `openedAt` owns recent ordering and changes only when a project opens; a successful save updates `modifiedAt` and `thumbnailPath` without moving the card. +- The native registry refreshes metadata from the persisted `project.json` mtime and optional bundle-root `thumbnail.jpg`; missing bundles retain their last metadata and do not attempt cover loading. +- Save failure does not update Home metadata, while the existing save coordinator keeps the project dirty and exposes its error toast. +- The temporary QA bundle remains recoverable in system Trash. The three original recent projects remained registered at the end. diff --git a/docs/audit/2026-07-14/runtime-artifacts/automated/home-component-mapping-real-device-2026-07-31.md b/docs/audit/2026-07-14/runtime-artifacts/automated/home-component-mapping-real-device-2026-07-31.md new file mode 100644 index 00000000..4effce19 --- /dev/null +++ b/docs/audit/2026-07-14/runtime-artifacts/automated/home-component-mapping-real-device-2026-07-31.md @@ -0,0 +1,82 @@ +# Home component mapping real-device evidence — 2026-07-31 + +## Scope + +- Plan: `home-shell-implementation.md`, Task 8 `HS-component-mapping-composite`. +- Requirement: `requirement-047e16af5d02f827`. +- Product boundary: packaged macOS Tauri application, not a browser-only mock. +- Verification project: `/private/tmp/opentake-beta-qa-20260729/TalkingHeadQA.opentake`. + +## Code and package gates + +- `pnpm --dir web test`: PASS, 82 files / 773 tests. +- `pnpm -C web exec vitest run src/components/shell/ShellComponentMapping.test.tsx -t every_documented_shell_component_has_exact_owner`: PASS, 1/1 exact owner-index test. +- `pnpm --dir web build`: PASS. Only the pre-existing Vite large-chunk and ineffective-dynamic-import warnings remain. +- `cargo test --workspace --no-fail-fast --quiet`: PASS across the workspace; the platform/real-device probes marked ignored by their owning suites remained ignored. +- `cargo fmt --all -- --check`: PASS. +- `git diff --check`: PASS. +- `web/node_modules/.bin/tauri build --debug --bundles app,dmg`: PASS. + +Verified package artifacts: + +| Artifact | Size | SHA-256 | +|---|---:|---| +| `target/debug/bundle/macos/OpenTake.app/Contents/MacOS/opentake` | 148338232 bytes | `329a937ee161abf913252ebed2e1dcde4e24dcce84fb06111091dd949156c68f` | +| `target/debug/bundle/dmg/OpenTake_1.0.0_aarch64.dmg` | 42588353 bytes | `a63074ee6a879767feb381d8b1add548ab989efa4e7efd74cef6cad2a722e7b1` | + +These are Task 8 verification artifacts, not the project-wide Beta release. Beta remains blocked until every implementation-plan record and the release gates are closed. + +## Packaged application walkthrough + +### Logical linked A/V selection and Inspector ownership + +1. Opened `TalkingHeadQA` from Home in the rebuilt application. +2. Selected the V1 picture clip whose linked audio companion is selected by the timeline. +3. Inspector exposed `视频`, `音频`, and `AI 编辑` rather than treating the linked pair as an unrelated multi-selection. +4. A genuine multi-selection continues to use the multi-selection Inspector state in the owning tests. + +This walkthrough exposed and closed the original linked-selection defect by routing Inspector, crop, and transition selection through `findLogicalSingleClip`. + +### AI Edit + +1. Generated the deterministic balanced-polish proposal. +2. Rejected the first proposal and observed that no undo entry was created. +3. Generated again, accepted, and applied the proposal. The UI reported `建议已通过可撤销编辑命令应用。`; global undo became enabled. +4. Activated `撤销本次应用`; the proposal returned to idle, undo disabled, and redo enabled. +5. Owning tests also cover generation failure and cancellation without command submission. + +### Music + +1. Opened `音乐` and verified independent project-music and saved-library empty states. +2. Activated `AI 生成音乐`; Agent opened and received the exact draft requiring style, mood, duration, instrumental choice, model, and cost confirmation before paid generation. +3. Imported `/tmp/opentake-task8-music.wav` through the native macOS file panel. Input SHA-256: `c087187ef80798631ceac4eee8d43c8d4d441451576abdf45a8d7b7bd2269129`. +4. The imported item appeared in project music, `放到时间线` succeeded, timeline duration advanced to `00:30:20`, Audio Inspector opened, and undo became available. +5. After application restart, the project music entry and placed audio remained present. + +### Cross-dissolve transition + +1. Opened `转场`, selected linked V1 clip `id-4`, and resolved the exact adjacent cut `id-4 → c7e1e6d4-6be2-4844-a827-c4fa9e20d402`. +2. Applied a 15-frame / 0.50-second cross dissolve. The selection and transition panel stayed visible and the UI reported success. +3. Returned Home, reopened the project, and observed the same transition selected with `aria-pressed=true`, a 15-frame duration, and a visible Remove action. +4. Project persistence contained `transitionOut.kind = crossDissolve`, `durationFrames = 15`, and the exact `toClipId` on `id-4`. +5. Removed the transition, observed `转场已移除。`, then used global undo. The transition returned, redo became available, and the stale removal message was no longer rendered. + +The walkthrough found and closed two packaged-app-only interaction defects: linked A/V selection hid the single-clip controls, and the generic media panel mouse-down handler cleared the cut selection while applying a transition. A final pass also bound transient feedback to the current project state so global undo cannot leave contradictory text. + +## Real export result + +With the saved cross dissolve active, the packaged application exported `/tmp/opentake-task8-transition-export.mp4` through the native save panel. + +`ffprobe` result: + +- video: H.264, 1280×720, `yuv420p`, 30/1 fps; +- audio: AAC; +- duration: 30.666667 seconds; +- size: 226647 bytes; +- SHA-256: `133a275ac193bf2e9e5b18f49e701a84cbdde6e278fec7d5d05b186e680f126b`. + +The render-plan owning tests additionally verify the outgoing/incoming weighted layer sequence inside the dissolve window and that preview/export consume the same plan. + +## Outcome + +Task 8 is complete and verified at its owning code, persistence, packaged-interaction, and export boundaries. This evidence closes only the Task 8 component-mapping slice; it does not reclassify the broader Inspector plan or authorize the first Beta release. diff --git a/docs/audit/2026-07-14/runtime-artifacts/automated/home-new-project-real-device-2026-07-31.md b/docs/audit/2026-07-14/runtime-artifacts/automated/home-new-project-real-device-2026-07-31.md new file mode 100644 index 00000000..4e3dfa15 --- /dev/null +++ b/docs/audit/2026-07-14/runtime-artifacts/automated/home-new-project-real-device-2026-07-31.md @@ -0,0 +1,26 @@ +# Home new-project acceptance — 2026-07-31 + +Scope: Home Shell Task 9 (`implementation-slice-60d775675af9091c`) on the exact arm64 debug bundle at `target/debug/bundle/macos/OpenTake.app`. + +## Code verification + +- RED: the three exact candidate tests failed because activating New Project exposed no shared `home.creating` state and did not disable the other Home lifecycle controls while creation was pending. +- The previous native flow called `project_new` before the initial `project_save(path)`. A failed first save could therefore discard the live project before reporting the failure. +- GREEN: all sidebar, empty-launcher, and populated-launcher New Project controls now share the Home lifecycle single-flight state with Open Project and recent cards. During creation, both New controls announce `正在创建…`; every New/Open/recent entry is natively disabled with `aria-busy`; settlement restores all controls. +- `project_new(path)` now creates and saves a separate fresh `AppCore` on a blocking worker, reopens the resulting bundle, and commits the prepared project only after the entire operation succeeds. A 15-second lifecycle timeout and the shared coordinator prevent main-thread stalls and overlapping New/Open publication. The no-path browser fallback remains supported. +- Failure tests prove that an invalid destination leaves the live Rust core, front-end project snapshot, media state, and Home view unchanged while exposing the localized structured error toast. Successful creation returns the committed bundle path directly and no longer performs a second `project_save` call. +- Focused results: 31/31 Home/project-action tests passed and 5/5 Rust async lifecycle tests passed. Each of the three plan-prescribed candidate commands executed successfully. +- Regression results: 72 Web files / 742 tests passed; production Web build passed; default and `--no-default-features` Tauri checks passed; both Clippy gates passed with `-D warnings`; `cargo fmt --all -- --check && cargo test --workspace --no-fail-fast` passed. Remaining output is limited to the repository's pre-existing `block v0.1.6` future-incompatibility warning, the known Vite chunk/dynamic-import warnings, and explicitly ignored hardware probe tests. + +## Real-device verification + +1. Started from populated Home and activated the sidebar `新建项目`. The native macOS save sheet opened. Cancelling it returned to Home with all controls enabled and no project/recent mutation. +2. Repeated the sidebar action, chose `/private/tmp/Task9Sidebar-20260731.opentake`, and saved. The app entered a fresh editor with an empty media library and `00:00:00` timeline. The bundle contains valid `project.json` and `media.json`; the timeline is 1920×1080 at 30 fps with zero tracks. +3. Returned to populated Home and activated the hero `新建项目`, saving `/private/tmp/Task9Populated-20260731.opentake`. The same fresh-editor and on-disk bundle checks passed. +4. Removed recent entries only (the bundles remained untouched) to expose the empty launcher, then activated its `新建项目` and saved `/private/tmp/Task9Empty-20260731.opentake`. The app entered the fresh editor and produced the same valid bundle structure. +5. Returned Home and double-clicked the `Task9Empty-20260731` recent card. The just-created bundle reopened successfully in the same packaged process, proving the first save produced a reusable project rather than a UI-only session. +6. Selected `/private/tmp` itself through the native Open Project picker as an invalid bundle. Home exposed `打开失败:missing required project.json in bundle at /private/tmp`, remained responsive, and recovered all lifecycle controls. This confirms the shared failure/retry path after the Task 9 state refactor. +7. Terminated duplicate stale debug/installed processes that shared the bundle identifier, relaunched exactly one current debug bundle, and repeated the final checks to exclude automation routing ambiguity. +8. Reopened the preserved `/private/tmp/opentake-beta-qa-20260729/TalkingHeadQA.opentake`; the editor showed `talking-head-30s` and duration `00:29:20`. Returning Home restored `TalkingHeadQA` alongside `Task9Empty-20260731` in recents. No pre-existing project bundle was modified or deleted. + +Result: PASS. All three planned New Project controls have native cancel/retry, success, persisted-bundle, reopen, busy exclusion, and explicit failure/recovery evidence, while initial-save failure can no longer replace the active project. diff --git a/docs/audit/2026-07-14/runtime-artifacts/automated/home-open-project-real-device-2026-07-30.md b/docs/audit/2026-07-14/runtime-artifacts/automated/home-open-project-real-device-2026-07-30.md new file mode 100644 index 00000000..caa96e12 --- /dev/null +++ b/docs/audit/2026-07-14/runtime-artifacts/automated/home-open-project-real-device-2026-07-30.md @@ -0,0 +1,24 @@ +# Home open-project acceptance — 2026-07-30 + +Scope: Home Shell Task 10 (`implementation-slice-526c91ba76edbda6`) on the exact arm64 debug bundle at `target/debug/bundle/macos/OpenTake.app`. + +## Code verification + +- RED: the three exact candidate tests reached the `home.opening` state but failed because their controls remained enabled while `openProjectViaDialog` was pending. +- GREEN: the sidebar, empty-launcher, and populated-launcher controls now share one Home-level pending state and expose native `disabled`/`aria-busy` state until the open attempt settles. All Open, New, and recent-project entries are disabled together, a second activation cannot start another dialog, and a rejection test proves recovery to the enabled label. +- Project-open failures preserve the current front-end state, surface the structured Tauri error message through the localized Home toast, and remain rejected to non-UI callers. +- `AppCore::prepare_project_open` runs in `spawn_blocking` behind a 15-second timeout; the prepared value is committed only after successful completion. A late timed-out worker has no reference to the live core and cannot replace the current project. A managed single-flight lifecycle coordinator also rejects overlapping Open/New transitions before either can supersede the other. +- Focused results: 26/26 Home/project-action tests passed; 6/6 Rust project-open/lifecycle tests passed, including single-flight exclusion, off-thread execution, timeout/no-late-commit, failure preservation, successful activation, and mapped-media acceptance. +- Regression results: 72 Web files / 737 tests passed; production Web build passed; default and `--no-default-features` Tauri checks passed; both CI-equivalent Clippy gates passed with `-D warnings`; `cargo fmt --all -- --check && cargo test --workspace --no-fail-fast` passed, with only the repository's pre-existing future-incompatibility warning for `block v0.1.6` and the explicitly ignored real-device probe tests. + +## Real-device verification + +1. Activated the Home sidebar Open Project control with a native double-click. Exactly one macOS directory sheet appeared. +2. In populated Home, activated the hero Open Project control with a native double-click. Exactly one directory sheet appeared. +3. Removed the recent entry only, entered the empty-launcher state, and activated its Open Project control with a native double-click. Exactly one directory sheet appeared. +4. Selected `/Users/lvbaiqing/Documents/OpenTake/未命名.opentake/Untitled.opentake`, whose visible components carry iCloud `Not downloaded` state and whose root `open(2)` previously froze the application. The sidebar control became disabled and announced `正在打开…`; the exact debug process remained responsive while the filesystem call stayed isolated off the main thread. +5. At the 15-second boundary the control returned to `打开项目` and the UI exposed `打开失败:project open timed out after 15s`. No editor transition or recent entry was published for the failed bundle. +6. In the same process, opened the preserved local bundle `/private/tmp/opentake-beta-qa-20260729/TalkingHeadQA.opentake`. The editor showed media `talking-head-30s` and duration `00:29:20`, proving the timeout did not corrupt or supersede the valid session. +7. Restored `TalkingHeadQA` to Home recents after the empty-state check. The FileProvider-backed `Untitled.opentake` bundle was not modified or deleted. + +Result: PASS. All three planned controls have pending, cancel/retry, success, and explicit failure/recovery evidence; a non-responsive cloud-backed directory no longer freezes the application or mutates the active project. diff --git a/docs/audit/2026-07-14/runtime-artifacts/automated/home-project-lifecycle-real-device-2026-07-31.md b/docs/audit/2026-07-14/runtime-artifacts/automated/home-project-lifecycle-real-device-2026-07-31.md new file mode 100644 index 00000000..cff96308 --- /dev/null +++ b/docs/audit/2026-07-14/runtime-artifacts/automated/home-project-lifecycle-real-device-2026-07-31.md @@ -0,0 +1,55 @@ +# Home project lifecycle real-device verification (2026-07-31) + +## Scope + +- Contract: `HS-project-card-lifecycle` +- Owning requirement: `requirement-c4af18634c223a8d` +- Exact packaged artifact: `target/debug/bundle/macos/OpenTake.app` +- DMG artifact: `target/debug/bundle/dmg/OpenTake_1.0.0_aarch64.dmg` + +## RED evidence + +- `cargo test -p opentake-tauri missing_entry_survives_registry_load_and_safe_trash_removes_only_after_success` failed to compile because `ProjectRegistry` did not exist. +- `pnpm -C web exec vitest run src/components/home/HomeView.test.tsx -t 'missing_card_reveal_remove_and_trash_states'` failed because Home rendered only the stale path and did not render `home.fileMissing` or any safe file actions. + +## GREEN evidence + +- Exact Rust owning test: PASS (`1 passed`). +- Exact Home owning test: PASS (`1 passed`). +- Home component regression pair: PASS (`2` files, `16` tests). +- `cargo fmt --all -- --check`: PASS. +- `cargo clippy --offline -p opentake-tauri --all-targets -- -D warnings`: PASS. +- `cargo test --offline --workspace --no-fail-fast`: PASS; hardware-only probes remain explicitly ignored by their owning runners. +- `pnpm -C web test -- --run`: PASS (`76` files, `753` tests). +- `pnpm -C web build`: PASS with the existing Vite mixed-import and bundle-size advisories. +- `git diff --check`: PASS. + +## Final artifact identity + +- App executable SHA-256: `5e345b5a143fee720eca5049bf673c09a57ab08428666f1da0b660f3a1b6f5d1`. +- DMG SHA-256: `a775931f5f622ddbb3507b31ca7d000c2ad373b7af21bf7766e007bae3f92b71`. +- Both bundles were produced successfully after the final native trash implementation. + +## Packaged macOS walkthrough + +All destructive checks used temporary copies of the built-in tutorial. The three pre-existing user recent projects were never targeted and remained present at the end. + +1. Opened `/tmp/OpenTake-HomeLifecycle-QA-20260731-1157.opentake` through the native Open panel. Home changed from `3 recent` to `4 recent`, proving native registration and legacy/local mirror synchronization. +2. Expanded `项目操作`; the accessible dialog exposed `在 Finder 中显示`, `从最近项目中移除`, and `移到废纸篓`. +3. `在 Finder 中显示` opened Finder at `/private/tmp` with the exact QA bundle selected. +4. The first packaged trash attempt exposed a real platform failure: Finder Apple Events waited indefinitely without Automation authorization. The UI stayed in pending state; after terminating only the stuck QA helper process, it returned an explicit retryable error. The bundle remained at its source path and the Home record remained at `4 recent`. +5. The implementation was changed to Foundation's native `NSFileManager.trashItem`, rebuilt, and relaunched from the exact `.app` path. +6. Activating `移到废纸篓` showed a named confirmation dialog. Confirming immediately returned Home to `3 recent`; the source path was absent and the complete bundle existed at `~/.Trash/OpenTake-HomeLifecycle-QA-20260731-1157.opentake`. +7. Opened a second QA bundle, relocated it to a recovery path while the editor was active, then returned Home. The registry preserved the card and exposed the accessible label `OpenTake-Missing-QA-20260731-1201 · 文件缺失`. +8. Double activation of the missing card stayed on Home. Reveal opened its existing `/private/tmp` parent rather than raising a silent error. +9. `从最近项目中移除` changed `4 recent` back to `3 recent` without touching the recovery bundle. +10. After making ordinary recent removal native-first, rebuilt both bundles and launched the exact final `.app` as the only running OpenTake instance. Opened `/tmp/OpenTake-AtomicRemove-QA-20260731-1215.opentake`, observed `3 recent` → `4 recent`, then used `从最近项目中移除` and observed `4 recent` → `3 recent`. The native registry no longer contained that path while the complete bundle remained at its source, proving removal committed before the UI mirror changed and did not delete the project. The QA copy was then moved to `~/.Trash/OpenTake-AtomicRemove-QA-20260731-1215.opentake` for recoverable cleanup. + +## Safety and recovery assertions + +- The native trash command rejects relative paths, non-`.opentake` paths, and paths not present in the native registry before invoking any operating-system capability. +- The injected permission-denied Rust path leaves both the project directory and registry entry unchanged. +- A missing registered bundle is treated as a removable stale record; no filesystem delete is attempted. +- Registry snapshots are staged, synced, and atomically renamed; mutation becomes visible in memory only after persistence succeeds. +- On desktop, ordinary recent removal awaits the native atomic registry commit before updating the local Home mirror; a persistence failure remains visible and retryable instead of hiding the card. +- All three temporary QA bundles were placed in the user's system Trash after verification and remain recoverable. Existing user project data was not modified. diff --git a/docs/audit/2026-07-14/runtime-artifacts/automated/home-sample-project-real-device-2026-07-31.md b/docs/audit/2026-07-14/runtime-artifacts/automated/home-sample-project-real-device-2026-07-31.md new file mode 100644 index 00000000..462e8618 --- /dev/null +++ b/docs/audit/2026-07-14/runtime-artifacts/automated/home-sample-project-real-device-2026-07-31.md @@ -0,0 +1,52 @@ +# Home sample project real-device verification (2026-07-31) + +## Scope + +- Contract: `HS-new-open-sample` +- Owning requirement: `requirement-31a4c6a115076c19` +- Packaged artifact: `target/debug/bundle/macos/OpenTake.app` +- DMG artifact: `target/debug/bundle/dmg/OpenTake_1.0.0_aarch64.dmg` + +## RED evidence + +- `cargo test -p opentake-tauri failed_materialization_rolls_back_entire_sample_directory` initially failed to compile because `SampleProjectService`, `ResolvedSample`, and `SampleDownload` did not exist. +- The existing Web suite remained green, while the newly declared Home interaction test failed because the tutorial sample button and its action route did not exist. + +## GREEN evidence + +- `cargo fmt --all -- --check`: PASS. +- `cargo test -p opentake-tauri failed_materialization_rolls_back_entire_sample_directory`: PASS (`1 passed`). +- All sample service tests: PASS (`3 passed`), including valid publish/progress and the offline tutorial contents. +- `cargo clippy -p opentake-tauri --all-targets -- -D warnings`: PASS. +- `cargo test --workspace --no-fail-fast`: PASS across the workspace; hardware-only GPU/audio probes remain explicitly ignored by their owning runners. +- `pnpm -C web test -- --run`: PASS (`76` files, `752` tests). +- `pnpm -C web build`: PASS. Vite retained its pre-existing mixed dynamic-import and bundle-size advisories. +- `git diff --check`: PASS. + +## Artifact identity + +- App executable SHA-256: `299d1c3efd845ce7c61ff84825a22ca3acb40c85b2936584723ae4bd97999179`. +- DMG SHA-256: `0f622f0b91a1e3f20929c35a1082aa6183797d8669931033fb725956206d11ab`. +- Tauri produced both bundles successfully from the tested source tree. + +## Packaged macOS walkthrough + +The exact `.app` path above was targeted through the macOS accessibility surface. An older running process was first quit, then the just-built artifact was launched so the verification could not reuse stale code. + +1. Home rendered an accessible `示例项目` region with `产品演示`, `快速教程`, and `模板项目` buttons. +2. Before opening a sample, Home showed exactly three existing recent projects: `TalkingHeadQA`, `Task7NativeSaveAs-20260731`, and `Task9Empty-20260731`. +3. Activating `快速教程` entered the editor and displayed `教程项目已打开。按照时间线中的示例开始编辑。`. +4. The editor reported a `00:12:00` timeline at `30 fps`, with one `T1` track and three sequential clips: `sample-text-0`, `sample-text-1`, and `sample-text-2`. +5. Direct inspection of the materialized cache bundle confirmed one track, three 120-frame text clips at frames `0`, `120`, and `240`, for `360` frames total. +6. Returning Home still showed the same three recent projects. The cache bundle was not registered as user work. +7. Activating `快速教程` a second time succeeded with the same three clips and tutorial toast, exercising replacement of the existing stable cache entry. + +## Failure and recovery coverage + +- The exact Rust ownership test injects a failure after the first file write and verifies that the stable sample directory does not exist and that the entire staging directory is removed. +- The action-layer Web test rejects materialization and verifies that `projectOpen` is not called, the editor remains on Home, existing recents are unchanged, and a localized error toast is displayed. +- The packaged walkthrough did not intentionally corrupt or intercept external network traffic; deterministic service and action tests own that destructive failure path. + +## Environment maintenance + +The first focused rerun after packaging exhausted the local disk while Rust attempted to write a test archive. Only the repository's regenerable `target/debug/incremental` cache was removed (about 10 GiB); the verified `.app`, DMG, source files, and user project data were preserved. The focused checks then passed. diff --git a/docs/audit/2026-07-14/runtime-artifacts/automated/home-secondary-controls-real-device-2026-07-30.md b/docs/audit/2026-07-14/runtime-artifacts/automated/home-secondary-controls-real-device-2026-07-30.md new file mode 100644 index 00000000..0e8b3415 --- /dev/null +++ b/docs/audit/2026-07-14/runtime-artifacts/automated/home-secondary-controls-real-device-2026-07-30.md @@ -0,0 +1,20 @@ +# Home secondary controls acceptance — 2026-07-30 + +Scope: Home Shell Task 11 (`implementation-slice-94554777e3aef548`) on the single current arm64 debug bundle at `target/debug/bundle/macos/OpenTake.app`. + +## Code verification + +- Baseline: the Home interaction runner existed from Task 12, but the four exact Task 11 names were absent; the focused filter executed zero candidates and skipped the file. +- Added exact tests for Home → Library, Home → Settings, background selection clearing, and recent-entry removal without project opening. +- Focused result: 5/5 Home interaction tests passed, including the four Task 11 candidates. +- Regression result: 72 Web files / 731 tests passed; production Web build passed with only the pre-existing dynamic-import and chunk-size warnings. + +## Real-device verification + +1. From Home, activated `素材库`; the app showed the global Library with `返回主页`, category controls, search, and empty-library state. +2. Returned Home and activated `设置`; the modal exposed `完成`, `通用`, `外观`, `导入`, `AI`, `MCP 说明`, `账户`, and `关于`, then closed back to Home. +3. Single-clicked `TalkingHeadQA`; accessibility changed it to `Value: on`. Clicked the empty Home workspace; it returned to `Value: off` without navigation. +4. Hovered `TalkingHeadQA` and activated `从最近中移除`. The Home count changed from 2 to 1, `TalkingHeadQA` disappeared, no editor opened, and `/tmp/opentake-beta-qa-20260729/TalkingHeadQA.opentake` still existed on disk. +5. Used Home → Open Project, navigated the native directory picker to the exact preserved bundle, and opened it. The editor showed `talking-head-30s` and `00:29:20`; returning Home showed 2 recent entries again. + +Result: PASS. Navigation, selection clearing, and recent-only removal behave correctly, and the recoverable recent entry was restored after validation. diff --git a/docs/audit/2026-07-14/runtime-artifacts/automated/home-upstream-composite-real-device-2026-07-31.md b/docs/audit/2026-07-14/runtime-artifacts/automated/home-upstream-composite-real-device-2026-07-31.md new file mode 100644 index 00000000..8a13f3d1 --- /dev/null +++ b/docs/audit/2026-07-14/runtime-artifacts/automated/home-upstream-composite-real-device-2026-07-31.md @@ -0,0 +1,69 @@ +# Home upstream composite real-device evidence — 2026-07-31 + +## Scope + +- Plan: `home-shell-implementation.md`, Task 1 `HS-upstream-home-composite`. +- Requirement: `requirement-0ccbf12f850bf335`. +- Boundary: complete Home shell aggregation after all fifteen child slices were already closed. + +## Exact code evidence + +The initial command + +`pnpm -C web exec vitest run src/components/home/HomeView.test.tsx -t upstream_home_children_close_one_composite_acceptance` + +found no matching test and reported one skipped file / three skipped tests. This was retained as the genuine missing-owning-evidence baseline rather than manufacturing a production failure. + +After implementation: + +- exact composite test: PASS, 1/1 (three unrelated tests skipped by the name filter); +- complete Web suite: PASS, 82 files / 774 tests; +- production Web build: PASS, with only the pre-existing large-chunk and ineffective-dynamic-import warnings; +- complete Rust workspace: PASS on the immediately preceding combined branch gate; Task 1 changed no Rust source; +- `git diff --check`: PASS; +- debug macOS `.app` and `.dmg` bundle build: PASS. + +Current verification artifacts: + +| Artifact | Size | SHA-256 | +|---|---:|---| +| `target/debug/bundle/macos/OpenTake.app/Contents/MacOS/opentake` | 148338232 bytes | `e7374361c03307bfa1b257f3d4977ce5ec3e8fa298913e37af28194c4df963e1` | +| `target/debug/bundle/dmg/OpenTake_1.0.0_aarch64.dmg` | 42588806 bytes | `009b679ca08fd3b4840823b8a22f039061e046627a1c504dbb459d6137123a45` | + +These remain verification bundles. They are not the first Beta release because other implementation-plan groups are still open. + +## Composite contract coverage + +The exact owning test exercises the Home boundary rather than checking disconnected source strings: + +1. With no recents and no last-seen version, Home renders sidebar, sample strip, empty welcome state, and a modal first-run welcome surface. +2. The welcome action receives initial focus; Escape dismisses it and records the exact build-time `__APP_VERSION__`. +3. A simulated prior version renders the `New in v{version}` surface and release body, then persists dismissal. +4. Existing and missing project cards render together; the missing card exposes the context menu, reveal/remove/safe-trash actions, confirmation copy, and a non-destructive cancel path. +5. Native keyboard activation of the existing project routes the exact path through `openProjectPath`. +6. The product-demo sample routes the exact `product-demo, false` arguments through `openSampleProject`. + +## Packaged application result + +The rebuilt macOS application opened directly to a modal `v1.0.0 新功能` surface containing the AI Edit, music, and cross-dissolve release summary. Accessibility exposed one dialog heading and one `了解` button, with the background Home controls unavailable while the modal was active. + +After activating `了解`, the application exposed: + +- sidebar: New Project, Open Project, Library, Settings; +- samples: Product Demo, Quick Tutorial, Template Project; +- welcome hero and tagline; +- three recent project cards with separate project-action controls. + +The app was quit and relaunched. The update surface did not reappear, proving the last-seen version persisted across a real process boundary and returned to the Home shell. + +## Child runtime evidence aggregated by this umbrella + +- Native new/open/sample and tutorial routing: [`home-sample-project-real-device-2026-07-31.md`](home-sample-project-real-device-2026-07-31.md). +- Project create/open/close/reopen lifecycle: [`home-project-lifecycle-real-device-2026-07-31.md`](home-project-lifecycle-real-device-2026-07-31.md). +- Autosave and Home metadata: [`home-autosave-metadata-real-device-2026-07-31.md`](home-autosave-metadata-real-device-2026-07-31.md). +- Missing/reveal/remove/safe-trash and secondary Home controls: [`home-secondary-controls-real-device-2026-07-30.md`](home-secondary-controls-real-device-2026-07-30.md). +- Native project-card focus, Return, and double-click: [`recent-project-card-real-device-2026-07-30.md`](recent-project-card-real-device-2026-07-30.md). + +## Outcome + +Task 1 is complete. Together with Tasks 2–16, all 43 records in the `home-shell` gap group now have owning tests and retained runtime evidence. This closes the Home group only; it does not reclassify other plan groups or authorize Beta publication. diff --git a/docs/audit/2026-07-14/runtime-artifacts/automated/hsl-secondary-real-device-2026-07-31.md b/docs/audit/2026-07-14/runtime-artifacts/automated/hsl-secondary-real-device-2026-07-31.md new file mode 100644 index 00000000..72dc672e --- /dev/null +++ b/docs/audit/2026-07-14/runtime-artifacts/automated/hsl-secondary-real-device-2026-07-31.md @@ -0,0 +1,129 @@ +# HSL secondary packaged-app verification (2026-07-31) + +## Scope + +This record closes implementation-plan Task 9 (`MR-hsl-secondary`). It covers a +persisted feathered hue qualifier, CPU/WGSL agreement, selected-hue isolation, +Inspector editing and recovery, and the complete packaged preview/export path. + +This is local functional evidence only. It is not Developer ID signing, +notarization, or a Beta release claim. + +## RED and GREEN evidence + +The owning test was added before implementation and initially failed to compile: + +- unresolved import `opentake_domain::HslSecondary` +- `ColorGrade` had no `hsl_secondary` field + +Exact RED/GREEN command: + +`cargo test -p opentake-render --test gpu_effects hsl_secondary_hue_boundary_feather_and_isolation -- --exact` + +The same command now passes on the real Metal/wgpu device. Its 16 x 16 chart +contains red, feather-boundary orange, green, and blue bars. It verifies: + +- selected red changes by more than 20 code values; +- boundary orange changes by more than 2 but less than selected red; +- isolated green and blue remain within 2 code values; +- fresh preview and export renders are byte-identical; +- JSON save/reopen preserves every HSL parameter exactly. + +Domain tests additionally cover red-range wraparound (`0 == 1`), grey-pixel +isolation, and stable rejection of a zero-width selector. The command test +persists the nested grade and restores it through undo and redo. + +## Implementation boundary + +- `HslSecondary` persists normalized hue center, full range width, feather, hue + rotation, relative saturation, and additive lightness. +- Validation rejects non-finite/out-of-range values before command mutation and + before source resolution in the compositor. +- The CPU reference and WGSL both use circular hue distance and an inward + smoothstep feather. Achromatic pixels are never selected. +- Two named vec4 uniform blocks carry qualifier metadata and adjustments after + the primary exposure/white-balance/LGG/contrast/saturation chain. +- Inspector exposes enable, six editable parameters, and reset. Every edit uses + the existing transactional `SetColorGrade` undo/redo path. +- Browser fallback normalization and validation mirror the Rust ranges. + +## Workspace and package gates + +- `cargo fmt --all -- --check`: passed. +- `cargo clippy --workspace --all-targets -- -D warnings`: passed. Cargo only + repeated the repository's existing future-incompatibility notice for + `block 0.1.6`. +- `cargo test --workspace --no-fail-fast`: passed; explicit real-device-only + probes remained ignored by design. +- `pnpm -C web test`: 89 files, 809 tests passed. +- `pnpm -C web build`: passed with the existing non-blocking bundle-size and + dynamic-import warnings. +- `web/node_modules/.bin/tauri build --bundles app --no-sign`: passed. + +Tested application: +`target/release/bundle/macos/OpenTake.app` + +- executable SHA-256: + `348d9664cc7e4b72fa32e07aa0cdf6ad7e85e8ffca669076afbb10393f94f9df` +- ad-hoc CDHash: `88ce65b3757e702ae63100c9ace54854fec2e584` +- `codesign --verify --deep --strict --verbose=2`: passed, including bundled + `ffmpeg` and `ffprobe`. +- `Signature=adhoc`, `TeamIdentifier=not set`; this proves local bundle + integrity only. + +## Packaged application workflow + +Fixture: +`/private/tmp/opentake-hsl-ui-real-device-20260731.opentake` +(native Save As copy of the real 30 fps talking-head project). + +Through the visible packaged UI: + +1. Selected blue video clip `id-4`, enabled HSL Secondary, and authored center + `0.667`, width `0.240`, feather `0.080`, hue shift `0.250`, saturation + `-0.250`, and lightness `0.100`. +2. Preview changed immediately from blue to purple, proving the Inspector state + reaches the packaged GPU render path. +3. Five undo operations restored the absent HSL qualifier and blue preview; + five redo operations restored all values and the purple preview. +4. Reset cleared the qualifier. Undo restored all six values, redo cleared it, + and a final undo restored the authored state. +5. Saved, returned Home, reopened from Recents, and confirmed all values and the + purple preview persisted. The fresh session correctly disabled undo/redo. +6. Native playback advanced from frame 0 to `00:01:07` with HSL active. +7. Exported the complete timeline from the packaged H.264 export dialog. + +Precise persisted state: + +```json +{"id":"id-4","colorGrade":{"exposure":0.0,"temperature":0.0,"tint":0.0,"liftGammaGain":{"lift":{"r":0.0,"g":0.0,"b":0.0},"gamma":{"r":1.0,"g":1.0,"b":1.0},"gain":{"r":1.0,"g":1.0,"b":1.0}},"contrast":0.0,"saturation":1.0,"hslSecondary":{"hueCenter":0.667,"hueWidth":0.24,"feather":0.08,"hueShift":0.25,"saturation":-0.25,"lightness":0.1}}} +``` + +- `project.json` SHA-256: + `5e3e028d18f254cfd2751679979163feec3c59d2a3009b72c534e32654b6c857` + +## Complete export + +`/private/tmp/opentake-hsl-ui-real-device-20260731.mp4` probes as: + +- H.264, 1920 x 1080, 30/1 fps, 920 frames +- AAC audio, 1439 frames +- duration `30.666667` seconds +- size `281291` bytes +- SHA-256: + `42e121dc2d3814b4af7d1f5fc184da85501821a9b3c8ff4dd3e865952c413707` + +Bundled FFmpeg sampled the opaque center of frame 0: + +- source: RGB `[52, 89, 138]` +- packaged export: RGB `[169, 100, 158]` + +The expected blue-to-purple rotation is therefore present in the complete +encoded result, while the owning chart test supplies the exact preview/export +isolation tolerance for hues outside the qualifier. + +## Result + +Task 9 is verified from bounded persisted model and transactional editing through +CPU math, real wgpu chart isolation, packaged Inspector recovery, save/reopen, +native playback, and complete export. diff --git a/docs/audit/2026-07-14/runtime-artifacts/automated/interchange-export-real-device-2026-07-30.md b/docs/audit/2026-07-14/runtime-artifacts/automated/interchange-export-real-device-2026-07-30.md new file mode 100644 index 00000000..eab2e123 --- /dev/null +++ b/docs/audit/2026-07-14/runtime-artifacts/automated/interchange-export-real-device-2026-07-30.md @@ -0,0 +1,74 @@ +# XMEML/FCPXML/OTIO/EDL export automated + real-device evidence — 2026-07-30 + +Scope: control `control-0d98e5e5a0c417ed` / implementation slice `implementation-slice-a85196307f399399`. + +## Production path + +- `web/src/components/shell/TitleBar.tsx#onExportInterchange` closes the title-bar menu, opens the native save panel, preserves the project-derived directory and filename, appends the selected extension when it is missing, routes to the selected format command, and reports success/failure through the visible toast. +- The interchange dialog intentionally omits the native extension filter. On macOS 26.5.2, the `tauri-plugin-dialog` 2.7.1 / `rfd` 0.16 `allowedFileTypes` path disabled the native Save button for these interchange formats. The application still enforces `.xml`, `.fcpxml`, `.otio`, or `.edl` through `withExt()` after confirmation. +- `web/src/lib/api.ts` invokes the typed Tauri command for XMEML, modern FCPXML, OTIO, or EDL; `src-tauri/src/commands.rs` passes the current timeline and media manifest to the matching `opentake-project` writer. +- `crates/opentake-project/src/edl.rs#top_video_clips` selects the first track containing actual video/image media. Text/Lottie overlay tracks are excluded so captions cannot shadow the editorial video track in CMX3600 output. + +## Defects found by real-device verification + +The first real EDL export, `/tmp/opentake-beta-qa-20260729/TalkingHeadQA-interchange.edl`, was 608 bytes with SHA-256 `120550f84260121e85b8bcf4bb3567ad29bc29387e543d28ee20c44ac575836c`. It contained four caption events named `Offline` and no source-video events. This proved that the previous `is_visual()` selection treated the top caption overlay as the single CMX3600 video track. + +The rebuilt application also exposed the macOS 26 native-filter regression: with an interchange extension filter present, the Save button remained disabled. Removing only that filter while keeping the project-derived default path restored the button and retained extension safety in application code. + +Corrections: + +- Filter EDL candidate clips to `ClipType::Video | ClipType::Image` and skip overlay-only visual tracks. +- Add `caption_overlay_track_does_not_shadow_video_track` as a Rust regression. +- Add the exact planned title-bar control test for all four formats, success, failure, cancellation, default-directory fallback, extension completion, menu closure, and the macOS-compatible dialog options. + +## Automated evidence + +Focused checks: + +```text +cargo test -p opentake-project edl::tests --lib +pnpm -C web test -- --run web/src/components/shell/TitleBar.interaction.test.tsx +``` + +The EDL module passed 14/14 tests. The web command exercised the repository suite and passed 70 files / 721 tests, including six exact `control-0d98e5e5a0c417ed export XMEML/FCPXML/OTIO/EDL` cases. + +Regression gates: + +```text +cargo test --workspace --no-fail-fast -q +cargo clippy --workspace --all-targets -- -D warnings +pnpm -C web test +pnpm -C web build +cargo fmt --all -- --check +git diff --check +``` + +All executed checks passed. The Rust workspace retained only its declared ignored hardware probes. Vite emitted only the existing ineffective-dynamic-import and large-chunk warnings. + +## Real macOS application loop + +Environment: + +- macOS 26.5.2 (25F84), arm64. +- Application: `/Users/lvbaiqing/TRUE 开发/PRIMARY-CN/OpenTake-generation/target/debug/bundle/macos/OpenTake.app`, rebuilt from the working tree and ad-hoc signed with all bundle resources sealed before launch. +- Project: `/tmp/opentake-beta-qa-20260729/TalkingHeadQA.opentake`, 890 frames at 30 fps, with caption overlay tracks above three video/audio edit fragments. + +Using the visible title-bar `导出` menu and native macOS save panel, the rebuilt application exported all four formats and displayed `已导出` after each write: + +| Format | Output | Bytes | SHA-256 | +| --- | --- | ---: | --- | +| XMEML | `/tmp/opentake-beta-qa-20260729/TalkingHeadQA-final-xmeml.xml` | 11,846 | `392b3f4e6024a66fa595449c63748ce8ad8bc5ff1c6a474a6e8b2b15b1d19c87` | +| FCPXML | `/tmp/opentake-beta-qa-20260729/TalkingHeadQA-final-fcpxml.fcpxml` | 2,750 | `751c357ecaa1003721d13801f47226456f51fab5b21b7371c74ca8363b933de4` | +| OTIO | `/tmp/opentake-beta-qa-20260729/TalkingHeadQA-final-otio.otio` | 15,628 | `972582868654e94db85000aa785abab3454e9bdde14d352803db4d22fe0afebc` | +| EDL | `/tmp/opentake-beta-qa-20260729/TalkingHeadQA-final-edl.edl` | 533 | `a7f9b82d3503dacd4817878ea8eaed69bae7a707cfc963972221ad617198a8c7` | + +Validation: + +- `xmllint --noout` parsed both XML outputs. +- `jq` parsed OTIO and found four tracks with child counts `[2,5,3,3]`, matching two overlay tracks plus three video and three audio edits with explicit gaps. +- The fixed EDL contains exactly three events, all named `talking-head-30s`, with no `Offline` entry. Its record ranges are `00:00:00;00–00:00:05;01`, `00:00:05;01–00:00:11;25`, and `00:00:11;25–00:00:29;20`; source trims are preserved in the source timecode columns. +- A second fixed EDL export was byte-identical, confirming deterministic output. + +## Result + +The planned interchange control is verified across the owning UI test, Tauri command boundary, format writers, a discovered-and-fixed EDL overlay regression, the macOS native save panel, visible success feedback, and parsed generated artifacts. This closes only implementation slice `implementation-slice-a85196307f399399`; adjacent plan slices remain independently tracked. diff --git a/docs/audit/2026-07-14/runtime-artifacts/automated/lgg-export-2026-07-31.mp4 b/docs/audit/2026-07-14/runtime-artifacts/automated/lgg-export-2026-07-31.mp4 new file mode 100644 index 00000000..d7d831e7 Binary files /dev/null and b/docs/audit/2026-07-14/runtime-artifacts/automated/lgg-export-2026-07-31.mp4 differ diff --git a/docs/audit/2026-07-14/runtime-artifacts/automated/lgg-export-frame-0-2026-07-31.png b/docs/audit/2026-07-14/runtime-artifacts/automated/lgg-export-frame-0-2026-07-31.png new file mode 100644 index 00000000..a5e23e32 Binary files /dev/null and b/docs/audit/2026-07-14/runtime-artifacts/automated/lgg-export-frame-0-2026-07-31.png differ diff --git a/docs/audit/2026-07-14/runtime-artifacts/automated/lgg-packaged-ui-2026-07-31.jpg b/docs/audit/2026-07-14/runtime-artifacts/automated/lgg-packaged-ui-2026-07-31.jpg new file mode 100644 index 00000000..ed758da1 Binary files /dev/null and b/docs/audit/2026-07-14/runtime-artifacts/automated/lgg-packaged-ui-2026-07-31.jpg differ diff --git a/docs/audit/2026-07-14/runtime-artifacts/automated/lgg-preview-frame-0-2026-07-31.png b/docs/audit/2026-07-14/runtime-artifacts/automated/lgg-preview-frame-0-2026-07-31.png new file mode 100644 index 00000000..87799db2 Binary files /dev/null and b/docs/audit/2026-07-14/runtime-artifacts/automated/lgg-preview-frame-0-2026-07-31.png differ diff --git a/docs/audit/2026-07-14/runtime-artifacts/automated/lgg-real-device-2026-07-31.md b/docs/audit/2026-07-14/runtime-artifacts/automated/lgg-real-device-2026-07-31.md new file mode 100644 index 00000000..5cbad07e --- /dev/null +++ b/docs/audit/2026-07-14/runtime-artifacts/automated/lgg-real-device-2026-07-31.md @@ -0,0 +1,148 @@ +# Lift / gamma / gain packaged-app verification (2026-07-31) + +## Scope + +This record closes implementation-plan Task 8 (`MR-lgg-proof`). It verifies the +authored lift/gamma/gain formula against the CPU reference and real wgpu output, +then exercises the same controls through the rebuilt packaged macOS application. + +This is local functional evidence only. It is not Developer ID signing, +notarization, or a Beta release claim. + +## RED evidence + +The existing CPU model and WGSL shader both used +`pow(gain * (x + lift), 1 / gamma)`. That made lift a uniform offset across the +whole range and put gain inside the gamma power, contrary to the documented +color-wheel contract +`gain * pow(x + lift * (1 - x), 1 / gamma)`. + +- `cargo test -p opentake-domain lift_gamma_gain_gain_scales` failed to compile + after the validation contract was added to the owning test because + `ColorGrade::validate` did not exist. +- `cargo test -p opentake-render --test gpu_effects lift_gamma_gain_matches_cpu_reference -- --exact` + failed with `expected 0.32364660303542436, got 0.36317811652316606`. + +These failures independently proved the missing hostile-input boundary and the +old CPU/GPU formula drift from the source requirement. + +## Implementation and GREEN evidence + +- `ColorGrade::apply_linear` and WGSL now share + `gain * pow(max(x + lift * (1 - x), 0), 1 / gamma)` per channel. +- Lift rolls off to zero influence at white, gamma shapes mid-tones, and gain + remains the final channel multiplier. +- `ColorGrade::validate` provides stable typed field/rule errors for every + Inspector range and rejects NaN/Inf. Gamma is strictly within `(0, 4]`. +- `SetColorGrade` validates before transaction mutation. A zero-gamma request + leaves the clip, timeline version, and undo stack unchanged. +- The compositor validates persisted grades before source resolution, so a + damaged project cannot degrade into an unchanged frame or submit NaN/Inf GPU + uniforms. +- `lift_gamma_gain_matches_cpu_reference` checks a non-neutral three-channel + fixture, preview/export byte equality, CPU/source-formula equality, GPU pixel + tolerance, and the pre-resolution invalid-grade refusal. + +Focused GREEN commands: + +- `cargo test -p opentake-domain lift_gamma_gain_gain_scales` +- `cargo test -p opentake-domain color_grade_rejects_non_finite_and_zero_gamma` +- `cargo test -p opentake-ops --test command_apply set_color_grade_rejects_invalid_without_mutation -- --exact` +- `cargo test -p opentake-render --test gpu_effects lift_gamma_gain_matches_cpu_reference -- --exact` + +All passed. + +## Workspace and package gates + +- `cargo fmt --all -- --check`: passed. +- `cargo clippy --workspace --all-targets -- -D warnings`: passed. Cargo only + repeated the repository's existing future-incompatibility notice for + `block 0.1.6`. +- `cargo test --workspace --no-fail-fast`: passed; explicit real-device-only + probes remained ignored by design. +- `pnpm -C web test`: 89 files, 808 tests passed. The browser fallback rejects + the same invalid gamma before mutation, and the Inspector's gamma minimum is + `0.01`, so its visible control cannot author a value the Rust boundary rejects. +- `web/node_modules/.bin/tauri build --bundles app --no-sign`: passed. Its + `pnpm -C web build` prerequisite also passed with the existing non-blocking + bundle-size/dynamic-import warnings. + +Tested application: +`target/release/bundle/macos/OpenTake.app` + +- executable SHA-256: + `638e22fd2c56157d08a778e31122ab90ae133c02327ca584cf4a523564baf246` +- ad-hoc CDHash: `6ba8caf5dcac7835c773158e335e0d8eb5df8666` +- `codesign --verify --deep --strict --verbose=2`: passed, including bundled + `ffmpeg` and `ffprobe`. +- `Signature=adhoc`, `TeamIdentifier=not set`; this proves local bundle + integrity only. + +## Packaged application workflow + +Fixture: +`/private/tmp/opentake-lgg-real-device-20260731.opentake` +(isolated copy of the real 30 fps talking-head project). + +Through the visible packaged UI: + +1. Selected video clip `id-4` and scrolled to the Inspector color-grade section. +2. Scrubbed Lift R to `0.10`, Gamma R to `1.79`, and Gain R to `0.82`. +3. The native preview changed immediately from the source blue to purple, + visibly proving that the controls are rendered rather than inert metadata. +4. Four undo operations restored Lift/Gamma/Gain to `0.00/1.00/1.00` and the + default blue image. Four redo operations restored `0.10/1.79/0.82` and the + purple image. +5. Saved, returned Home, reopened the project from Recents, selected `id-4`, and + confirmed all values persisted. The fresh session correctly showed disabled + undo and redo controls. +6. Native playback advanced from frame 0 to frame 224 (`00:07:14`) with the grade + active and without a stall or crash. +7. Captured native frame 0 and exported the complete timeline through the + packaged application's export dialog. + +The precise persisted values (scrubbing retains sub-display precision) are: + +```json +{"id":"id-4","colorGrade":{"exposure":0.0,"temperature":0.0,"tint":0.0,"liftGammaGain":{"lift":{"r":0.0961456298828125,"g":0.0,"b":0.0},"gamma":{"r":1.793203125,"g":1.0,"b":1.0},"gain":{"r":0.8197265625,"g":1.0,"b":1.0}},"contrast":0.0,"saturation":1.0}} +``` + +- `project.json` SHA-256: + `213859fd864f1717ade84c0c291a07a7ad51127e87c22b6ee897c0e65e96f6a6` +- post-capture `media.json` SHA-256: + `0189ca41d6ae79eab408b4dd7081a4105151fd186321c91926cc69d47041a2fe` + +## Preview/export parity + +The complete packaged export probes as: + +- H.264, 1920 x 1080, 30/1 fps, 920 frames +- AAC audio +- duration `30.666667` seconds +- size `280672` bytes +- SHA-256: + `620d1b4133feeabf2a4e75a90ee2204cfffa91fa29829ed8575af975c2033b34` + +Native preview frame 0 and exact exported frame 0 were compared with the bundled +FFmpeg: + +- SSIM: `0.999283` (`31.445265` dB) +- PSNR average: `38.469386` dB +- both frames: PNG, 1920 x 1080 + +The only difference is expected H.264 chroma/quantization loss; the purple LGG +result and geometry match visually. + +## Artifacts + +- `lgg-packaged-ui-2026-07-31.jpg` — packaged Inspector plus visibly graded + preview. +- `lgg-preview-frame-0-2026-07-31.png` — native packaged preview. +- `lgg-export-2026-07-31.mp4` — complete packaged-app export. +- `lgg-export-frame-0-2026-07-31.png` — exact exported comparison frame. + +## Result + +Task 8 is verified from typed model/command boundaries through CPU math, wgpu, +packaged Inspector editing, undo/redo, save/reopen, native playback, and complete +export parity. diff --git a/docs/audit/2026-07-14/runtime-artifacts/automated/linked-audio-real-device-2026-08-01.md b/docs/audit/2026-07-14/runtime-artifacts/automated/linked-audio-real-device-2026-08-01.md new file mode 100644 index 00000000..5f997d79 --- /dev/null +++ b/docs/audit/2026-07-14/runtime-artifacts/automated/linked-audio-real-device-2026-08-01.md @@ -0,0 +1,29 @@ +# MR-linked-audio-complete packaged-runtime evidence — 2026-08-01 + +## Scope and package identity + +This acceptance run used `target/release/bundle/macos/OpenTake.app`. The final executable SHA-256 was `942aa7d0e432c270146d04a025c5c3632b61333e17f92369d7152295d6bd20c8`; strict deep code-sign verification passed. The bundle remains ad-hoc signed with no Team ID, so this is packaged-runtime evidence rather than Developer ID notarization. + +The deterministic source was `/private/tmp/opentake-linked-audio-silent-20260801.mp4`, SHA-256 `ee12524fb8a661a396b2b578e06ae6d2e06cdd2ec80b3e42faa69afe45901b4f`. It is five seconds of H.264 640×360 at 30 fps and has no audio stream. The isolated saved project was `/private/tmp/opentake-linked-audio-real-device-20260801.opentake`. + +## Code boundary + +- `cargo test -p opentake-agent does_not_link_audio_when_source_has_no_audio` executed `add_clips_does_not_link_audio_when_source_has_no_audio` and `insert_clips_does_not_link_audio_when_source_has_no_audio`: 2 passed, 0 failed. +- `cargo test -p opentake-media video_with_zero_channel_audio_has_no_audio`: 1 passed, 0 failed. +- The owning Agent tests were introduced by `a2f34cb`; the zero-channel probe regression was introduced by `9920468`. Because the inherited implementation already satisfied the contract, the current acceptance baseline was GREEN and no artificial RED was manufactured. + +The effective gate is `place_clip`: linked audio requires all four conditions `add_linked_audio`, video target, video source type, and `has_audio`. `resolve_media_kind` carries the manifest value into both Agent command paths. FFprobe audio streams that explicitly report zero channels produce `has_audio=false`; an absent audio stream does likewise in the real fixture. + +## Packaged UI and persistence + +In the rebuilt desktop application: + +1. A new empty project was saved through the native Save panel. +2. The source was imported through the native Import panel. The material library showed exactly one five-second video item. +3. Double-clicking the item added it to the timeline. The accessibility tree showed exactly one track label, `V1`, with one clip. No `A1` or `A2` label or audio clip was created. +4. Playback advanced visibly from `00:00:00` to `00:01:05` of `00:05:00`. +5. The persisted `media.json` entry recorded `type:"video"` and `hasAudio:false`. +6. The persisted `project.json` contained one video track and one video clip. The clip had no serialized `linkGroupId`. +7. Export completed with the visible message `导出完成 · 1280×720 · 150 帧`. + +The exported file was `/private/tmp/opentake-linked-audio-real-device-20260801.mp4`, SHA-256 `1b01b65af6d3c6e8524b21a42ff6010de2e7ba69f9f361f858bb416d6d306e96`. Independent FFprobe inspection found a single H.264 video stream at 1280×720, 30 fps, duration 5.000 seconds, and no audio stream. This verifies import, timeline placement, playback, persistence, and export parity for the silent-video boundary. diff --git a/docs/audit/2026-07-14/runtime-artifacts/automated/loudness-real-device-2026-08-01.md b/docs/audit/2026-07-14/runtime-artifacts/automated/loudness-real-device-2026-08-01.md new file mode 100644 index 00000000..a6602fd8 --- /dev/null +++ b/docs/audit/2026-07-14/runtime-artifacts/automated/loudness-real-device-2026-08-01.md @@ -0,0 +1,49 @@ +# MR-loudness packaged runtime evidence — 2026-08-01 + +## Scope and package identity + +- Exact application: `target/release/bundle/macos/OpenTake.app` +- Executable SHA-256: `43ca7cdc03657dfeb3c361595aeba0b17a5fcae625c74d7ede405bd9557095f8` +- Code signature: valid strict/deep ad-hoc signature; CDHash `38b143d0e3f43a22d778e30ec647fd9905837baf`; no TeamIdentifier. This is runtime evidence, not a notarized Beta artifact. +- Project: `/private/tmp/opentake-loudness-real-device-20260801.opentake` + +## Code verification + +- RED: `cargo test -p opentake-media --test loudness normalization_reaches_configured_lufs_within_tolerance -- --exact` failed before the new analysis owner existed. +- GREEN: focused media loudness, domain persistence/wire-schema, project compatibility, undo/redo, Tauri routing/playback/export and Web Inspector/API tests passed. +- `cargo test --workspace --all-targets --quiet`: PASS; existing environment-only integrations remained ignored. +- `cargo clippy --workspace --all-targets -- -D warnings`: PASS. +- `cargo fmt --all -- --check` and `git diff --check`: PASS. +- `npm --prefix web test -- --run`: PASS, 90 files / 814 tests. +- `npm --prefix web run build`: PASS with the pre-existing large-chunk and ineffective-dynamic-import warnings only. +- `tauri build --bundles app --no-sign`: PASS; the resulting bundle was ad-hoc signed and passed strict/deep verification. + +## Packaged application workflow + +All UI actions below were performed against the exact release `.app` through macOS accessibility and native file/save panels. + +- Imported deterministic 48 kHz mono speech and music WAV fixtures. +- Speech analysis displayed `-32.0 → -16.0 LUFS`, `+18.0 dB`, `-2.1 dBTP` after the final codec-margin fix. +- Music analysis displayed `-27.4 → -16.0 LUFS`, `+11.8 dB`, `-2.3 dBTP`. +- Analyze/apply, reanalyze, reset, undo and redo changed/restored the expected Inspector state. +- Native playback advanced the transport for normalized speech and music. +- Save and application relaunch reopened the project writable and restored the persisted speech normalization. A missing compatibility descriptor initially caused a read-only reopen; adding `loudnessNormalization` to `Clip::WIRE_FIELDS` plus the known-schema/wire-schema regression fixtures fixed it before acceptance. +- A real one-second silent WAV returned `loudness_silent_audio: no block passed the EBU R128 absolute gate` and did not create a normalization result. + +## Independent export measurements + +The first speech export exposed an AAC reconstruction overshoot (`-16.09 LUFS / -0.23 dBTP`) with a one-dB codec margin. The shared preview/export safety margin was raised to two dB, the package was rebuilt, and both deliverables were re-exported from the GUI. + +| Fixture | Deliverable | FFmpeg `loudnorm` input I | input TP | Result | +| --- | --- | ---: | ---: | --- | +| Speech | `/private/tmp/opentake-loudness-speech-export-v2-20260801.mp4` | -16.07 LUFS | -1.15 dBTP | PASS | +| Music | `/private/tmp/opentake-loudness-music-export-20260801.mp4` | -16.02 LUFS | -1.74 dBTP | PASS | + +Both files are five-second 1920×1080 H.264/AAC, 30 fps, 48 kHz mono exports. Acceptance is target `-16 LUFS ±1 LU` and true peak no hotter than `-1 dBTP`. + +- Speech SHA-256: `39985024152dd103db6ba02579dc16379d9dcb28855b12ae75588903d9017fd8` +- Music SHA-256: `6f87d13f5f56377a8bb8cb78e169a12248c3056f656b4cf5d6b6194eb6f7493c` + +## Verdict + +MR-loudness is PASS for code, packaged macOS runtime, persistence, error handling, native preview and exported-deliverable measurements. This verdict covers Task 11 only and does not remove the project-wide Beta release blockers. diff --git a/docs/audit/2026-07-14/runtime-artifacts/automated/lut-real-device-2026-07-31.md b/docs/audit/2026-07-14/runtime-artifacts/automated/lut-real-device-2026-07-31.md new file mode 100644 index 00000000..0af29b37 --- /dev/null +++ b/docs/audit/2026-07-14/runtime-artifacts/automated/lut-real-device-2026-07-31.md @@ -0,0 +1,149 @@ +# 3D LUT packaged-app verification (2026-07-31) + +## Scope + +This record closes implementation-plan Task 10 (`MR-lut`). It covers bounded +`.cube` parsing, project-managed content-addressed storage, transactional clip +editing, real GPU sampling, packaged preview/playback/export, and save/reopen. + +This is local functional evidence only. It is not Developer ID signing, +notarization, or a Beta release claim. + +## RED and GREEN evidence + +The owning test was added before implementation and initially failed to compile +because `CubeLut`, `LutReference`, `GpuLutTexture`, `upload_lut_3d`, the render +plan field, and the resolver method did not exist. + +Exact RED/GREEN command: + +`cargo test -p opentake-render --test lut malformed_and_oversized_luts_fail_closed_and_valid_lut_matches_preview_export -- --exact --nocapture` + +It now passes on the real Metal/wgpu device. The test verifies: + +- malformed, oversized, and unsupported 16-point inputs fail closed; +- valid 17-point identity and 33-point known-transform tables parse; +- identity output remains within two code values; +- a known transform at 75% intensity visibly changes the expected channels; +- fresh preview and export renders are byte-identical; +- JSON save/reopen preserves the path-free reference. + +The command test additionally verifies apply, intensity adjustment, removal, +undo, and redo. Project storage tests verify bounded no-follow reads and complete +Save As copying of `media/luts`. Tauri tests verify absolute-path enforcement, +symlink refusal on Unix, pre-copy validation, and SHA-256-addressed publication. + +## Implementation boundary + +- `CubeLut` accepts UTF-8 `.cube` input up to 4 MiB, exactly one ordered domain, + and complete 17- or 33-point 3D tables with finite bounded values. +- A clip persists only `{id, name, intensity}`. The external import path is not + retained; runtime paths are derived as `media/luts/.cube`. +- Import validates a no-follow regular source through a retained project identity + workflow before atomically publishing the managed asset. +- Preview, native playback, MCP inspection, and export resolve the same managed + bytes, verify their content hash, parse them again, and fail closed on missing + or tampered assets. +- RGBA16F 3D textures use texel-center-aligned hardware trilinear sampling after + the primary grade/HSL chain and before the generic effect chain. +- Inspector provides native `.cube` selection, filename, intensity, removal, + explicit errors, and the shared transactional undo/redo route. + +## Workspace and package gates + +- `cargo fmt --all -- --check`: passed. +- `cargo clippy --workspace --all-targets -- -D warnings`: passed. Cargo only + repeated the repository's existing future-incompatibility notice for + `block 0.1.6`. +- `cargo test --workspace --all-targets --quiet`: passed; existing tests that + explicitly require optional external fixtures remained ignored by design. +- `pnpm -C web test`: 89 files, 810 tests passed. +- `pnpm -C web build`: passed with the existing non-blocking bundle-size and + ineffective-dynamic-import warnings. +- `web/node_modules/.bin/tauri build --bundles app --no-sign`: passed. + +Tested application: +`target/release/bundle/macos/OpenTake.app` + +- executable SHA-256: + `f8fbeaa1149652dde3175d2421274b6cb38280835ac755f2254f2106c4e5318f` +- ad-hoc CDHash: `acd9d8f381353d0bdbf10a4d498850ea965a15e5` +- `codesign --verify --deep --strict --verbose=2`: passed, including bundled + `ffmpeg` and `ffprobe`. +- `Signature=adhoc`, `TeamIdentifier=not set`; this proves local bundle integrity + only. + +## Packaged application workflow + +Fixture project: +`/private/tmp/opentake-lut-ui-real-device-20260731.opentake` + +Imported LUT: +`/private/tmp/opentake-lut-ui-swap-rb-17.cube` + +- size: 147473 bytes / 4917 lines +- SHA-256: + `f9828fe9741ec855e6e2b6184de48471171ff906108d7c5e1f28bf1ca897d7ee` +- transform: `[r,g,b] -> [b,g*0.5,r]` + +Through the visible packaged UI: + +1. Created a native Save As copy of the prior real-device project and selected + blue video clip `id-4`. +2. Imported the valid 17-point LUT through the native `.cube` picker. Inspector + showed `opentake-lut-ui-swap-rb-17`, the undo action became available, and the + preview changed immediately from lavender-purple to vivid magenta. +3. Set intensity to `0.350`; preview moved to the expected intermediate color. + Undo restored `1.000`, and redo restored `0.350`. +4. Removed the LUT, observed `未选择`, then undid removal and recovered the LUT + and its `0.350` intensity. +5. Saved, returned Home, reopened from Recents, and confirmed the name, intensity, + and preview persisted while the new-session undo/redo stack was empty. +6. Native playback advanced from `00:00:00` to `00:06:18` with the LUT active. +7. Exported the complete timeline through the packaged H.264 export dialog. + +After the final browser-fallback validation hardening, the bundle was rebuilt, +re-signed, reopened from Home, and again showed the persisted LUT and `0.350` +intensity with an empty history. Its native playback advanced from `00:00:00` +to `00:08:02`, and the complete export below was repeated from that final hash. + +Persisted clip state: + +```json +{"id":"id-4","lut":{"id":"f9828fe9741ec855e6e2b6184de48471171ff906108d7c5e1f28bf1ca897d7ee","name":"opentake-lut-ui-swap-rb-17","intensity":0.35}} +``` + +The managed copy exists only at +`media/luts/f9828fe9741ec855e6e2b6184de48471171ff906108d7c5e1f28bf1ca897d7ee.cube` +and has the same SHA-256 as the import. The project contains no external LUT +source path. + +- `project.json` SHA-256: + `e8d96cfa1a4fc2d227dfcc22ecf9542db93139b75dc0708572b075784ff8beb2` + +## Complete export + +`/private/tmp/opentake-lut-ui-final-package-20260731.mp4` probes as: + +- H.264, 1920 x 1080, 30/1 fps, 920 frames +- AAC audio, 1439 frames +- duration `30.666667` seconds +- size `279902` bytes +- SHA-256: + `908bde76040bbb653fc8f791a22dae5b0c776aff8da84fa33f5501e23f6cfc37` + +Bundled FFmpeg sampled the opaque center of frame 0: + +- source: RGB `[52, 89, 138]` +- prior packaged HSL-only export: RGB `[169, 100, 158]` +- packaged HSL + 35% LUT export: RGB `[167, 86, 161]` + +The red/blue movement and green attenuation match the authored transform and +intensity. The owning real-GPU test supplies the exact identity and +preview/export byte-parity tolerances without lossy codec noise. + +## Result + +Task 10 is verified from typed bounded parsing and project-managed storage +through transactional Inspector editing, fresh-session persistence, native +playback, and complete packaged export. diff --git a/docs/audit/2026-07-14/runtime-artifacts/automated/mask-rendering-2026-07-31.md b/docs/audit/2026-07-14/runtime-artifacts/automated/mask-rendering-2026-07-31.md new file mode 100644 index 00000000..af858cdd --- /dev/null +++ b/docs/audit/2026-07-14/runtime-artifacts/automated/mask-rendering-2026-07-31.md @@ -0,0 +1,74 @@ +# MR-mask-rendering real-device receipt — 2026-07-31 + +## Candidate + +- Source branch: `agent/advanced-ai-workflows` +- Packaged app: `target/release/bundle/macos/OpenTake.app` +- Executable SHA-256: `05e9b2c16b8e418170330082d0996991af6b9bc614d8f33eb5eb5ea07b69656e` +- Code signature: local ad-hoc signature; `codesign --verify --deep --strict` + passed after the exact candidate bundle was re-signed. +- Validation project: + `target/runtime-validation/mr-optical-flow/OpticalFlow60.opentake` + +## Required RED receipt + +The reviewed planned test +`linear_circle_and_polygon_masks_match_cpu_reference_in_preview_and_export` +was added before implementation. It failed on the first polygon pixel with +`pixel=(0,0) expected=0 actual=255`, proving that the previous GPU polygon path +was a full-coverage no-op. + +## Code verification + +- `cargo test -p opentake-render --test gpu_effects circle_mask_clips_to_center -- --exact` — pass. +- `cargo test -p opentake-render --test gpu_effects linear_circle_and_polygon_masks_match_cpu_reference_in_preview_and_export -- --exact` — pass on the local GPU for linear, circle, and polygon shapes at feather `0` and `0.18`, with preview/export bytes equal and every channel within 3 levels of the CPU reference. +- `pnpm -C web test` — 89 files, 805 tests passed. +- `pnpm -C web build` — production build passed. +- `cargo fmt --all -- --check` — pass. +- `cargo clippy --workspace --all-targets -- -D warnings` — pass. +- `cargo test --workspace --no-fail-fast` — pass; seven pre-existing real-device probes remain intentionally ignored by the unit gate. + +The command layer rejects more than four masks or polygon paths outside 3–16 +points, so the fixed GPU uniform cannot silently truncate editor/Agent changes. + +## Packaged UI verification + +All actions below were executed through the packaged `.app`, not a development +server: + +1. Reopened the saved 60 fps project and enabled a mask on its video clip. +2. Switched Circle to the enabled `Pen / Polygon` option. +3. Dragged P1 directly in Preview from `(0.250, 0.250)` to approximately + `(0.143, 0.140)`; the overlay and Inspector updated together. +4. Added P5 `(0.300, 0.800)`, then deleted P5. +5. Undid the point drag (P1 returned to `0.250`) and redid it (P1 returned to + `0.143`). +6. Set mask offset X `0.100`, rotation `20°`, feather `0.100`, and invert on. + Preview showed the rotated/translated polygon and the expected reversed soft + boundary over the moving white-square fixture. +7. Saved, quit, reopened, and reselected the clip. Shape, four points, offset, + scale, rotation, feather, and invert persisted exactly; the reopened undo + stack was correctly empty. +8. Exported `H.264`, `1920×1080`, `60/1 fps`, `2.000 s`, exactly `120` decoded + frames, then captured the same preview frames through the packaged app. + +The persisted mask payload in `project.json` contains the four edited polygon +points plus `feather: 0.1`, `invert: true`, `offset.x: 0.1`, identity scale, and +`rotationDegrees: 20.0`. + +## Preview/export pixel comparison + +Artifacts live in +`runtime-artifacts/automated/mask-rendering-2026-07-31/`: + +- `preview-frame-000.png` / `export-frame-000.png`: pixel-identical, SSIM 1.0 and infinite PSNR. +- `preview-frame-060.png` / `export-frame-060.png`: soft inverted polygon boundary is visible; SSIM `0.999753`, average PSNR `62.854540 dB`. +- `mask-runtime-h264-1080p.mp4`: playable H.264 export, 120 frames. + +SHA-256: + +- `export-frame-000.png`: `bc17b329a39fdc77b8db61cd72ecf8b8da7a7881e329096c412bce91fc531ab8` +- `export-frame-060.png`: `eb5c7e2399a9b4c6d77c96ec3bcd1e30a9778d8c040d5372085c86c83f241cc4` +- `mask-runtime-h264-1080p.mp4`: `362f5f959ccb3756b7a30d3fb387a78870620c64e8e8ed3923dbf8bf3fcd7231` +- `preview-frame-000.png`: `564fe64317b8abae9bc593ee47aca81a6790fc8fea2e1fbfdb34e093f6a10add` +- `preview-frame-060.png`: `1feb50b313e7072920cc486199bea36749f49ba5d2345e7937742380e08e147d` diff --git a/docs/audit/2026-07-14/runtime-artifacts/automated/mask-rendering-2026-07-31/export-frame-000.png b/docs/audit/2026-07-14/runtime-artifacts/automated/mask-rendering-2026-07-31/export-frame-000.png new file mode 100644 index 00000000..86ba2acf Binary files /dev/null and b/docs/audit/2026-07-14/runtime-artifacts/automated/mask-rendering-2026-07-31/export-frame-000.png differ diff --git a/docs/audit/2026-07-14/runtime-artifacts/automated/mask-rendering-2026-07-31/export-frame-060.png b/docs/audit/2026-07-14/runtime-artifacts/automated/mask-rendering-2026-07-31/export-frame-060.png new file mode 100644 index 00000000..a9b02747 Binary files /dev/null and b/docs/audit/2026-07-14/runtime-artifacts/automated/mask-rendering-2026-07-31/export-frame-060.png differ diff --git a/docs/audit/2026-07-14/runtime-artifacts/automated/mask-rendering-2026-07-31/mask-runtime-h264-1080p.mp4 b/docs/audit/2026-07-14/runtime-artifacts/automated/mask-rendering-2026-07-31/mask-runtime-h264-1080p.mp4 new file mode 100644 index 00000000..b98ddf18 Binary files /dev/null and b/docs/audit/2026-07-14/runtime-artifacts/automated/mask-rendering-2026-07-31/mask-runtime-h264-1080p.mp4 differ diff --git a/docs/audit/2026-07-14/runtime-artifacts/automated/mask-rendering-2026-07-31/preview-frame-000.png b/docs/audit/2026-07-14/runtime-artifacts/automated/mask-rendering-2026-07-31/preview-frame-000.png new file mode 100644 index 00000000..94d3033a Binary files /dev/null and b/docs/audit/2026-07-14/runtime-artifacts/automated/mask-rendering-2026-07-31/preview-frame-000.png differ diff --git a/docs/audit/2026-07-14/runtime-artifacts/automated/mask-rendering-2026-07-31/preview-frame-060.png b/docs/audit/2026-07-14/runtime-artifacts/automated/mask-rendering-2026-07-31/preview-frame-060.png new file mode 100644 index 00000000..5db220e2 Binary files /dev/null and b/docs/audit/2026-07-14/runtime-artifacts/automated/mask-rendering-2026-07-31/preview-frame-060.png differ diff --git a/docs/audit/2026-07-14/runtime-artifacts/automated/media-render-packaged-ffmpeg-real-device-2026-07-31.md b/docs/audit/2026-07-14/runtime-artifacts/automated/media-render-packaged-ffmpeg-real-device-2026-07-31.md new file mode 100644 index 00000000..c478c664 --- /dev/null +++ b/docs/audit/2026-07-14/runtime-artifacts/automated/media-render-packaged-ffmpeg-real-device-2026-07-31.md @@ -0,0 +1,165 @@ +# Media Render Task 33 — Packaged FFmpeg real-device evidence (2026-07-31) + +Status: **cross-platform acceptance complete.** The exact-source macOS bundle +and Windows installed NSIS package have both passed the owning real-media smoke +without relying on ambient `PATH`. + +## Bound contract + +- Plan: `media-render-playback-export-implementation.md`, Task 33 + (`implementation-slice-ddfcf34d5292a998`). +- Candidate: `requirement-ff2faf0938e25f39`. +- Owning test: + `scripts/tests/packaged-sidecars-test.rb#packaged_macos_windows_sidecars_resolve_and_execute`. + +## RED receipt + +The owning test was added before the supply implementation and run exactly as +planned: + +```text +$ ruby scripts/tests/packaged-sidecars-test.rb --name packaged_macos_windows_sidecars_resolve_and_execute +missing sidecar supply-chain lock: .../scripts/ffmpeg-sidecars.lock.json +exit 1 +``` + +The first provisioning attempt also correctly rejected the upstream tag label +as insufficient evidence: the pinned Apple Silicon asset under release +`b6.1.1` actually reports `ffmpeg version 6.0`. The final lock therefore records +the executable-reported version per target/tool in addition to URL and SHA-256. + +## Code and supply-chain evidence + +- `scripts/ffmpeg-sidecars.lock.json`: immutable release URLs, exact reported + versions, and SHA-256 for macOS arm64/x64 and Windows x64. +- `scripts/provision_ffmpeg_sidecars.py`: bounded target allowlist, retrying + download, streaming SHA-256, executable version verification, atomic publish, + and removal of only its own named partial files. +- `src-tauri/tauri.macos.conf.json` and `src-tauri/tauri.windows.conf.json`: + platform-specific Tauri `externalBin` entries for both tools. +- `crates/opentake-media/src/ff.rs#packaged_sidecar_beside`: accepts only a + regular non-symlink sibling of the running executable. +- `src-tauri/src/lib.rs#resolve_media_tools`: an intact packaged pair overrides + ambient variables; a release package with a missing tool pins the missing + sibling path and fails closed instead of searching developer `PATH`. +- `.github/workflows/ci.yml#windows-product`: provisions the Windows target, + runs the source smoke with an empty PATH, builds MSI/NSIS, silently installs + NSIS, then runs the owning test against the installed directory. +- The package includes the repository GPL/NOTICE and + `resources/ffmpeg/SOURCE.md`. + +## Automated GREEN receipts (macOS arm64) + +```text +$ python3 scripts/provision_ffmpeg_sidecars.py --verify-only +verified src-tauri/binaries/ffmpeg-aarch64-apple-darwin +verified src-tauri/binaries/ffprobe-aarch64-apple-darwin + +$ ruby scripts/tests/packaged-sidecars-test.rb --name packaged_macos_windows_sidecars_resolve_and_execute +PASS: packaged_macos_windows_sidecars_resolve_and_execute + +$ cargo test -p opentake-media packaged_sidecar +2 passed; 0 failed + +$ cargo test -p opentake-tauri --test security_config +4 passed; 0 failed + +$ cargo test --workspace --no-fail-fast +all workspace unit, integration, and doc-test binaries passed; only tests marked +with their existing explicit real-device fixture requirements were ignored + +$ cargo clippy --workspace --all-targets -- -D warnings +exit 0 + +$ cargo clippy -p opentake-tauri --no-default-features --all-targets -- -D warnings +exit 0 + +$ pnpm -C web test +82 files passed; 774 tests passed +``` + +The owning smoke clears `PATH` for every sidecar invocation, creates a 64×36 +video, verifies ffprobe metadata, decodes one exact RGBA frame, encodes a 32×18 +output, and probes the output again. + +## Packaged `.app` receipt + +Build: + +```text +$ web/node_modules/.bin/tauri build --debug --bundles app +Finished 1 bundle at target/debug/bundle/macos/OpenTake.app +``` + +The bundle contained `Contents/MacOS/opentake`, `ffmpeg`, and `ffprobe`, plus the +GPL/NOTICE/source resources. Before local signing, the two packaged sidecars +matched the locked source digests exactly: + +```text +ffmpeg a90e3db6a3fd35f6074b013f948b1aa45b31c6375489d39e572bea3f18336584 +ffprobe bb2db6f5d8cef919da12fbf592119a987202a8c060a886f3cab091f9cab90b64 +``` + +The app and nested sidecars were then ad-hoc signed for this local candidate. +Signing changes the Mach-O file digest, so the final installed/package gate +verifies each nested code signature, locked reported version, and the complete +media smoke rather than incorrectly requiring the pre-sign digest: + +```text +$ ruby scripts/tests/packaged-sidecars-test.rb --name packaged_macos_windows_sidecars_resolve_and_execute --package target/debug/bundle/macos/OpenTake.app +PASS: packaged_macos_windows_sidecars_resolve_and_execute + +$ codesign --verify --deep --strict --verbose=2 target/debug/bundle/macos/OpenTake.app +... ffmpeg validated +... ffprobe validated +OpenTake.app: valid on disk +OpenTake.app: satisfies its Designated Requirement +``` + +## Packaged UI/runtime receipt + +The exact app binary was launched with a minimal system `PATH` and deliberately +invalid `OPENTAKE_FFMPEG` / `OPENTAKE_FFPROBE` values. The packaged sibling pair +therefore had to override both injected values for media decoding to work. + +In the real Tauri WebView: + +1. Home loaded with six persisted recent projects. +2. `opentake-ds-legacy-real-device.opentake` opened and rendered its imported + PNG plus the existing audio waveform. +3. `TalkingHeadQA.opentake` opened in explicit compatibility read-only mode + (because it contains the independently tracked `transitionOut` field), while + its real 30-second video still produced a visible `talking-head-30s` image, + duration, tracks, and audio waveform. There was no blank media library or + missing-FFmpeg error despite the poisoned ambient paths. +4. The application was exited after inspection; no project edit was made. + +## Windows x64 installed-package receipt + +The first exact-head Windows run for `21f7e9ebe1a4e16a16d1ef7931f48d7ee9e9fc62` +([run 30612593449](https://github.com/appergb/OpenTake/actions/runs/30612593449)) +passed provisioning and the source-side empty-`PATH` probe/decode/encode smoke. +It did not reach installer creation because a Web documentation-owner test +hard-coded the local checkout directory name. The same run also exposed two +Windows Tauri test jobs that compiled before provisioning the new external +binaries. Both CI portability defects are now covered by regression contracts; +neither partial run is an installed-package receipt. + +The corrected exact-SHA workflow then completed on source +`9eeeb6ffe3088a16f19946ceb7db5e90090356ac`: + +- workflow: [run 30614001607](https://github.com/appergb/OpenTake/actions/runs/30614001607), success; +- product job: [Windows product build, bundle, install, and smoke](https://github.com/appergb/OpenTake/actions/runs/30614001607/job/91102897263), success; +- uploaded artifact: `opentake-windows-9eeeb6ffe3088a16f19946ceb7db5e90090356ac`, artifact ID `8787369512`, 546,654,352 bytes, server digest `sha256:f287f5a9309245b9e415b55d52b72c75ddaaae1651fccfe03bfc8c15514b3a63`; +- MSI: `target/release/bundle/msi/OpenTake_1.0.0_x64_en-US.msi`, 283,295,744 bytes, SHA-256 `2dfe332521214fab8892be21bd5800e2708cf38161fa0c401c3602be507c6dd7`; +- NSIS: `target/release/bundle/nsis/OpenTake_1.0.0_x64-setup.exe`, 263,758,072 bytes, SHA-256 `d85002098bdf6883811251261947d399800140fa3cc2c2850bc7e3fbceedb95c`. + +The job provisioned the locked Windows sidecars, passed the source empty-`PATH` +smoke, built both native installers, silently installed the NSIS package, found +the installed application directory from Windows itself, and passed +`packaged_macos_windows_sidecars_resolve_and_execute` against that installed +directory. It then bound the uploaded installer receipt to the same source SHA. + +This closes the Task 33 cross-platform installed-package criterion. It does not +claim the independently tracked distribution-signing/notarization criteria in +Data Safety Task 10. diff --git a/docs/audit/2026-07-14/runtime-artifacts/automated/motion-tracking-agent-backend-2026-08-01.md b/docs/audit/2026-07-14/runtime-artifacts/automated/motion-tracking-agent-backend-2026-08-01.md new file mode 100644 index 00000000..efd2cf0f --- /dev/null +++ b/docs/audit/2026-07-14/runtime-artifacts/automated/motion-tracking-agent-backend-2026-08-01.md @@ -0,0 +1,50 @@ +# Motion tracking Agent/backend evidence — 2026-08-01 + +Scope: Task 3 sub-slice `requirement-fdd45062091b48f3`. The production backend, +Agent boundary, Inspector/Preview interaction, persistence, and export path are +now complete. Only the final packaged Beta GUI pass remains open. + +## Production path + +- `track_motion` is a strict, capability-gated MCP/Chat tool. Hosts without a production advanced-workflow bridge do not advertise it and direct dispatch fails closed. +- The desktop bridge resolves the selected ordinary video clip from one authoritative project snapshot, decodes at most 48 source-aligned frames, tracks only the normalized selected rectangle, and checks cancellation throughout. +- Successful analysis returns editable linear position keyframes, algorithm/version provenance, and minimum confidence. Preview is the default and does not mutate the timeline. +- Confidence below 0.25 returns `MCP_ANALYSIS_LOW_CONFIDENCE`; invalid rectangles/ranges, missing sources, cancellation, and stale project revisions do not commit. +- `apply=true` writes the complete position track through `EditCommand::SetKeyframes` at the analyzed project revision. It is one ordinary undoable transaction. +- The desktop command boundary exposes the same capability through + `advanced_track_motion`. The Inspector accepts an exact half-open clip range, + normalized numeric region fields, cancellation/retry, result confidence and + keyframe count, Apply, and visible Undo. +- “Select region in preview” replaces the ordinary transform overlay with a + crosshair rectangle editor. Reverse-direction drags and out-of-canvas drags + are normalized and clamped; any region/range change invalidates the reviewed + result before Apply can be used. + +## RED/GREEN evidence + +The reviewed Agent test first failed because `track_motion` was not a typed tool. The media test then failed to compile because `track_region_motion` and `NormalizedMotionRegion` did not exist. + +Passing focused commands: + +```text +CARGO_INCREMENTAL=0 cargo test -p opentake-media analysis::stabilization::tests::region_tracker_keeps_known_subject_center_within_five_pixels -- --exact --nocapture +CARGO_INCREMENTAL=0 cargo test -p opentake-tauri advanced::tests:: --lib -- --nocapture +CARGO_INCREMENTAL=0 cargo test -p opentake-agent --test advanced_ai_workflows +CARGO_INCREMENTAL=0 cargo clippy -p opentake-agent -p opentake-media -p opentake-tauri --all-targets -- -D warnings +``` + +The synthetic 96×72 three-frame target moves by `(8,4)` pixels. The final normalized track converts back within five pixels on both axes. The desktop test generates a 12-frame H.264 MP4 with FFmpeg, imports and places it through the real `AppCore`, obtains a mutation-free preview, applies the returned keyframes, saves and reopens the exact position track, exports all 12 frames through the production H.264 renderer, performs one undo, and verifies a pre-cancelled retry leaves the timeline unchanged. + +The final integrated code gate passed: + +- `cargo fmt --all -- --check`; +- `CARGO_INCREMENTAL=0 cargo test -p opentake-tauri --lib`: 416/416; +- default and no-default-feature Tauri Clippy with `-D warnings`; +- `npm test`: 119 files / 880 tests, including the Inspector flow, preview + rectangle normalization, cancellation/stale-result isolation, and Tauri + command parity; +- `npm run build` (existing chunk advisories only). + +## Remaining acceptance + +- Packaged macOS GUI evidence retained with the Beta release run. diff --git a/docs/audit/2026-07-14/runtime-artifacts/automated/nested-timeline-compound-export-2026-07-31.mp4 b/docs/audit/2026-07-14/runtime-artifacts/automated/nested-timeline-compound-export-2026-07-31.mp4 new file mode 100644 index 00000000..893c17e1 Binary files /dev/null and b/docs/audit/2026-07-14/runtime-artifacts/automated/nested-timeline-compound-export-2026-07-31.mp4 differ diff --git a/docs/audit/2026-07-14/runtime-artifacts/automated/nested-timeline-compound-real-device-2026-07-31.md b/docs/audit/2026-07-14/runtime-artifacts/automated/nested-timeline-compound-real-device-2026-07-31.md new file mode 100644 index 00000000..616ce759 --- /dev/null +++ b/docs/audit/2026-07-14/runtime-artifacts/automated/nested-timeline-compound-real-device-2026-07-31.md @@ -0,0 +1,146 @@ +# Nested timeline / compound clip real-device evidence (2026-07-31) + +Status: **macOS packaged acceptance complete.** This receipt closes the shared +runtime criteria for Media Render Task 2 (`MR-nested-timeline`) and Preview / +Timeline Task 2 (`compound-clips`). It does not claim Developer ID signing, +notarization, or Windows UI acceptance. + +## Bound contracts + +- `media-render-playback-export-implementation.md`, Task 2, + `implementation-slice-b8f61feebde4e2ab` / + `requirement-bedfdc6edfa147b9`. +- `preview-timeline-implementation.md`, Task 2, + `implementation-slice-0ef5268789b6e13f` / + `requirement-b49e0f5ed8c2415c`. +- Owning tests: + - `crates/opentake-render/tests/nested_timeline.rs#nested_edits_preview_and_export_same_frames` + - `crates/opentake-project/tests/compound_roundtrip.rs#compound_clip_roundtrips_nested_timeline` + - `crates/opentake-render/tests/compound_render.rs#compound_clip_preview_export_frames_match` + +## Historical RED receipt + +The exact planned tests were run in an isolated detached worktree at +`8bea548`, the parent of the nested-timeline implementation commit. All three +commands exited 101 because their owning test targets did not exist: + +```text +no test target named `nested_timeline` +no test target named `compound_roundtrip` +no test target named `compound_render` +historical-red-exits nested=101 roundtrip=101 render=101 +``` + +During packaged validation, the first build also exposed a real linked-audio +paste failure: + +```text +粘贴失败:TauriCommandError: entries[1]: asset type is not compatible with the destination track +``` + +The audio lane correctly retained `sourceClipType=video` because it decodes +from the original video asset. Destination validation incorrectly used that +source-container type instead of the placed `mediaType=audio`. The corrected +validator and regression test now preserve both meanings. + +## Automated GREEN receipts + +```text +$ cargo test -p opentake-render --test nested_timeline nested_edits_preview_and_export_same_frames -- --exact +1 passed; 0 failed + +$ cargo test -p opentake-project --test compound_roundtrip compound_clip_roundtrips_nested_timeline -- --exact +1 passed; 0 failed + +$ cargo test -p opentake-render --test compound_render compound_clip_preview_export_frames_match -- --exact +1 passed; 0 failed + +$ cargo fmt --all -- --check +exit 0 + +$ cargo clippy --workspace --all-targets -- -D warnings +exit 0 (only the existing future-incompatibility notice for block 0.1.6) + +$ cargo test --workspace --no-fail-fast +all workspace unit, integration, and doc-test binaries passed; only the seven +explicit real-device probe tests remained ignored + +$ npm --prefix web test -- --run +88 files passed; 801 tests passed + +$ npm --prefix web run build +exit 0 (existing bundle-size and ineffective-dynamic-import warnings only) +``` + +## Packaged application receipt + +The release bundle was produced with the repository-locked Tauri CLI 2.11.3: + +```text +$ web/node_modules/.bin/tauri build --bundles app +Finished 1 bundle at target/release/bundle/macos/OpenTake.app +``` + +The unsigned local bundle was fully ad-hoc signed for this machine, including +the packaged `ffmpeg` and `ffprobe` sidecars. Strict verification passed: + +```text +$ codesign --verify --deep --strict --verbose=2 target/release/bundle/macos/OpenTake.app +OpenTake.app: valid on disk +OpenTake.app: satisfies its Designated Requirement +``` + +This local ad-hoc signature is runtime evidence only; it is not a distributable +Developer ID / notarization receipt. + +## Packaged GUI and persistence receipt + +The exact release `.app` was operated through the macOS accessibility surface: + +1. Created `MRNestedTimeline.opentake` through the native Save panel. +2. Imported deterministic 4-second red/440 Hz and blue/660 Hz H.264/AAC media + through the native multi-file Open panel. +3. Added both assets to the root timeline, selected the linked blue video/audio + pair, and invoked **创建复合片段** from the timeline context menu. +4. Double-clicked the compound to enter its child timeline. The breadcrumb + displayed **← 主时间线 / 复合片段**. +5. Trimmed the linked child pair by 10 source frames and nudged it right by 5 + frames. The persisted pair became `startFrame=15`, `durationFrames=110`, + `trimStartFrame=10` with the original shared link group. +6. Copied the pair, moved the child playhead to its corrected local end + (`00:04:05 / 00:04:05`), and pasted. The new video/audio pair began at frame + 125, preserved the trim and `sourceClipType=video`, and received a fresh + shared link group. +7. Returned to the root timeline. At `00:04:10`, the paused composite showed + the nested blue frame. Continuous packaged playback advanced to `00:06:19` + while retaining the blue nested composite. +8. Saved, returned Home, reopened the project, re-entered the compound, and + observed all four child clip IDs across V1/A1. + +The persisted `project.json` used for the final reopen had SHA-256 +`53373a35cb56f4adf3a09c3212000a4168e29c6737496623cfaacf262811b2f4`. + +## Export receipt + +The packaged Export dialog completed 231 frames and reported +`导出完成 · 1280×720 · 231 帧`. Packaged `ffprobe` then reported: + +```text +video: h264, 1280x720, 30/1 fps +audio: aac, 48000 Hz, mono +duration: 7.700000 seconds +size: 86668 bytes +``` + +Tracked exact artifacts: + +- `nested-timeline-compound-export-2026-07-31.mp4` — SHA-256 + `96a8a3cbb772336e389a101f98a2c77fe965a7a0459ac16d8a558474901f9434` +- `nested-timeline-export-frame-1s-2026-07-31.png` — red root frame, + SHA-256 `d82eff581346e21a5633ae9927b982b4e3838786c49ee6429028cb321b7fbf0a` +- `nested-timeline-export-frame-5s-2026-07-31.png` — blue nested frame, + SHA-256 `fe97190f271c2abe496a3658073c238eafe4c11e3649920be56be39aec7f9ecd` + +The two decoded frames prove the exported file crosses from the root red clip +into the live nested blue sequence; the AAC stream proves linked nested audio +was included by the same flattened render plan. diff --git a/docs/audit/2026-07-14/runtime-artifacts/automated/nested-timeline-export-frame-1s-2026-07-31.png b/docs/audit/2026-07-14/runtime-artifacts/automated/nested-timeline-export-frame-1s-2026-07-31.png new file mode 100644 index 00000000..02168158 Binary files /dev/null and b/docs/audit/2026-07-14/runtime-artifacts/automated/nested-timeline-export-frame-1s-2026-07-31.png differ diff --git a/docs/audit/2026-07-14/runtime-artifacts/automated/nested-timeline-export-frame-5s-2026-07-31.png b/docs/audit/2026-07-14/runtime-artifacts/automated/nested-timeline-export-frame-5s-2026-07-31.png new file mode 100644 index 00000000..3327f7c5 Binary files /dev/null and b/docs/audit/2026-07-14/runtime-artifacts/automated/nested-timeline-export-frame-5s-2026-07-31.png differ diff --git a/docs/audit/2026-07-14/runtime-artifacts/automated/object-removal-vertical-2026-08-01.md b/docs/audit/2026-07-14/runtime-artifacts/automated/object-removal-vertical-2026-08-01.md new file mode 100644 index 00000000..7a04ca1a --- /dev/null +++ b/docs/audit/2026-07-14/runtime-artifacts/automated/object-removal-vertical-2026-08-01.md @@ -0,0 +1,65 @@ +# Object Removal Vertical — 2026-08-01 + +## Delivered surface + +- The strict `remove_object` Agent contract is advertised by the desktop host + because this build always includes the local implementation. +- The provider/model pair is explicitly reported as `opentake-local` / + `opentake-boundary-fill-v1`; unsupported provider or model names fail closed + instead of implying an unavailable hosted service. +- The selected editable vector mask and absolute project-frame range form part + of a content-addressed derivative key together with source SHA-256, trim, + duration, and timeline FPS. +- Preview decodes the ordinary forward 1x source, applies deterministic + boundary propagation only inside the selected range, preserves feathered + mask edges, encodes ProRes 422, retains source audio, and does not mutate the + project. +- The Inspector provides mask editing through the existing mask controls plus + range fields, preview, cancellation, retry, inline video review, Apply, and + Undo. Editing the mask invalidates a previously reviewed preview. +- Apply publishes the exact reviewed cache bytes through the retained no-follow + project-media capability, registers source/range/mask/provider/model + provenance, and swaps the selected clip to the derivative. +- Media registration, clip replacement, and clearing the now-baked editable + masks form one durable edit transaction. Undo restores source media and + masks; redo and save/reopen restore the derivative. The original asset stays + in the media manifest. + +## Automated evidence + +- `CARGO_INCREMENTAL=0 cargo test -p opentake-tauri advanced::tests --lib -- --nocapture` + - five advanced-workflow tests passed; + - the object-removal test uses a real eight-frame H.264/AAC fixture; + - a sampled frame outside the requested range retains the object while a + sampled frame inside the range removes it; + - preview leaves timeline and manifest unchanged; + - a nonexistent mask fails without changing timeline, manifest, or version; + - Apply preserves audio and provenance, and the published file SHA-256 equals + the reviewed preview SHA-256 consumed by playback/export; + - Undo/redo and save/reopen restore the expected media reference and masks; + - a pre-cancelled cached request returns typed cancellation. +- `CARGO_INCREMENTAL=0 cargo test -p opentake-ops motion_media_transaction_tests --lib` + - four tests passed, including one-entry swap/clear Undo and Redo. +- `CARGO_INCREMENTAL=0 cargo test -p opentake-core motion_media_commit --lib` + - three durable media-commit tests passed, including outside-bundle and + symlink refusal without mutation. +- `CARGO_INCREMENTAL=0 cargo test -p opentake-core generated_media_commit_refuses_version_drift_without_mutation --lib` + - a project edit made while generation is running refuses the stale commit + atomically, preventing a reviewed mask or range from being overwritten. +- `CARGO_INCREMENTAL=0 cargo clippy -p opentake-tauri -p opentake-core -p opentake-ops --all-targets -- -D warnings` + - passed. +- `npm test -- --run` + - all 113 web test files passed: 867 tests, including preview failure/retry, + cancellation with stale-completion rejection, apply/undo, compatibility, + missing-mask refusal, and an Apply response that arrives after the baked + mask has already been cleared from the timeline mirror. +- `npm run build` + - TypeScript and the production Vite bundle passed; existing chunk-size and + ineffective-dynamic-import warnings remain non-fatal. + +## Remaining acceptance evidence + +Packaged macOS GUI verification and final delivery-export inspection remain +required before `requirement-be73dca02523d3b0` is closed. These run after the +full code gate so the evidence represents the Beta candidate rather than an +intermediate development build. diff --git a/docs/audit/2026-07-14/runtime-artifacts/automated/optical-flow-24-to-60-export-2026-07-31.mp4 b/docs/audit/2026-07-14/runtime-artifacts/automated/optical-flow-24-to-60-export-2026-07-31.mp4 new file mode 100644 index 00000000..9752b0e8 Binary files /dev/null and b/docs/audit/2026-07-14/runtime-artifacts/automated/optical-flow-24-to-60-export-2026-07-31.mp4 differ diff --git a/docs/audit/2026-07-14/runtime-artifacts/automated/optical-flow-24-to-60-real-device-2026-07-31.md b/docs/audit/2026-07-14/runtime-artifacts/automated/optical-flow-24-to-60-real-device-2026-07-31.md new file mode 100644 index 00000000..dee2f3d8 --- /dev/null +++ b/docs/audit/2026-07-14/runtime-artifacts/automated/optical-flow-24-to-60-real-device-2026-07-31.md @@ -0,0 +1,131 @@ +# Optical-flow 24-to-60 real-device evidence (2026-07-31) + +Status: **macOS packaged acceptance complete for Media Render Task 3.** This +receipt closes `MR-optical-flow`; it does not claim Developer ID signing, +notarization, Windows acceptance, or Beta release readiness. + +## Bound contract + +- `media-render-playback-export-implementation.md`, Task 3, + `implementation-slice-c85c1acc35668396` / + `requirement-5933e802c9dfe372`. +- Owning test: + `crates/opentake-render/tests/optical_flow.rs#two_frame_fixture_is_deterministic_and_matches_preview_export`. +- Quality regression: + `crates/opentake-render/tests/optical_flow.rs#optical_flow_tracks_opposing_local_motion_without_global_frame_shift`. + +## Historical RED receipt + +The reviewed owning test was added before the implementation and the exact +planned command exited 101. Compilation reported the five missing product +boundaries: media interpolation types, media conversion/interpolation +functions, render interpolation config/types, and the resolver interpolation +method. This is the expected RED for a previously absent backend. + +## Automated GREEN receipts + +```text +$ cargo test -p opentake-render --test optical_flow two_frame_fixture_is_deterministic_and_matches_preview_export -- --exact +1 passed; 0 failed + +$ cargo test -p opentake-render --test optical_flow +2 passed; 0 failed + +$ cargo fmt --all -- --check +exit 0 + +$ cargo clippy --workspace --all-targets -- -D warnings +exit 0 (only the existing future-incompatibility notice for block 0.1.6) + +$ cargo test --workspace --no-fail-fast +all workspace unit, integration, and doc-test binaries passed; only the seven +explicit real-device probe tests remained ignored +``` + +The owning test verifies endpoint-stable 24-to-60 mapping, exact source alpha +values, deterministic pixel output, monotonic motion, preview/export policy +parity, explicit nearest/blend/error fallback behavior, and invalid-rate +rejection. The second regression uses two regions moving in opposite directions +so a single whole-frame translation cannot satisfy the fixture. + +## Packaged application receipt + +The release application was rebuilt with the repository-locked Tauri CLI and +the final local block-motion implementation: + +```text +$ web/node_modules/.bin/tauri build --bundles app +Finished 1 bundle at target/release/bundle/macos/OpenTake.app + +$ codesign --verify --deep --strict --verbose=2 target/release/bundle/macos/OpenTake.app +OpenTake.app: valid on disk +OpenTake.app: satisfies its Designated Requirement +``` + +The local bundle was ad-hoc signed, including packaged `ffmpeg` / `ffprobe`. +That proves local integrity only and is not a distributable signature. + +## Packaged GUI receipt + +The exact release `.app` was operated through the macOS accessibility surface. +A project was created through the native Save panel. Because the current +project-format UI displays FPS but does not edit it, the saved fixture's +`project.json` was set from 30 to 60 fps while the application was closed, then +reopened through the Home screen. The packaged UI displayed both the `60` badge +and `帧率 60 fps`. Importing the 320x180, 24 fps motion fixture raised the +expected format-mismatch dialog; **保持当前设置** retained the 1920x1080, +60 fps project. + +The deterministic fixture is 48 H.264 frames over 2 seconds. Its white square +moves from source x=20..59 to x=22..61 between frames 0 and 1; at the project +scale those left edges are x=120 and x=132. At project frame 1 (1/60 second), +the packaged paused preview capture had bounding box: + +```text +x=126..363, y=421..658 +``` + +The x=126 left edge lies strictly between the two source positions, proving an +interpolated motion frame rather than nearest-frame repetition. A second +packaged capture using the multi-region `testsrc2` fixture visually retained +the stationary color regions while interpolating local motion; it exposed no +whole-frame translation. + +## Export and preview/export parity receipt + +The packaged Export dialog produced `optical-flow-24-to-60-export-2026-07-31.mp4`. +`ffprobe` and the decoded output frame reported: + +```text +codec=h264 +width=1920 +height=1080 +avg_frame_rate=60/1 +duration=2.000000 +nb_frames=120 +export frame 1 bbox: x=126..363, y=421..658 +preview/export frame 1 SSIM: 0.999515 +preview/export frame 1 PSNR: 63.644069 dB +``` + +The identical motion bounding box proves temporal parity. The remaining pixel +difference is the expected H.264 encode loss; the in-memory owning test asserts +bit-exact preview/export resolver output before encoding. + +Tracked exact artifacts: + +- `optical-flow-moving-square-input-2026-07-31.mp4` — SHA-256 + `2c344578e303e5c2c4a727eac1ea3ce79bb1a9e4aa6b40ab6535e4b8363f3c69` +- `optical-flow-preview-frame-001-2026-07-31.png` — SHA-256 + `7d306ac1e3e5e891404d89bfcca2c4b067ca3324f8c63d02c55ff077cc5eedd4` +- `optical-flow-24-to-60-export-2026-07-31.mp4` — SHA-256 + `35d7c230db896e9559509a203421cc31a4d0651dea30c9a24a68db90e7d459f6` +- `optical-flow-export-frame-001-2026-07-31.png` — SHA-256 + `5adfddf3bc52bd666d24a24158fba4baa159af1f9e68d2ce86e6c37d3582fdae` +- `optical-flow-complex-preview-frame-001-2026-07-31.png` — SHA-256 + `02eb6dee137db6053833fe02d923014c770f86c00a98450923bcd056cb13c130` + +This task's mapped preview boundary is the high-quality paused composite path. +The separately governed low-latency continuous-playback stream retains its +existing real-time frame-normalization policy; this receipt makes no claim that +the streaming decoder runs the same block-motion algorithm. diff --git a/docs/audit/2026-07-14/runtime-artifacts/automated/optical-flow-complex-preview-frame-001-2026-07-31.png b/docs/audit/2026-07-14/runtime-artifacts/automated/optical-flow-complex-preview-frame-001-2026-07-31.png new file mode 100644 index 00000000..853993f8 Binary files /dev/null and b/docs/audit/2026-07-14/runtime-artifacts/automated/optical-flow-complex-preview-frame-001-2026-07-31.png differ diff --git a/docs/audit/2026-07-14/runtime-artifacts/automated/optical-flow-export-frame-001-2026-07-31.png b/docs/audit/2026-07-14/runtime-artifacts/automated/optical-flow-export-frame-001-2026-07-31.png new file mode 100644 index 00000000..c12b7e8a Binary files /dev/null and b/docs/audit/2026-07-14/runtime-artifacts/automated/optical-flow-export-frame-001-2026-07-31.png differ diff --git a/docs/audit/2026-07-14/runtime-artifacts/automated/optical-flow-moving-square-input-2026-07-31.mp4 b/docs/audit/2026-07-14/runtime-artifacts/automated/optical-flow-moving-square-input-2026-07-31.mp4 new file mode 100644 index 00000000..3b5432bb Binary files /dev/null and b/docs/audit/2026-07-14/runtime-artifacts/automated/optical-flow-moving-square-input-2026-07-31.mp4 differ diff --git a/docs/audit/2026-07-14/runtime-artifacts/automated/optical-flow-preview-frame-001-2026-07-31.png b/docs/audit/2026-07-14/runtime-artifacts/automated/optical-flow-preview-frame-001-2026-07-31.png new file mode 100644 index 00000000..fc4404b2 Binary files /dev/null and b/docs/audit/2026-07-14/runtime-artifacts/automated/optical-flow-preview-frame-001-2026-07-31.png differ diff --git a/docs/audit/2026-07-14/runtime-artifacts/automated/playback-route-lifecycle-real-device-2026-08-01.md b/docs/audit/2026-07-14/runtime-artifacts/automated/playback-route-lifecycle-real-device-2026-08-01.md new file mode 100644 index 00000000..03dff061 --- /dev/null +++ b/docs/audit/2026-07-14/runtime-artifacts/automated/playback-route-lifecycle-real-device-2026-08-01.md @@ -0,0 +1,66 @@ +# Playback route/lifecycle packaged real-device evidence — 2026-08-01 + +Parent: `MR-playback-route-lifecycle-complete` + +Environment: macOS arm64, final packaged release application, Chinese UI. The +application executable SHA-256 was +`a92c3e31d503300b87806ecd36aec48ba3128c49f202c9c78167e301b6f75711`; +the DMG SHA-256 was +`040d44146f224c0aedc45cafe0cf333bae5fa10eaad3ae8c820e1f2b0fc99e57`. +The whole app, signed DMG, and mounted-DMG app passed strict/deep signature +verification. The signature is ad hoc, so this is package-integrity evidence, +not Developer ID/notarization evidence. + +## Reviewed code owners + +The current baseline already contained the reviewed Wave 1A implementation, so +the focused baseline was GREEN rather than an artificially recreated RED. + +- `playbackRoute.test.ts#routes plain forward video to WebKit` passed. +- `nativePlaybackSession.test.ts#publishes only increasing matching frame sequences` + passed. +- `rustFrameBuffer.test.ts#keeps two stable Rust frame image slots mounted` + passed. +- `playback::resolver::tests::drain_propagates_stream_failure_instead_of_freezing_cache` + passed. +- The focused Web commands execute the current Vitest configuration's full + suite; each run passed 93 files / 824 tests. +- Task16's immediately preceding full Rust workspace gate, strict Clippy, + formatting, Web suite, and production build passed, and no product source + changed between that gate and this reconciliation. + +## Packaged WebKit route + +Project: +`/private/tmp/opentake-task16-bounded-audio-real-device-20260801.opentake`. +Its project document contains one visible ordinary video track and no +compositor-only property, which deterministically selects the WebKit route. + +- The project opened at `00:00:00 / 01:00:00` after a different project had + been active. +- Playback advanced to `00:02:23 / 01:00:00` and paused normally. +- The correct 60-second duration and zero start after the project boundary show + that the preceding Rust session's playhead/publication did not leak into the + replacement project. + +## Packaged Rust route + +Project: `/private/tmp/opentake-hsl-ui-real-device-20260731.opentake`. Its +persisted timeline has visible text, a color-graded video, multiple visible +video tracks, and an audio track. `resolveTimelinePlaybackRoute` therefore +selects Rust rather than WebKit. + +- Playback advanced from `00:00:00 / 00:30:20` to + `00:03:07 / 00:30:20`. +- Pause settled at `00:03:16`; a second observation 1.8 seconds later remained + `00:03:16`. +- The composited project remained visible while the playhead advanced; no stale + terminal image, black fallback, or previous-project duration was exposed. + +## Result + +`MR-playback-route-lifecycle-complete` is **PASS** as a reconciliation slice. +The authoritative route, exact session identity, monotonic publication, +retained two-slot handoff, decode failure propagation, and project-boundary +reset have direct owning tests; the final package smoke covers both production +routes and a cross-project lifecycle boundary. diff --git a/docs/audit/2026-07-14/runtime-artifacts/automated/recent-project-card-real-device-2026-07-30.md b/docs/audit/2026-07-14/runtime-artifacts/automated/recent-project-card-real-device-2026-07-30.md new file mode 100644 index 00000000..71357aaa --- /dev/null +++ b/docs/audit/2026-07-14/runtime-artifacts/automated/recent-project-card-real-device-2026-07-30.md @@ -0,0 +1,25 @@ +# Recent project card acceptance — 2026-07-30 + +Scope: Home Shell Task 12 (`implementation-slice-8e481b772d7d2357`) on the current arm64 debug bundle at `target/debug/bundle/macos/OpenTake.app`. + +## Code verification + +- Baseline RED: `pnpm -C web exec vitest run src/components/home/HomeView.interaction.test.tsx -t 'control-9697b53d4d2cf1ca select or open a recent project card'` exited 1 because the planned owning runner did not exist. +- Added the exact candidate-named interaction test for single-click selection, focus selection, Enter opening, and double-click opening through `openProjectPath(entry.path)`. +- Focused result: 1 file / 1 test passed. Nearby Home visual result: 11/11 passed. +- Regression result: 72 Web files / 727 tests passed; the production Web build and complete Rust workspace passed. + +## Product correction + +The original recent-card root was a pointer-only `div`. It was replaced with a real named button carrying `aria-pressed`, and its remove control remains a separate sibling button. Focus and single click select the path; the existing launcher Return handler and double-click both call the same project-open boundary. Return events from other focused interactive controls are ignored, preventing a stale card selection from opening alongside another Home action. + +## Real-device verification + +1. The initial pointer-only build exposed `TalkingHeadQA` and `Untitled` only as text in the macOS accessibility tree, confirming the product-level RED. +2. Rebuilt the exact app and terminated two pre-existing debug instances before launching a single current bundle, preventing stale-process evidence from being mixed with the candidate. +3. The fresh accessibility tree exposed `toggle button TalkingHeadQA, Value: off` and `toggle button Untitled, Value: off`. +4. A single click selected `TalkingHeadQA` (`Value: on`) while the app remained on Home. +5. Tab focus reached `TalkingHeadQA`; pressing Return opened the editor with the expected `talking-head-30s` media and 29:20 timeline. +6. Returned Home and invoked a native two-click activation (`click_count: 2`) on `TalkingHeadQA`; it opened the same editor and project content. + +Result: PASS. The planned pointer, keyboard, accessibility, and project-open paths agree in the packaged application. diff --git a/docs/audit/2026-07-14/runtime-artifacts/automated/schema-safe-persistence-real-device-2026-07-31.md b/docs/audit/2026-07-14/runtime-artifacts/automated/schema-safe-persistence-real-device-2026-07-31.md new file mode 100644 index 00000000..457deb56 --- /dev/null +++ b/docs/audit/2026-07-14/runtime-artifacts/automated/schema-safe-persistence-real-device-2026-07-31.md @@ -0,0 +1,59 @@ +# HS-schema-safe-persistence packaged macOS evidence — 2026-07-31 + +## Scope + +- Plan: `home-shell-implementation.md`, Task 6 `HS-schema-safe-persistence` +- App: `target/debug/bundle/macos/OpenTake.app` +- Project: `/private/tmp/opentake-beta-qa-20260729/TalkingHeadQA.opentake` +- Host: packaged macOS app, exercised through the native accessibility tree + +## Code contract + +The store persists only five global UI preferences, each in its own versioned +key: + +- `opentake.ui.v1.layoutPreset` +- `opentake.ui.v1.agentPanelVisible` +- `opentake.ui.v1.mediaPanelVisible` +- `opentake.ui.v1.inspectorPanelVisible` +- `opentake.ui.v1.keyframesPanelVisible` + +The exact owning test covers defaults; every valid and invalid current value; +valid and invalid legacy unprefixed values; one-time valid migration; isolated +per-action writes; fresh-store rehydration; unavailable/throwing reads; rejected +writes; and exclusion of view, playhead, selection, and maximize session state. + +## Gates + +- True focused RED: the planned test failed because `createEditorUiStore` did + not exist and the old singleton could not prove a fresh-session boundary. +- True focused GREEN: 1/1. +- Plan-form focused command executed after implementation. +- Web regression: 74 files / 747 tests passed. +- Web production build passed. +- `./web/node_modules/.bin/tauri build --debug` passed and produced both the app + and debug DMG. +- The preceding Task 7 full Rust workspace and formatting gates passed; Task 6 + changes only TypeScript store persistence, its tests, and documentation. + +## Packaged restart sequence + +1. Opened `TalkingHeadQA` from Home. The existing unprefixed preferences were + accepted and migrated: Agent reopened visible while Media and Inspector used + their stored/default visibility. +2. Selected a text clip and opened the Keyframes panel. +3. Applied Vertical layout, kept Agent visible, and hid Media and Inspector. + The packaged screen showed the expected distinct vertical geometry. +4. Terminated that exact debug app process and relaunched the same bundle. +5. The app correctly started at Home, proving `view=editor` was not persisted. +6. Reopened `TalkingHeadQA`: Vertical layout and Agent-visible/Media-hidden/ + Inspector-hidden preferences were restored. Playhead was `00:00:00` and every + timeline clip was unselected, proving project/session state did not leak. +7. Re-enabled Inspector and selected the text clip. The Keyframes lanes appeared + immediately without pressing the Keyframes button, proving that preference + also survived the process restart. +8. Restored Default layout, Agent off, Media and Inspector visible, Keyframes + closed, and cleared selection for the final app state. + +No project file, timeline command, media entry, or external output was written +by this verification. diff --git a/docs/audit/2026-07-14/runtime-artifacts/automated/script-to-video-vertical-2026-08-01.md b/docs/audit/2026-07-14/runtime-artifacts/automated/script-to-video-vertical-2026-08-01.md new file mode 100644 index 00000000..ed764b9c --- /dev/null +++ b/docs/audit/2026-07-14/runtime-artifacts/automated/script-to-video-vertical-2026-08-01.md @@ -0,0 +1,23 @@ +# Script-to-video vertical — 2026-08-01 + +`requirement-30bcd764cc0c454d` is implemented through the capability-gated Agent contract and the enabled Smart Pack / Script-to-Video panel. + +- Planning validates exact visual and narration media IDs, source duration, narration duration within one project frame, script bounds, frame durations, and supported cross-dissolve transitions. +- The canonical plan is SHA-256 identified and persists planner/version provenance, script, media IDs, narration IDs, transition choices, start frame, and durations in `project.json` before assembly clips exist. `apply=true` refuses an unreviewed or changed plan. +- Applying uses one `ApplyScriptAssemblyPlan` document transaction to add fresh visual/narration tracks, mute source visual audio where narration exists, preserve existing tracks, and bind outgoing transitions to exact adjacent clip IDs. One undo removes the complete assembly while retaining its reviewed plan. +- Cancellation/failure before commit leaves no partial tracks. The panel exposes editing, add/remove segment, plan review, retry, progress phases, cancel, apply, and undo, and ignores stale async completions. + +Focused verification: + +- `CARGO_INCREMENTAL=0 cargo test -p opentake-ops script_assembly -- --nocapture` — 2 passed. +- `CARGO_INCREMENTAL=0 cargo test -p opentake-tauri script_to_video_three_segment -- --nocapture` — passed using three real PNG sources plus real WAV narration; proved persisted preview, cancel atomicity, 3+3 aligned clips, two bound transitions, one undo, save/reopen, and an 18-frame H.264/AAC export whose audio stream probes successfully. +- `npm test -- ScriptToVideoTab.test.tsx` — 2 passed (review/edit/retry/apply/undo and cancel/stale-result isolation). + +Full slice gates after integration: + +- `CARGO_INCREMENTAL=0 cargo test -p opentake-domain -p opentake-ops -p opentake-project -p opentake-agent -p opentake-tauri` — passed, including the real script assembly/export integration; only the repository's seven explicitly ignored real-device probes remained ignored. +- `npm test` — 116 test files and 874 tests passed. +- `cargo fmt --all -- --check` — passed. +- `CARGO_INCREMENTAL=0 cargo clippy -p opentake-domain -p opentake-ops -p opentake-project -p opentake-agent -p opentake-tauri --all-targets -- -D warnings` — passed. Cargo reported only the pre-existing future-incompatibility notice for transitive `block v0.1.6`. +- `npm run build` — passed. Vite retained the existing dynamic-import and large-chunk advisories. +- `git diff --check` — passed. diff --git a/docs/audit/2026-07-14/runtime-artifacts/automated/stabilization-export-2026-07-31.mp4 b/docs/audit/2026-07-14/runtime-artifacts/automated/stabilization-export-2026-07-31.mp4 new file mode 100644 index 00000000..e2130bcf Binary files /dev/null and b/docs/audit/2026-07-14/runtime-artifacts/automated/stabilization-export-2026-07-31.mp4 differ diff --git a/docs/audit/2026-07-14/runtime-artifacts/automated/stabilization-export-frame-000-2026-07-31.png b/docs/audit/2026-07-14/runtime-artifacts/automated/stabilization-export-frame-000-2026-07-31.png new file mode 100644 index 00000000..9ff8fed0 Binary files /dev/null and b/docs/audit/2026-07-14/runtime-artifacts/automated/stabilization-export-frame-000-2026-07-31.png differ diff --git a/docs/audit/2026-07-14/runtime-artifacts/automated/stabilization-input-2026-07-31.mp4 b/docs/audit/2026-07-14/runtime-artifacts/automated/stabilization-input-2026-07-31.mp4 new file mode 100644 index 00000000..3b5432bb Binary files /dev/null and b/docs/audit/2026-07-14/runtime-artifacts/automated/stabilization-input-2026-07-31.mp4 differ diff --git a/docs/audit/2026-07-14/runtime-artifacts/automated/stabilization-preview-frame-000-2026-07-31.png b/docs/audit/2026-07-14/runtime-artifacts/automated/stabilization-preview-frame-000-2026-07-31.png new file mode 100644 index 00000000..6ae0d9e3 Binary files /dev/null and b/docs/audit/2026-07-14/runtime-artifacts/automated/stabilization-preview-frame-000-2026-07-31.png differ diff --git a/docs/audit/2026-07-14/runtime-artifacts/automated/stabilization-real-device-2026-07-31.md b/docs/audit/2026-07-14/runtime-artifacts/automated/stabilization-real-device-2026-07-31.md new file mode 100644 index 00000000..7f7fe566 --- /dev/null +++ b/docs/audit/2026-07-14/runtime-artifacts/automated/stabilization-real-device-2026-07-31.md @@ -0,0 +1,113 @@ +# Stabilization real-device evidence (2026-07-31) + +Status: **macOS packaged acceptance complete for Media Render Task 5.** This +receipt closes `MR-stabilization`; it does not claim Developer ID signing, +notarization, Windows acceptance, or Beta release readiness. + +## Bound contract + +- `media-render-playback-export-implementation.md`, Task 5, + `implementation-slice-7ef9369889a0a0d6` / + `requirement-20198476e9083261`. +- Owning test: + `crates/opentake-render/tests/stabilization.rs#synthetic_shake_produces_editable_undoable_preview_export_solution`. + +## Historical RED and GREEN receipts + +The owning test was added before implementation. The exact planned command +exited 101 with eight missing API/type errors across the domain, media, ops, and +render boundaries. After implementation: + +```text +$ cargo test -p opentake-render --test stabilization synthetic_shake_produces_editable_undoable_preview_export_solution -- --exact +1 passed; 0 failed + +$ pnpm -C web test +89 files passed; 806 tests passed + +$ cargo fmt --all -- --check +exit 0 + +$ cargo clippy --workspace --all-targets -- -D warnings +exit 0 (only the existing future-incompatibility notice for block 0.1.6) + +$ cargo test --workspace --no-fail-fast +all workspace unit, integration, and doc-test binaries passed; only the seven +explicit real-device probe tests remained ignored +``` + +The owning test verifies that a deterministic jitter sequence has lower +post-stabilization displacement, the safety crop covers every output corner, +preview/export sample the exact same composed transform, apply/reset are +non-destructive, and undo restores the prior document. Media tests cover block +motion and cooperative cancellation; Tauri tests cover single-flight token +cancellation and command routing; Web tests cover the Inspector and native +playback route. + +## Packaged application receipt + +The release application was rebuilt with the repository-locked Tauri CLI. The +final binary SHA-256 was: + +```text +fcea9121b4b26fe1fdf191cd03734c54e5685fc0ddf7909dfec15c39235aed4a +``` + +`codesign --verify --deep --strict --verbose=2` reported the ad-hoc signed app +valid on disk and satisfying its designated requirement, including packaged +`ffmpeg` and `ffprobe`. This proves local integrity only. + +## Packaged GUI receipt + +An isolated 1920×1080, 60 fps project containing a 2-second 320×180, 24 fps +synthetic jitter clip was opened through the native project picker in the exact +release `.app`. The Inspector analysis produced +`opentake.motion-smoothing v1` with 48 motion samples. The UI then verified: + +- strength 100% → 65%; additional crop 0% → 3%; +- undo restored crop to 0%, redo returned it to 3%; +- reset removed the track and undo restored the complete 65% / 3% solution; +- explicit save and quit/reopen retained model/version, source identity, 48 + keyframes, strength, and crop while starting with an empty history stack; +- the clip media reference and source file were unchanged. + +The persisted correction track had maximum absolute horizontal correction +0.0075 and conservative coverage zoom. A playback-routing defect found during +this run was fixed so every stabilized clip uses the desktop compositor rather +than WebKit's source-video path. + +## Cancellation receipt + +A separate 60-second 3840×2160 long-GOP fixture kept analysis running long +enough to exercise cancellation. The packaged UI remained interactive while +showing `正在分析运动…` and `取消分析`. Clicking cancel immediately restored the +existing 48-sample solution, emitted no error, and left both undo and redo +disabled. The decode/motion work runs on a background blocking task so the +cancel command can be dispatched concurrently. + +## Export and preview/export parity + +The packaged Export dialog produced `stabilization-export-2026-07-31.mp4`: + +```text +codec=h264 +width=1920 +height=1080 +pixel_format=yuv420p +avg_frame_rate=60/1 +duration=2.000000 +nb_frames=120 +preview/export frame 0 SSIM: 0.999557 +preview/export frame 0 PSNR: 56.843845 dB +``` + +Tracked artifacts: + +- `stabilization-input-2026-07-31.mp4` — SHA-256 + `2c344578e303e5c2c4a727eac1ea3ce79bb1a9e4aa6b40ab6535e4b8363f3c69` +- `stabilization-preview-frame-000-2026-07-31.png` — SHA-256 + `24e959f29c3f324923f77322d7cab8225df3cca6b98eec1a7a05f153cba598f5` +- `stabilization-export-2026-07-31.mp4` — SHA-256 + `18440c7d62fb4afc38c28a1399a5f31a833fe3606befb9a2b3f22bca54a6ed4f` +- `stabilization-export-frame-000-2026-07-31.png` — SHA-256 + `58c14f1af53bd275e15e934b442a6d1526c3e4ee8e777587944d307bb818749a` diff --git a/docs/audit/2026-07-14/runtime-artifacts/automated/stem-separation-agent-vertical-2026-08-01.md b/docs/audit/2026-07-14/runtime-artifacts/automated/stem-separation-agent-vertical-2026-08-01.md new file mode 100644 index 00000000..edcafdc0 --- /dev/null +++ b/docs/audit/2026-07-14/runtime-artifacts/automated/stem-separation-agent-vertical-2026-08-01.md @@ -0,0 +1,58 @@ +# Stem Separation Agent / Track-Import Vertical — 2026-08-01 + +## Delivered surface + +- The capability-gated `separate_stems` Agent tool now invokes the same local, + integrity-checked `opentake-center-v1` owner as the Inspector. Unsupported + hosted providers/models fail closed and no upload adapter is implied. +- Separation produces two ordinary audio assets with stable IDs and persisted + source asset/SHA-256, execution, model SHA-256, stem kind, and output index. +- Existing Inspector progress, cancellation, retry, local-privacy copy, hosted + consent refusal, and result state are retained. Each completed asset now has + an inline native audio audition. +- A reviewed pair can be placed at the current playhead on two independent + aligned audio tracks. Both placements form one `Import Stems To Tracks` undo + entry; Undo removes both tracks while keeping reusable stem media in the + catalog. +- The Agent `importToTracks` option uses the same separate-track transaction and + returns both placed clip IDs and the action name. +- The local release profile remains intentionally scoped to centred dialogue + plus complementary stereo side content. It is not represented as arbitrary + semantic Demucs/MDX separation. + +## Automated evidence + +- `CARGO_INCREMENTAL=0 cargo test -p opentake-media --test stems -- --nocapture` + - deterministic 48 kHz stereo mixture passed; + - vocals improved SDR by at least 12 dB; + - accompaniment achieved at least 60 dB against the documented + mono-compatible reference; + - the sum of published stems reconstructed that documented mixture at at + least 60 dB SDR; + - model integrity, progress endpoints, cancellation cleanup, and hosted + fail-closed behavior passed. +- `CARGO_INCREMENTAL=0 cargo test -p opentake-tauri stem_bridge_imports_provenance_aligned_tracks_undo_reopen_and_cancel --lib -- --nocapture` + - a real WAV source produced two persisted provenanced assets; + - both three-frame outputs were placed at frame 45 on separate audio tracks; + - one Undo removed both tracks while retaining the two media assets; + - Redo plus save/reopen restored both tracks and all three media entries; + - a pre-cancelled repeat added no media or tracks. +- `CARGO_INCREMENTAL=0 cargo test -p opentake-ops aligned_stem_track_tests --lib` + - overlapping aligned entries use separate fresh tracks and one undo entry. +- `CARGO_INCREMENTAL=0 cargo clippy -p opentake-media -p opentake-ops -p opentake-tauri --all-targets -- -D warnings` + - passed. +- `npm test -- --run` + - all 114 web test files passed: 871 tests; + - focused UI/IPC coverage includes progress, cancellation, audition elements, + aligned-track import at the playhead, and Undo. +- Existing packaged-runtime evidence in + `runtime-artifacts/automated/stems-real-device-2026-08-01.md` verifies local + privacy/failure/success paths, source/model hashes, direct asset preview, + cancellation cleanup, save/reopen, and independent packaged exports. + +## Remaining acceptance evidence + +The newly added explicit audition and aligned-track controls still require a +final packaged macOS GUI pass in the assembled Beta candidate. The existing +packaged evidence already covers the underlying separation assets and export +path; this final pass closes the updated interaction surface. diff --git a/docs/audit/2026-07-14/runtime-artifacts/automated/stems-real-device-2026-08-01.md b/docs/audit/2026-07-14/runtime-artifacts/automated/stems-real-device-2026-08-01.md new file mode 100644 index 00000000..59619256 --- /dev/null +++ b/docs/audit/2026-07-14/runtime-artifacts/automated/stems-real-device-2026-08-01.md @@ -0,0 +1,37 @@ +# MR-stems packaged-runtime evidence — 2026-08-01 + +## Scope and package identity + +Task 13 was exercised in the rebuilt release bundle at `target/release/bundle/macos/OpenTake.app`. The final executable SHA-256 was `942aa7d0e432c270146d04a025c5c3632b61333e17f92369d7152295d6bd20c8`; strict deep code-sign verification passed. The bundle is ad-hoc signed and has no Team ID, so this evidence is local packaged-runtime verification, not Developer ID notarization or a publishable Beta signature. + +The saved test project was `/private/tmp/opentake-stems-real-device-20260801.opentake`. Its deterministic five-second stereo mixture was `/private/tmp/opentake-stems-mix-20260801.wav` (SHA-256 `295804e62dcc5d5f4f8146b78c485ac19823c98529ee025455c2c368d5530524`). The vocal reference SHA-256 was `835ee1c4414a75acc6ce87b9d3f2ad0f37b3f190e9e1958c08c051906524c2c6`; the final mono-compatible accompaniment reference SHA-256 was `444f012257e20c6e4fe4d70e9fcb30c7c87d26e3429498c7226e4b4529c3f495`. + +## Privacy, failure and success paths + +- Hosted mode visibly required provider/model and upload consent. Submitting without consent returned the localized error `使用托管服务前必须确认上传源音频。`. Supplying labels and consent with no adapter returned `所选托管服务尚未配置可用的音轨分离适配器。`; no upload occurred. +- Local mode displayed `完全在本机处理,音频不会上传。首次使用会安装并校验内置模型。` and completed with execution id `local:opentake-center-v1`. +- The installed model profile was 104 bytes and matched SHA-256 `9c72ab220f370000a702fc11c8071905648a56d1102d9519659a6062abb4b376`. +- The two results appeared as independent derived media assets. Save/reopen retained `generationInput` source/model hashes, selecting either asset exposed its resolved project-relative path and audio preview, and both could be placed on the timeline and played. + +The final derived job directory was `media/stems-686f5e60-a666-4768-87e5-f610c9f92d97`. Vocals SHA-256 was `8fc799724bb9ce760fd90447ba348a64b8af6eb4dc0092735731c8a8a3790f76`; accompaniment SHA-256 was `5ef6b291022bce125a10e1954ecaefce5223051a74b352cbd768403da70dda8a`. Direct PCM comparison measured `85.3732 dB` SDR for both vocal channels and `83.8316 dB` for both accompaniment channels. + +## Packaged export + +Each stem was independently exported through the normal timeline path: + +- `/private/tmp/opentake-stems-vocals-export-v2-20260801.mp4`, SHA-256 `8290427162eb6fb94fc591829017646299901b75ceeb42a288585aa129054428`, five seconds, H.264 1920×1080 plus AAC mono 48 kHz, `34.1008 dB` SDR against the vocal reference. +- `/private/tmp/opentake-stems-accompaniment-export-v2-20260801.mp4`, SHA-256 `6031b6badec7a1b57eacbde3563ce92b2e93c497685a89081e08987656b258c2`, five seconds, H.264 1920×1080 plus AAC mono 48 kHz, `25.5688 dB` SDR against the mono-compatible accompaniment reference. Its mean level was `-13.2 dB`, confirming a non-silent export. + +The first side-channel implementation cancelled to silence in the mono export path. A focused regression failed at `-3.010 dB` for mono compatibility; publishing both stems as dual-mono fixed the actual export while preserving the deterministic centre/side boundary. This is a compatibility choice, not a claim of semantic source separation. + +## Cancellation and cleanup + +Cancellation was verified with `/private/tmp/opentake-stems-cancel-1800s-20260801.wav` (330 MiB, SHA-256 `072c31ef023f50d6f8a87ec88e42ca1adceec2f64e1489c866f63bc163aec4b6`). The packaged UI reached `正在分离音轨… 8 %`; `取消分离` returned it to idle. The media manifest remained at nine entries with only the imported source matching that fixture, no matching derived item, no new stem job directory, and no `.partial` or temporary output. A shorter 300-second fixture finished before cancellation could be observed and is not counted as cancellation evidence. + +## Runtime defects found and closed + +- Selecting the first generated asset initially produced a black WebView through an unstable Zustand selector. A RED Inspector regression reproduced `Maximum update depth exceeded`; deriving generation sections from the stable `items` selector fixed it. +- Project-relative derived assets initially returned no path in `MediaItemDto`. A RED Rust test observed `None`; returning the resolved path fixed direct preview and timeline use. +- The initial anti-phase accompaniment exported silently through the mono mixer. The focused mono-compatibility RED and the packaged export above verify the dual-mono correction. + +The local algorithm is an integrity-checked centre/side DSP profile suited to centred voice/dialogue fixtures. It is not a neural Demucs/MDX separator and does not close the broader semantic-separation gap for arbitrary mixes. diff --git a/docs/audit/2026-07-14/runtime-artifacts/automated/subtitle-export-real-device-2026-07-30.md b/docs/audit/2026-07-14/runtime-artifacts/automated/subtitle-export-real-device-2026-07-30.md new file mode 100644 index 00000000..394f88c3 --- /dev/null +++ b/docs/audit/2026-07-14/runtime-artifacts/automated/subtitle-export-real-device-2026-07-30.md @@ -0,0 +1,65 @@ +# Subtitle SRT/VTT export automated + real-device evidence — 2026-07-30 + +Scope: requirement `requirement-7043d6a44938bd27` / implementation slice `implementation-slice-3b2b0548dab34607`, plus the owning title-bar controls `control-c035467e6746e570` and `control-f54f4037ab7bffbe`. + +## Production path + +- `TitleBar.tsx#onExportSubtitles` exposes SRT and VTT from the editor title bar, closes the format menu, opens the native save panel with the correct extension filter, appends a missing extension, and reports done/empty/failure through the visible toast state. +- `web/src/lib/api.ts#exportSubtitles` invokes the typed `export_subtitles` Tauri command with the selected path and format. +- `src-tauri/src/commands.rs#export_subtitles` serializes the current caption clips through the domain SRT/VTT writers and returns the written cue count. + +## Automated evidence + +Focused tests: + +```text +cargo test -p opentake-tauri exports_non_empty_srt_with_cue_count +cargo test -p opentake-tauri exports_vtt_with_header +pnpm -C web exec vitest run src/components/shell/TitleBar.visual.test.ts src/components/shell/TitleBar.interaction.test.tsx +``` + +The Rust tests each passed with one matching test. The two title-bar files passed with the exact planned SRT/VTT routing test and owning DOM controls for menu dismissal, native save-panel arguments, extension completion, typed API arguments, success, zero-cue empty state, write failure, user cancellation, and default-directory fallback. + +Regression gates: + +```text +pnpm -C web test +pnpm -C web build +cargo fmt --check +git diff --check +``` + +The final web regression passed 70 files / 709 tests; the focused owning filter passed all four matching subtitle-export cases. The production build completed. The full Rust workspace passed with zero failures and only the repository-declared ignored hardware probes. Workspace clippy passed with warnings denied. Vite emitted only the existing ineffective-dynamic-import and large-chunk warnings. Formatting and diff checks passed. + +## Real macOS application loop + +Application: `/Users/lvbaiqing/TRUE 开发/PRIMARY-CN/OpenTake-generation/target/debug/bundle/macos/OpenTake.app`. + +Project: `/tmp/opentake-beta-qa-20260729/TalkingHeadQA.opentake`, reopened from the saved talking-head cleanup fixture with four regenerated caption clips. + +Using the visible title-bar `导出字幕` popup and the native macOS save panel: + +1. `字幕 · SubRip (.srt)` exported `/tmp/opentake-beta-qa-20260729/TalkingHeadQA-cleaned.srt` and the application reported `字幕已导出 · 4 条`. +2. `字幕 · WebVTT (.vtt)` exported `/tmp/opentake-beta-qa-20260729/TalkingHeadQA-cleaned.vtt` and the application reported `字幕已导出 · 4 条`. + +Both files are non-empty (307 bytes) and contain exactly these four cues in timeline order: + +1. `Hello and welcome to this open take beta test` +2. `today we are testing the talking head clean up workflow` +3. `the accepted cuts should keep` +4. `the audio and video perfectly synchronized` + +Format verification: + +- SRT contains numbered cues and comma millisecond timestamps, beginning `00:00:00,000 --> 00:00:02,866`. +- VTT begins with `WEBVTT`, omits SRT numbering, and uses dot millisecond timestamps, beginning `00:00:00.000 --> 00:00:02.866`. +- Cue 4 ends at `00:00:16.400`, within the 890-frame / 29.666-second edited timeline. + +SHA-256: + +- SRT: `8b68f2abff929d100b3b2c290aa3e4768825458a785eb99b465e2fbe786afe6b` +- VTT: `fffe46a61b6a42e59dccea2484782b8feb9a09e6ea25ce35e03582017e8d4fe0` + +## Result + +The planned caption-export requirement is verified through code, owning automated tests, the Tauri command boundary, the real OpenTake UI, the native save panel, and the generated file contents. This closes only the SRT/VTT export slice; adjacent caption authoring, styling, and provider-driven workflows remain independently tracked. diff --git a/docs/audit/2026-07-14/runtime-artifacts/automated/talking-head-cleanup-2026-07-29.md b/docs/audit/2026-07-14/runtime-artifacts/automated/talking-head-cleanup-2026-07-29.md new file mode 100644 index 00000000..374cc5ad --- /dev/null +++ b/docs/audit/2026-07-14/runtime-artifacts/automated/talking-head-cleanup-2026-07-29.md @@ -0,0 +1,91 @@ +# Talking-head cleanup automated + real-device evidence — 2026-07-29/30 + +Scope: Task 3 sub-slice `requirement-9b922be7c8e92147`. The automated contract and a real macOS application loop now pass. This artifact still does not close the complete requirement because the Agent chat/provider-driven review surface was not exercised with a configured model credential. + +## Production path + +- `remove_filler_words` is a strict typed MCP/Chat tool backed by the production `MediaBridge::transcribe` path through `get_transcript`. +- Configurable single- and multi-word filler phrases are normalized and matched against word-level project-frame timestamps. +- Results are preview-only, carry stable review ids and `accepted: true`, and group selected half-open ranges into one undoable `ripple_delete_ranges` command per affected track. +- The catalog omits transcript-dependent tools when no media bridge is present; direct dispatch then returns `Tool is not advertised`. +- `tighten_silences` remains the PCM/RMS half of the same workflow and returns reviewable ripple commands without direct mutation. + +## RED evidence and defect found + +The initial `remove_filler_words_returns_reviewable_word_aligned_ranges` test failed with `Unknown tool: remove_filler_words` before the tool was added. + +After adding a fixed 30-second linked A/V fixture, `reviewed_filler_cut_applies_once_and_undo_restores_the_timeline` failed because the audio track retained a duplicate `[6,12)` middle fragment while the video track did not. Root cause: `clear_region` searched all tracks for the right half created by a linked split and could select the partner track's fragment. The fix scopes that lookup to the original clip's track, and a lower-level ripple regression now pins the behavior. + +The first real Whisper run exposed three additional production defects that synthetic word fixtures could not reveal: + +- whisper.cpp's experimental absolute token timestamps spread early words backwards through long leading silence, so filler cuts targeted silence rather than speech; +- zero-duration lexical tokens such as `You [t,t]` were discarded, preventing `you know` from being reviewed as one phrase; +- a fresh desktop process restarted the sequential core id generator at `id-1`, allowing regenerated captions to collide with ids loaded from the saved project. + +PCM edge alignment now tightens only long silent segment edges and remaps token positions into the audible interval. Lexical normalization retains zero-duration words and gives them non-overlapping reviewable spans. The production desktop wiring now uses UUID ids while deterministic sequential ids remain available to tests. + +The first caption regeneration after accepted cuts exposed one further defect: cached source-segment text still contained deleted fillers, so captions resurrected `Um`. Caption generation now rebuilds only cut segments from visible word runs, preserving original punctuation and split-token spelling (for example `synchron` + `ized` remains `synchronized`). A sync-locked derived caption follower also correctly refused a colliding ripple operation atomically; the validated workflow removes and regenerates derived captions around the linked A/V cleanup. + +## Focused GREEN commands + +```text +cargo test -p opentake-ops ripple_delete_ranges_keeps_linked_av_frame_exact +cargo test -p opentake-agent filler -- --nocapture +cargo test -p opentake-agent --test advertised_tool_acceptance every_advertised_tool_is_live_or_absent -- --exact +cargo test -p opentake-agent without_bridge +cargo test -p opentake-agent system_prompt_includes_context_signal +cargo test -p opentake-agent --test tool_argument_contract +``` + +All commands passed on the working tree. The fixed 30-second, 30 fps fixture proves: + +- `um` maps to `[6,12)` and `you know` maps to `[15,27)`. +- Applying only `[6,12)` leaves `you know` in transcript order. +- Linked video and audio both become `[(0,6),(6,894)]`, with no drift or duplicate fragment. +- Selecting the linked video clip expands to its transcribed audio partner before matching, so a normal talking-head video selection does not silently return zero cuts. +- One `undo` restores the exact prior timeline. + +## Regression gates + +```text +cargo test -p opentake-ops --no-fail-fast +cargo test -p opentake-agent --no-fail-fast +cargo fmt --all -- --check +cargo clippy --workspace --all-targets -- -D warnings +cargo test --workspace --no-fail-fast -q +cd web && pnpm build +cd web && pnpm test +git diff --check +``` + +All gates passed. The affected subsystem totals include 184 `opentake-ops` unit tests plus 55 command integration tests and 332 `opentake-agent` unit tests plus its integration suites. The full workspace completed with zero failures; only repository-declared ignored tests remained ignored. Web verification passed 70 files / 703 tests and the production build. Vite emitted the pre-existing ineffective-dynamic-import and large-chunk warnings; they are warnings, not build failures. + +## Real macOS application loop + +Application: exact current-branch debug bundle at `target/debug/bundle/macos/OpenTake.app`. + +Fixture and outputs: + +- source: `/tmp/opentake-beta-qa-20260729/talking-head-30s.mp4`, SHA-256 `fd4eae86104ed966d82bd1dfbbdf3feb5be4370fdfbacaba0a426f8acbe771bb`; +- saved project: `/tmp/opentake-beta-qa-20260729/TalkingHeadQA.opentake`; +- Whisper model: `ggml-base.bin` (local 141 MB model); +- export: `/tmp/opentake-beta-qa-20260729/TalkingHeadQA-cleaned.mp4`, SHA-256 `4f0724b28e1762c9a532f402f99fc2f28065b2fbe1c86a0681c880007558ffa6`. + +Observed and accepted review results: + +- corrected real-word spans placed `Um` at `[150,155)` and `You know` across `[357,366)`, inside the spoken audio rather than leading silence; +- default filler review proposed `Um [151,154)` and `You know [358,365)`; both were accepted; +- the linked video and audio tracks each became the same three fragments: timeline `[0,151)`, `[151,355)`, `[355,890)`, with source trims `0`, `154`, and `365` respectively; +- the operation removed exactly 10 frames across the two linked tracks and shifted four fragments; post-cut transcription contained no `Um`, `You`, or `know`; +- regenerated captions were exactly: `Hello and welcome to this open take beta test`; `today we are testing the talking head clean up workflow`; `the accepted cuts should keep`; `the audio and video perfectly synchronized`; +- all regenerated caption ids were UUIDs and did not collide with existing A/V ids. + +The project was saved, the desktop process was fully terminated, the same build was restarted, and the saved project reopened at 890 frames (`00:29:20` at 30 fps) with all three linked A/V fragments and four captions intact. + +The real UI export produced H.264 1920×1080 at 30 fps with 890 decoded video frames and 29.666667 s video duration; AAC audio duration was 29.666000 s (0.000667 s difference). Visual inspection of seam and caption contact sheets found no black/broken seam frames and showed the four expected captions without deleted filler text. Contact-sheet hashes are `ed5c51e95860929c3d93a5405bf2cd146d8646fd7aa68f2b1808a8b7b29d3f6d` and `97f2ef75ec54a38229e93a70ab73f1cc712d022b892e034b9656804a82c7b674`. + +## Remaining requirement evidence + +- Exercise the same review/apply flow through the Agent chat UI with a configured model provider; the local tool contract was invoked directly because no provider credential was available in this test environment. +- Exercise the combined filler + configured-silence review in that user-facing surface. Filler review/apply passed independently, while the existing silence workflow remains covered by automated PCM/RMS tests. +- Retain durable release-run screenshots or packaged CI artifacts rather than relying only on the local temporary contact sheets recorded above. diff --git a/docs/audit/2026-07-14/runtime-artifacts/automated/titlebar-controls-real-device-2026-07-30.md b/docs/audit/2026-07-14/runtime-artifacts/automated/titlebar-controls-real-device-2026-07-30.md new file mode 100644 index 00000000..71b69185 --- /dev/null +++ b/docs/audit/2026-07-14/runtime-artifacts/automated/titlebar-controls-real-device-2026-07-30.md @@ -0,0 +1,49 @@ +# TitleBar controls automated + real-device evidence — 2026-07-30 + +Scope: `home-shell-implementation.md` Task 13 / `implementation-slice-1f94f01d3b65a701`, covering seven title-bar control records. + +## Automated evidence + +The exact planned tests now exist in `web/src/components/shell/TitleBar.interaction.test.tsx`: + +- `control-f52cc89817361a19 return from editor to Home` +- `control-4bda8f075e1f3a14 open the global Library` +- `control-ff132f94a8c87906 open Settings from the editor` +- `control-d7ba227c6447e43e open Video Export` +- `control-c035467e6746e570 open/close subtitle export formats` +- `control-229710d0115f07bc open/close interchange export menu` +- `control-02d1bf7fff7c1e3a open Video Export from the interchange menu` + +The focused filter passed all seven candidates. It proves the exact UI-store transitions, both popup menus' expanded state, Escape/outside-click dismissal, empty-timeline video-export disablement, populated-timeline enablement, and menu-to-dialog return path. + +Regression: + +```text +pnpm -C web test +pnpm -C web build +cargo fmt --check +git diff --check +``` + +Result: 70 files / 715 web tests passed; the production build completed. Vite emitted only the existing ineffective-dynamic-import and large-chunk warnings. Formatting and diff checks passed. + +## Real macOS application loop + +Application: `/Users/lvbaiqing/TRUE 开发/PRIMARY-CN/OpenTake-generation/target/debug/bundle/macos/OpenTake.app`. + +Project: `/tmp/opentake-beta-qa-20260729/TalkingHeadQA.opentake`, 890 frames at 30 fps with the saved three-fragment linked A/V timeline and four captions. + +Observed in the real interface: + +1. `设置` opened the Settings overlay at `通用`; `完成` closed it and returned to the unchanged editor. +2. `素材库` opened the global Library. `返回主页` reached Home; the `TalkingHeadQA` recent card was visible with the exact saved path, and double-click reopened the same 890-frame project. +3. The direct `导出视频` control was enabled for the populated timeline, opened the video export dialog, and `取消` returned to the editor. +4. `导出` opened the interchange menu with MP4, XMEML, FCPXML, OTIO, and EDL entries; Escape dismissed the menu. +5. Reopening `导出` and selecting `渲染为视频(MP4)` closed the popup and opened the same video export dialog; `取消` returned to the editor. +6. The previously validated `导出字幕` popup remained present and usable; its SRT/VTT native export evidence is recorded separately in `subtitle-export-real-device-2026-07-30.md`. + +The final accessibility tree still showed `TalkingHeadQA` at `00:29:20`, all six linked A/V clip fragments, and no modal or popup left open. + +## Result + +All seven controls in Task 13 are verified by exact owning tests and a real application round trip. This closes only the planned title-bar control slice; Home launcher controls, interchange file contents, and other shell surfaces remain independently tracked. diff --git a/docs/audit/2026-07-14/runtime-artifacts/automated/transition-export-2026-07-31.mp4 b/docs/audit/2026-07-14/runtime-artifacts/automated/transition-export-2026-07-31.mp4 new file mode 100644 index 00000000..8977e712 Binary files /dev/null and b/docs/audit/2026-07-14/runtime-artifacts/automated/transition-export-2026-07-31.mp4 differ diff --git a/docs/audit/2026-07-14/runtime-artifacts/automated/transition-export-frame-140-2026-07-31.png b/docs/audit/2026-07-14/runtime-artifacts/automated/transition-export-frame-140-2026-07-31.png new file mode 100644 index 00000000..ca845dba Binary files /dev/null and b/docs/audit/2026-07-14/runtime-artifacts/automated/transition-export-frame-140-2026-07-31.png differ diff --git a/docs/audit/2026-07-14/runtime-artifacts/automated/transition-packaged-ui-2026-07-31.png b/docs/audit/2026-07-14/runtime-artifacts/automated/transition-packaged-ui-2026-07-31.png new file mode 100644 index 00000000..c13f032f Binary files /dev/null and b/docs/audit/2026-07-14/runtime-artifacts/automated/transition-packaged-ui-2026-07-31.png differ diff --git a/docs/audit/2026-07-14/runtime-artifacts/automated/transition-preview-frame-140-2026-07-31.png b/docs/audit/2026-07-14/runtime-artifacts/automated/transition-preview-frame-140-2026-07-31.png new file mode 100644 index 00000000..ffe3425e Binary files /dev/null and b/docs/audit/2026-07-14/runtime-artifacts/automated/transition-preview-frame-140-2026-07-31.png differ diff --git a/docs/audit/2026-07-14/runtime-artifacts/automated/transition-real-device-2026-07-31.md b/docs/audit/2026-07-14/runtime-artifacts/automated/transition-real-device-2026-07-31.md new file mode 100644 index 00000000..ee21aae6 --- /dev/null +++ b/docs/audit/2026-07-14/runtime-artifacts/automated/transition-real-device-2026-07-31.md @@ -0,0 +1,126 @@ +# Editable cross-dissolve packaged-app verification (2026-07-31) + +## Scope + +This record closes implementation-plan Task 7 (`MR-transitions`) for the first +editable clip-to-clip transition vertical slice. It verifies the Rust product +boundary first, then the rebuilt packaged macOS application against an isolated +copy of a real 30 fps project. + +This is local functional evidence only. It is not Developer ID signing, +notarization, or a Beta release claim. + +## Test-first implementation evidence + +- Owning test added at + `crates/opentake-render/tests/transitions.rs#adjacent_clip_transition_is_editable_undoable_and_matches_preview_export`. +- Historical RED command: + `cargo test -p opentake-render --test transitions adjacent_clip_transition_is_editable_undoable_and_matches_preview_export -- --exact` +- RED failure: the serialized transition did not contain + `"fromClipId":"a"`, proving the previous model did not persist both + adjacent clip identities. +- The same exact command passed after implementation. +- The owning test covers add/change/remove, undo/redo, save/reopen, explicit + rejection of an overlong transition without history mutation, and fresh GPU + preview/export equality at the cut, midpoint, last mixed frame, and end. +- Its synthetic red-to-blue fixture also guards against midpoint darkening: the + midpoint is purple, not two half-transparent layers over black. + +## Code and build gates + +All commands completed successfully against the Task 7 working tree: + +- `npm test`: 89 test files, 807 tests passed. +- `npm run build`: passed (existing non-blocking bundle-size warnings only). +- Focused web tests for the transition tab, fallback command path, and timeline + overlay: 3 files, 29 tests passed. +- `cargo fmt --all -- --check`: passed. +- `cargo clippy --workspace --all-targets -- -D warnings`: passed. Cargo only + repeated the repository's existing future-incompatibility notice for + `block 0.1.6`. +- `cargo test --workspace --no-fail-fast`: passed. Explicit real-device-only + tests remained ignored by design. +- `web/node_modules/.bin/tauri build --bundles app --no-sign`: passed. + +The tested application was +`target/release/bundle/macos/OpenTake.app`: + +- executable SHA-256: + `a0fd7fdc734909699eb3c54cba96018fa566b87cea8e14b5638fc72a23f10f54` +- ad-hoc CDHash: `3325a88d8ac1bb1a7bfa777e64fc355fc9c34276` +- `codesign --verify --deep --strict --verbose=2`: passed, including the bundled + `ffmpeg` and `ffprobe` helpers. +- `Signature=adhoc`, `TeamIdentifier=not set`; therefore this proves local + bundle integrity only. + +## Packaged application workflow + +Fixture: +`/private/tmp/opentake-transition-real-device-20260731.opentake` +(isolated copy; source media remained external and unmodified). + +The packaged application completed the following workflow through its visible +UI: + +1. Opened the fixture and selected the enabled **Transition** media tab. +2. Loaded a legacy cross-dissolve that contained only `toClipId`; the UI showed + it correctly without corrupting the project. +3. Removed the transition, undid the removal, redid it, then added it again. +4. Changed its duration to 20 frames (0.67 seconds), applied it, and saved. +5. Returned to Home, reopened the project from Recents, and confirmed the + 20-frame transition persisted while the fresh session had no stale undo or + redo history. +6. Started playback before the transition at frame 140. Playback advanced past + the cut at frame 151 and reached frame 919 (`00:30:19`), proving native + playback crossed the transition boundary without stalling or crashing. +7. Returned exactly to frame 140 (`00:04:20`), captured the native preview, and + exported the complete timeline through the packaged application's export + dialog. + +The saved model contains both adjacent identities and the requested duration: + +```json +{"id":"id-4","transitionOut":{"fromClipId":"id-4","toClipId":"c7e1e6d4-6be2-4844-a827-c4fa9e20d402","kind":"crossDissolve","durationFrames":20}} +``` + +- `project.json` SHA-256: + `e7e7d1aede80a0dd2c647ef5c13106aa1c27621c08011a3cc8e2b731d3e090f9` +- post-capture `media.json` SHA-256: + `7df2035ed2a328495dabac49a9ec6801be971b1ed265153276f1a0ba7f98f981` + +## Preview/export parity + +The packaged application export probes as: + +- H.264 video, 1920 x 1080, 30/1 fps, 920 frames +- AAC audio +- duration `30.666667` seconds +- file size `273566` bytes +- SHA-256: + `132f00e123a805006da55e93cc7074ac2b78762eaebfd16e36a19ffe2065a0ee` + +Frame 140 was extracted from the complete export and compared with the native +preview capture at the same timeline frame using the bundled FFmpeg: + +- SSIM: `0.997060` (`25.317063` dB) +- PSNR average: `37.911328` dB +- both frames: PNG, 1920 x 1080 + +The small difference is the expected H.264 chroma/quantization loss; geometry, +transition composition, and the active text overlay match visually. + +## Artifacts + +- `transition-packaged-ui-2026-07-31.png` — reopened packaged UI with the + persisted 20-frame transition. +- `transition-preview-frame-140-2026-07-31.png` — native packaged preview. +- `transition-export-2026-07-31.mp4` — complete packaged-app export. +- `transition-export-frame-140-2026-07-31.png` — exact exported comparison + frame. + +## Result + +Task 7's first cross-dissolve vertical slice is verified through its owning +tests, workspace gates, save/reopen behavior, undo/redo, packaged playback, and +complete export. Additional wipe, slide, and 3D transition types remain future +library expansion and are not part of this task's acceptance contract. diff --git a/docs/audit/2026-07-14/runtime-artifacts/automated/view-menu-contract-real-device-2026-07-31.md b/docs/audit/2026-07-14/runtime-artifacts/automated/view-menu-contract-real-device-2026-07-31.md new file mode 100644 index 00000000..fb24bc5f --- /dev/null +++ b/docs/audit/2026-07-14/runtime-artifacts/automated/view-menu-contract-real-device-2026-07-31.md @@ -0,0 +1,118 @@ +# HS-menu-contract packaged macOS evidence — 2026-07-31 + +## Scope + +- Plan: `home-shell-implementation.md`, Task 7 `HS-menu-contract` +- App under test: `target/debug/bundle/macos/OpenTake.app` +- Original project: `/private/tmp/opentake-beta-qa-20260729/TalkingHeadQA.opentake` +- Save-As fixture: `/private/tmp/Task7NativeSaveAs-20260731.opentake` +- Host: macOS desktop package, exercised through the native accessibility tree + +This task covers both menu surfaces required by the source specification: the +complete native application menu (App/File/Edit/View/Help) and the cross-platform +in-app View menu. The two surfaces dispatch the same project, edit, media, and UI +actions; native enabled/checked/text state is synchronized from the owning stores. + +## Code and package gates + +- True focused RED before implementation: + `pnpm -C web test --run src/components/shell/ViewMenu.test.tsx -t "commands_shortcuts_checked_state_and_disabled_rules"` + failed because the planned owning test file did not exist. +- Focused GREEN after implementation: 1/1. +- Save-As action regression: 20/20, including atomic success, failed-publication + rollback, and compatibility-read-only rejection. +- Web regression: 73 files / 746 tests passed. +- Web production build passed. +- `cargo check -p opentake-tauri` passed. +- `./web/node_modules/.bin/tauri build --debug` passed and produced the app and + debug DMG under `target/debug/bundle/`. +- `cargo fmt --all -- --check` passed. +- `cargo test --workspace` passed on the final run. The preceding run had one + three-second readiness timeout in the mocked generation-provider smoke test; + that exact test passed in isolation and the immediate full rerun also passed, + identifying a timing flake rather than a persistent product failure. +- `git diff --check` passed. +- Tauri capability validation includes only the additional + `core:window:allow-set-fullscreen` permission required by the command. + +The plan-prescribed focused command contains an extra `--`; Vitest interprets +the trailing arguments as passthrough and runs the whole suite. It is retained +for plan parity, while the true focused form above proves that the named test +itself executed. + +## Native application menu matrix + +The packaged app exposed exactly five top-level groups and 32 specified entries: +App 4, File 6, Edit 10, View 8, Help 4. Update, Tutorial, and Feedback are visible, +explicitly labelled unavailable in Beta, and disabled rather than silently wired +to nonexistent behavior. + +| Context / group | Packaged result | +| --- | --- | +| Home / File | New and Open enabled; Save, Save As, Import Media, and Export disabled. `⌘N` opened the native Save dialog and `⌘O` opened the native Open dialog; both were cancelled without side effects. | +| Editor / File | All six entries enabled. Save As published the independent fixture atomically; it contains `project.json`, `media.json`, `thumbnail.jpg`, and the chat session. Its external media reference remained valid. Save then rewrote that fixture successfully. Import Media opened the native media picker and cancel restored the editor. Export opened the application export panel and cancel restored the editor. | +| Home / Edit | All ten entries disabled. | +| Editor / Edit, no selection | Undo/Redo, Cut/Copy/Paste, trims, and Delete disabled; Select All and Split enabled because the project contains clips. | +| Editor / Edit, selection | Select All selected all ten timeline items; Cut, Copy, both trims, and Delete became enabled. Copy was non-mutating and enabled Paste. Escape cleared the selection and returned those commands to the disabled state. | +| App | About, Settings, and Quit present; Check for Updates visible and disabled for Beta. Settings opened the General pane. Quit was not activated during verification. | +| Help | Keyboard Shortcuts and MCP Instructions enabled; Tutorial and Send Feedback visible and disabled for Beta. Both enabled commands opened their exact Settings panes, including an accessible shortcut table. | +| Runtime language | Switching Settings from Chinese to English immediately changed native group and item labels (`文件/编辑/视图/帮助` → `File/Edit/View/Help`) without restart; switching back restored Chinese. | + +The original `TalkingHeadQA.opentake` was never written by Save As. The app was +restarted after fixture verification and the Home recent list showed both the +original project and the new fixture, proving the canonical path changed only +after successful publication. + +## View command matrix + +The in-app menu opened with keyboard focus on Media Panel. Every item exposed +the specified accelerator and accessibility checked state. The same View entries +were present and enabled in the native View menu, including the three-item Layout +submenu. + +| Command | Pointer/native result | Shortcut result | Checked/result evidence | +| --- | --- | --- | --- | +| Media Panel | panel removed/restored through both in-app and native menus | `⌘0` removed it | state changed on → off → on | +| Inspector | panel removed/restored | `⌘⌥0` removed it | state changed on → off | +| Agent Panel | panel removed/restored | `⌘⌥A` removed it | title toggle and menu changed on → off | +| Maximize Focused Panel | timeline expanded to the editor body | backquote expanded/restored it | state changed off → on | +| Default Layout | default geometry restored | `⌘1` restored it | sole selected layout item | +| Media Layout | media geometry applied | `⌘2` applied it | selected state moved to Media | +| Vertical Layout | vertical geometry applied | `⌘3` applied it | selected state moved to Vertical | +| Enter Full Screen | macOS window entered/exited fullscreen | `⌘F` entered/exited it | traffic-light controls disappeared/reappeared | + +Down moved focus from the first enabled command to Inspector; Home/End and arrow +traversal are covered by the focused contract. Escape closed the menu, returned +focus to its trigger, and did not leak to the editor-wide maximize handler. + +The focused contract also proves the defensive rule that Maximize Focused Panel +is disabled when `focusedPanel === null`. Hiding a currently focused/maximized +collapsible panel moves focus to timeline and clears maximize. + +## Defects found and corrected during packaged validation + +1. The first implementation covered only the in-app View popover, while the + requirement literally called for the complete application/main menu. The + native 32-entry menu, shared command router, store synchronization, Settings + deep links, and Save-As action were added before the task was classified. +2. Escape originally reached both the menu dismissal handler and the global + editor handler, cancelling maximize. Capture-phase consumption now makes one + Escape perform one action. +3. Fullscreen initially failed because Tauri's window capability allowed reads + but not writes. The exact set permission was added and failure now produces a + localized recovery toast. +4. Native labels initially reflected only startup locale. Menu/submenu handles + now subscribe to the i18n store and update text immediately; packaged Chinese + → English → Chinese verification passed. +5. Self-review found that the first synchronization pass would resend every + native label on unrelated high-frequency UI changes such as playhead motion. + State and locale synchronization are now separate and snapshot-deduplicated; + the rebuilt package retained immediate language switching. + +## Final state + +- Application returned to Home with Chinese restored. +- Original and Save-As fixture both remained available in Recents. +- No destructive edit command was executed against the original project. +- Default layout, visible panels, non-maximized state, and non-fullscreen state + were restored before the final restart. diff --git a/docs/audit/2026-07-14/runtime-artifacts/automated/view-menu-real-device-2026-07-30.md b/docs/audit/2026-07-14/runtime-artifacts/automated/view-menu-real-device-2026-07-30.md new file mode 100644 index 00000000..5a4577b4 --- /dev/null +++ b/docs/audit/2026-07-14/runtime-artifacts/automated/view-menu-real-device-2026-07-30.md @@ -0,0 +1,25 @@ +# View menu control acceptance — 2026-07-30 + +Scope: Home Shell Task 16 (`implementation-slice-4a4cd45e11211989`) on the rebuilt arm64 macOS application at `target/debug/bundle/macos/OpenTake.app`, with `/tmp/opentake-beta-qa-20260729/TalkingHeadQA.opentake` open. + +## Code verification + +- The specified owning runner did not exist. `pnpm -C web exec vitest run src/components/shell/ViewMenu.interaction.test.tsx` therefore produced the expected RED result: `No test files found, exiting with code 1`. +- The plan's generated `pnpm -C web test -- --run ...` spelling was not used as focused evidence because Vitest treated the extra `--` as an argument boundary and ran the full suite. The direct Vitest spelling above is the verified focused command. +- Added exactly five candidate-named interaction tests covering menu toggle/Escape/outside dismissal, preset selection and close, and Agent/Media/Inspector panel toggles with persisted state and checked-state assertions. +- Focused result: 1 file, 5 tests passed. +- Regression result: 71 files, 726 tests passed. +- `pnpm -C web build` passed. Vite emitted only the existing dynamic-import and chunk-size warnings. + +## Real-device verification + +Computer Use exercised the packaged Tauri application through its accessibility surface: + +1. Opened `视图`; accessibility exposed a menu with `默认布局`, `媒体布局`, `竖屏布局`, `Agent 面板`, `媒体面板`, and `检查器`. +2. Selected `媒体布局`; the menu closed, the layout changed, and reopening reported `媒体布局 Value: 1` with the other presets at `0`. +3. Toggled Agent, Media, and Inspector while the menu stayed open. Each accessibility value changed from `1` to `0`, and the corresponding panel disappeared from the editor. +4. Pressed Escape; the menu closed and the editor accessibility tree returned. +5. Reopened the menu and clicked the preview outside it; the menu closed. +6. Restored `默认布局` and all three panels to `Value: 1`, then dismissed the menu. The final packaged-app screenshot and accessibility tree showed the original Agent + Media + Preview + Inspector arrangement. + +Result: PASS. All five planned controls behaved correctly in component tests and in the packaged macOS app. diff --git a/docs/audit/2026-07-14/upstream-downstream.md b/docs/audit/2026-07-14/upstream-downstream.md index da800682..73d4cd94 100644 --- a/docs/audit/2026-07-14/upstream-downstream.md +++ b/docs/audit/2026-07-14/upstream-downstream.md @@ -259,3 +259,5 @@ Integrated heads with an empty fork-main tree delta: **1**. | M | web/src/styles/global.css | integrated | integrated | Canonical UI or state delta associated with the earlier playback convergence work. | Audited target path web/src/styles/global.css at tree d992a0e3a7657d4d9b6ca66afb977433ca6b5e6a | none | none | true | `6cea8117201e65ac804350a267723a512ac666fcf6e4c2d210ef24990bbbb2e8` | regular | 2705 | `35105a71a4f9351dc28d2cb63b9308f5966b53a02609643b4cb5f20d35aeda3a` | Tracked patch hashes are computed from binary-safe `git diff HEAD -- ` bytes. Regular worktree files receive content hashes and byte counts; missing files and non-regular entries do not expose content. Symlinks, if present, expose only a hash of the link target bytes. + +Injected legacy reference: doc-00110a2e259a7a44 diff --git a/docs/audit/2026-08-02/beta-functional-verification.md b/docs/audit/2026-08-02/beta-functional-verification.md new file mode 100644 index 00000000..070ff0e9 --- /dev/null +++ b/docs/audit/2026-08-02/beta-functional-verification.md @@ -0,0 +1,234 @@ +# OpenTake Beta 功能验证记录(2026-08-02) + +状态:进行中。只有在代码门禁、原生界面逐项验证、布局/动效复测及候选包重装全部完成后,才会改为发布通过。 + +## 2026-08-03 进度快照 + +- **Web 门禁全绿**:129 个测试文件 / 1072 项测试全部通过(Vitest),`pnpm build` 通过(仅既有 large chunk / dynamic import 警告),`git diff --check` 通过。 +- **Rust 门禁验证(08-03 已修复 3 项,workspace 全量确认全绿)**: + - 前置:`cargo test --workspace` 需先 `scripts/provision_ffmpeg_sidecars.py` + provision FFmpeg sidecar(`src-tauri/binaries/` 被 gitignore),否则 Tauri build 脚本报 + `binaries/ffmpeg-* doesn't exist`。 + - 修复 1:`project_root::first_save_refuses_a_target_that_appears_after_staging` —— + publish 对 staging 后出现的目标 fail-closed 已生效,但错误消息未区分"存在性变化";现 + 目标存在性变化报 `existence changed`(`crates/opentake-project/src/project_root.rs`)。 + - 修复 2:`project_root::publish_never_deletes_a_foreign_bundle_rebound_at_backup_name` + —— **安全缺陷**:publish 清理 backup 前未复验其 identity,外来对象反弹到 backup 名会被 + 误删。现 backup 清理前复验 identity == 原 target,不匹配则 fail-closed 保留外来对象。 + - 修复 3:`mcp::inspect_project_media_reads_the_retained_bundle_after_path_rebind` —— + `inspect_source_media` 图片分支原按路径读取,bundle 路径反弹会读到反弹 bundle(新增 + 测试断言溢出暴露)。现新增 `AppCore::open_project_asset`(retained no-follow 打开)+ + `image_thumbnail_reader`,Project 源图片经 retained 权威读取,反弹路径不再重定向。 + - **最终结论**:`cargo test --workspace` 全量运行(含 ffmpeg/GPU/chromium 集成)全部通过, + 无 FAILED、无编译错误;opentake-project 234、opentake-media 419、opentake-tauri 492、 + opentake-ops 208 等目标全绿。此前观察的 2 次偶发失败 + (`recovery_refuses_an_unmarked_target...`、opentake-media 某用例)在独立/串行/全量复跑 + 中均稳定通过,判定为 workspace 并发偶发 flaky,收尾时持续关注。 +- **代码核查(08-03)确认以下曾列 finding 的实现已落地**,待独立 review 与候选包重装后关闭: + - MED 删除锁:`web/src/store/mediaDeleteActions.ts`(模块级删除锁 + 工程身份键,防跨工程污染)。 + - SET-04 原子身份租约:`crates/opentake-core/src/core.rs` 的 `OwnedUndoResult` / + `ProjectAssetAuthority` / `PreparedProjectOpen::is_current_namespace`,`opentake-agent` + `chat::ChatTurnGate` + mcp server 逐轮工程绑定会话。 + - MCP 返回脱敏:`crates/opentake-agent/tests/get_media_redaction.rs`(白名单 DTO,路径/签名 + URL/request id/prompt/hash 不外泄)。 + - 生命周期/交互补测:`web/src/App.lifecycle.test.tsx`、`CropOverlay.interaction` / + `TransformOverlay.interaction` / `SettingsView.interaction`、`api.pathExists` / + `api.searchIndex` / `asset` / `editActions.analysisIdentity` 等新增测试。 + - 安全加固:`src-tauri/src/safe_asset_protocol.rs`、`fs_availability.rs`、 + `crates/opentake-project/src/path_policy.rs`、`crates/opentake-media/src/process_tree.rs`。 +- **尚未完成(收尾清单)**:① Rust 全量测试终审结论;② 各未决 finding 的独立 review 清零; + ③ 候选包重装 + 真机 GUI 逐项验收(HOME/MED/TL/PV/IN/CAP/SMART/AG/MOT/EXP/SET 各批次); + ④ Windows exact-SHA CI 与实机验证;⑤ 独立 prerelease 标签发布与资产上传(需仓库所有者授权)。 + +## 验证对象 + +- 源码分支:`agent/advanced-ai-workflows` +- 验证起点:`c73c192f6fdc01e35a1711089b6e59edc3312309` +- 本机应用:`/Applications/OpenTake.app` +- 版本:`1.0.0-beta.1`(arm64) +- 应用主二进制 SHA-256:`03fdaaf023f9d9913a86fc43df7875e10b3a60c80a71fed8d00842135568e0da` +- 发布 DMG SHA-256:`32d97d35db7872253866b2427bd7da9816f365ce7bf9cb490d2b11a31c46cdfd` + +安装校验已完成:DMG、GitHub digest 与公开 `SHA256SUMS` 一致;`hdiutil verify` 通过;应用 `codesign --verify --deep --strict` 通过;FFmpeg/ffprobe sidecar 实际完成 1 秒 H.264 生成与探测。当前包为 ad-hoc hardened runtime,不是 Developer ID 签名/公证包,因此不能把本机 `spctl` 结果当作公证证明。 + +## 代码基线 + +初始候选的代码门禁: + +- Web:119 个测试文件、882 项测试通过。 +- Web 生产构建:通过;保留既有 large chunk / dynamic import 警告。 +- Rust:`cargo test --workspace` 通过;硬件 GPU/音频探针为测试声明中的显式 ignore。 + +生命周期第一轮修复后: + +- 聚焦:5 个测试文件、61 项测试通过。 +- Web:120 个测试文件、893 项测试通过。 +- `tsc -b && vite build`:通过。 +- 独立 review 尚确认 Save As 路径采用、启动重试和事件窗口仍有待修复项,因此 893/893 不是最终发布结论。 + +HOME / Save As 最终修复后: + +- Web:121 个测试文件、903 项测试通过。 +- `pnpm build`:通过;仅保留既有 large chunk / dynamic import 警告。 +- `git diff --check`:通过。 +- 独立 code reviewer:APPROVE,CRITICAL/HIGH/MEDIUM/LOW 均为 0。 + +布局第一轮及媒体/生命周期第二轮合并点(尚非发布冻结点): + +- Web:123 个测试文件、941 项测试通过。 +- `pnpm build`:通过;仅保留既有 dynamic import / large chunk 警告。 +- `git diff --check`:通过。 +- 后续独立终审又发现媒体跨工程删除、搜索事件身份及 timeline refresh owner + 协调缺口;这些 finding 正在补红测和整改。因此 941/941 只能证明该合并点,不能作为 + 当前工作树或候选包的最终通过证据。 + +## 功能矩阵 + +当前生产入口已拆成约 100 个原子功能,按以下批次执行: + +1. `PRJ` / `SHELL`:工程、Home、窗口和全局快捷键。 +2. `MED`:导入、文件夹、预览、搜索、收藏、全局素材库、代理与生成占位。 +3. `TL` / `PV`:时间线手势、预览、画布与原子撤销。 +4. `IN`:Inspector、关键帧、调色、Mask、AI 本地工作流和音频。 +5. `CAP` / `SMART`:字幕、翻译与 SmartPack。 +6. `AG` / `MOT`:Agent、MCP 工具与 Motion Canvas。 +7. `EXP`:视频、字幕与交换格式导出。 +8. `SET`:语言、外观、导入目录、Codex/ChatGPT 官方登录、BYOK、MCP、Account 与 About。 + +每个 mutation 用例附加四项 oracle:工程 epoch/path 防迟到写;一次用户提交对应一次 Rust `EditCommand`;取消/重试 identity 防旧结果提交;原生错误可见且状态不变。 + +## Agent 执行记录 + +### HOME-01 — Home 与工程生命周期 + +Agent:`/root/func_home_lifecycle`。 + +已通过:安装/版本、Home 主入口、三个样例、新建并立即落盘、自动保存与 `⌘S`、最近工程、Enter/双击、原生打开选择器、完全退出/重启恢复、关窗驻留/Dock 恢复、缺失路径、Finder/移除最近/废纸篓取消、版本更新弹窗、键盘 Tab 顺序和 `⌘N`/`⌘O`。 + +原始 QA 标记:`HOME01-SAVE-MARKER`;工程 `project.json` SHA-256 为 `f3530509abb81bec0fc273dae63c271c124b46f63d69fd8828148d5def1d49be`。 + +发现: + +- `HOME-BLOCK-01`(发布阻断):默认 `未命名.opentake` 已存在时,新建面板进入旧工程包内部,可能生成嵌套工程。 +- `H-17`(P3):中文首页项目数硬编码为 `{count} recent`。 + +修复与复验:默认工程名现在按 `未命名.opentake`、`未命名 2.opentake` 递增避让, +兼容 Windows/Unix 分隔符并在探测失败时安全回退父目录;最近项目数量已完整中英文国际化。 +Save As 会合并重复手势,同工程并发编辑后采用原生已提交路径但保留脏状态,跨工程迟到结果 +不会接管当前工程。上述实现已通过 903/903 全量测试和独立零 finding 审查,关闭 +`HOME-BLOCK-01` 与 `H-17`。 + +证据目录:`/private/tmp/opentake-home01-qa-20260802.8ADdfg/evidence`。 + +### 生命周期并发与监听 + +Agent:`/root/feature_matrix`;独立 code review 同 Agent 的后续只读轮次。 + +首轮修复:跨工程 Save As 迟到前端写入、timeline/media sync 首次失败后不可重试、迟到 listener 泄漏、Library 首次失败后不可重试。 + +Review 仍确认: + +- P1:同工程编辑期间 Save As 原生成功后,exact snapshot guard 会跳过 committed path,造成 native/store 路径分裂。 +- P1:App 启动注册失败被静默吞掉,当前挂载期间不重试。 +- P2:refresh 后 subscribe 存在丢事件窗口。 +- P2:async event handler rejection 可能未处理且 mirror 停留旧状态。 +- P3:旧 `go_home` callback 未检查 disposed;Library 测试泄漏 module-level `started`。 + +Save As 的 P1 项已在 HOME-01 最终实现中关闭。LIFE-02 已补 App 注册有界重试、 +refresh-register-refresh、handler rejection、迟到 listener 清理、离开 Editor 时同步停止 +DOM/transport/native playback,以及 Library stop/re-enter 的 request/lifecycle token。 + +第一次终审继续复现了 startup 无-floor refresh 取消已观察事件 floor、Library 旧请求迟到覆盖; +第二次终审继续复现了正常 `forceRefresh` 接替事件 refresh 后的虚假失败,以及 Media 同步恢复后 +旧错误不清。对应实现 Agent 已用 deferred 交错先得到 3 项失败,再实现共享 mirror refresh owner、 +最高 floor 重检/追赶及 media sync error owner channel;关联 5 个文件、94 项测试和生产构建通过。 +当前等待稳定工作树上的最终 code/TypeScript reviewer,未清零前仍不关闭 PRJ-07/08、SHELL-01。 + +### Media 交互 + +Agent:`/root/fix_media_interactions`。 + +范围:鼠标/键盘素材选择统一、Delete/menu 对可见选中生效、文件夹选择/Enter 可达、搜索清空与失败的 stale response 防护、异步 listener disposed 清理。 + +代码验证:红测先得到 2 个文件中 6 fail / 3 pass;第一轮修复后聚焦 2 个文件、13 项全部通过, +全量 Web 122 个文件、909 项全部通过,生产构建与 `git diff --check` 通过。误在 sibling +`OpenTake` 工作树产生的本任务 hunks 已用 `apply_patch` 精确撤销,并证明只保留该工作树原有 +preload 改动。 + +第一轮独立 review 提出 roving focus/Delete 错目标、Space 被 transport capture 抢占、Delete repeat、 +旧 index status rejection、ARIA/menu 焦点以及搜索右键共 6 项;整改后聚焦 3 个文件、33 项、 +共享全量 941 项及构建通过。最终 review 没有据此放行,又复现:Tab/直接 focus 卡片仍可删除隐藏 +selection、模块级删除锁跨工程污染、应用菜单 Delete 绕过统一事务、搜索 index progress 的跨工程 +事件、pointer 菜单 Escape 焦点,以及原 Space 集成测试停在 Home 导致捕获层未运行的假阳性。 +这些问题正在以 A deferred -> 切换 B、hidden A -> focus B -> Delete、Editor 普通 Space 对照组等 +行为测试返工;finding 清零前不关闭 MED 代码或原生验收。 + +### SET-04 — 官方 Codex CLI / ChatGPT + +2026-08-02 复核时本机已是 `codex-cli 0.146.0`,`codex login status` 实际返回 +`Logged in using ChatGPT`。 +使用应用代码同款 `codex exec --json --ephemeral --ignore-user-config --ignore-rules +--sandbox read-only` 与唯一 `opentake` MCP 配置完成一次真实只读调用;`get_timeline` +返回当前工程 `1280x720`、`30 fps`、2 条轨道,进程退出码 0,工程未变更。 + +首次调用仍注入用户技能/Agent 描述,消耗约 100,009 input tokens。增加 +`skills.include_instructions=false` 及关闭 multi-agent/plugins/shell/apps/browser/computer-use +等无关能力后,同一调用降至 36,310 input tokens,且仍只使用 `opentake.get_timeline` 成功。 +用户 Agent role TOML 与本地 model cache 的诊断仍会由 CLI 输出;正式集成需要在候选包中固定 +最小能力参数并再次完成一项可撤销的真实 MCP 编辑,不能把当前只读成功当作 SET-04 最终通过。 + +按当前官方 Codex 手册重新核对后,又执行了一次 `--strict-config` 最小能力实测。第一轮严格校验 +以退出码 1 拒绝了当前 CLI 尚不识别的 `tools.view_image`(虽然当前在线配置参考已列出该字段), +证明参数不能只按文档猜测;移除该字段后的相同调用退出码 0,只出现 +`opentake.get_timeline` 一次 started/completed,返回 `1920x1080`、`30/1 fps`、0 轨道,usage 为 +38,623 input / 24,064 cached input / 210 output / 59 reasoning tokens。JSONL 还含 6 条用户 agent role +格式诊断,stderr 含 4 条本地 model manager timeout,但均未成为 turn failure,应用现有 parser 也不会 +把它们显示成工具失败。 + +代码审计同时确认现有应用内 Codex 仍连接无工程身份的全局 MCP URL。对话启动虽校验 epoch/path 并 +在切工程时请求取消外部进程,但迟到或在途 HTTP tool call 还缺少与 `ProjectTurnGate` 等价的原子身份 +租约。正式方案将为每个应用内 Codex turn 创建临时、工程绑定的 loopback MCP 会话,并在切换工程时 +拒绝/取消迟到写;该竞态测试、最小能力参数及一项可撤销真实编辑全部通过前,SET-04 保持未关闭。 + +## 布局与可用性实测 + +浏览器仅用于可重复的 DOM/尺寸诊断;不能替代 Tauri 桌面成功证据。 + +已测视口:1200×800、1024×700、800×600,以及应用声明的最小窗口 760×480。 + +通过:Home 与 Library 在所有上述尺寸无 body overflow;800×600 Settings 的主要内容可操作;Codex/ChatGPT provider 入口、缺 CLI 状态和 disabled 登录按钮语义清晰。 + +LAYOUT-01 已修复并经三轮独立 code review 清零:Settings 在 760×480 为 720×440,内容与侧栏 +独立滚动,具备 dialog 语义、初始 Shift+Tab/Tab focus trap、嵌套 Dropdown 第一次 Escape 仅关闭 +listbox 并回焦 trigger、第二次 Escape 关闭 Settings 并恢复入口焦点;三栏组合最小宽度逐层传播, +Default + Agent 在 760px 的实测宽度为 Agent 240 / Media 160 / Preview 200 / Inspector 160。 +不足以容纳的 Media preset 会保留 Agent mounted subtree/draft 后自适应折叠,并安全移交焦点;800px +恢复时同一节点和状态仍在。Library 搜索输入为真实 26px target 并具 search 语义。 + +验证:聚焦 5 个文件、20 项;共享全量 123/941;生产构建和 `git diff --check` 均通过。Chromium +实测上述键盘流、live resize、节点身份与 exact widths;应用 console 0 error(仅 favicon 404)。 +截图:`output/playwright/layout-01/reviewer-fixes/editor-agent-760x480-final.png`、 +`editor-media-agent-folded-760x480.png`。 + +Accessibility / motion 独立验收已关闭后续 4 个 HIGH:Editor 紧凑控件与轨道高度拖拽均具备 +至少 24px 命中区;SplitPane 采用不占布局、且不遮挡缝边按钮的 24px effective band;Export 与 +Project Settings 具备初始焦点、双向 focus trap、Escape 与入口焦点恢复;Home / Library / +Editor 保活节点、local state 与滚动位置,隐藏 Home 不再处理 Enter/Escape。真实 Chromium 在 +760×480 证明无全局 overflow、无 24px 黑色分隔带,空白分隔缝可鼠标拖拽且三处分隔栏均可由 +Tab 和方向键操作;focused 13 个文件、94 项测试通过。该范围独立 reviewer 结论为 APPROVE。 + +当前共享树的 Web 全量与 `tsc -b` 仍因时间线原子手势重构的落盘中间态失败;上述范围通过不 +替代最终稳定树上的 full / typecheck / build,也不会据此提前放行候选包。 + +截图位于 `output/playwright/`;最终候选包将重新生成对应 before/after 证据。 + +## 外部条件 + +以下不允许伪造成功: + +- 真实付费生成/翻译/Avatar/Voice Clone 需要 provider 凭据与明确费用授权;无凭据时只验证 fail-closed。 +- Windows 原生 UI 必须在真实 Windows 交互环境验证;本机 macOS 结果不能替代。 +- macOS Developer ID 与 notarization 需要发布凭据;当前 ad-hoc 包只可作为本机 Beta 使用包。 +- GitHub 上 `v1.0.0-beta.1` 已发布为 prerelease,目标提交为起点 `c73c192...`;当前本机 + `gh` keyring token 已失效。修复后候选仍可本机构建、重装和验收,但上传新资产或发布后继 + prerelease 前必须由仓库所有者重新授权,不能伪造上传成功。 diff --git a/docs/audit/2026-08-07/editor-core-after-fix-assets/01-home-cover-fixed.png b/docs/audit/2026-08-07/editor-core-after-fix-assets/01-home-cover-fixed.png new file mode 100644 index 00000000..a3f285d5 Binary files /dev/null and b/docs/audit/2026-08-07/editor-core-after-fix-assets/01-home-cover-fixed.png differ diff --git a/docs/audit/2026-08-07/editor-core-after-fix-assets/02-reopen-thumbnail-timeline-fixed.png b/docs/audit/2026-08-07/editor-core-after-fix-assets/02-reopen-thumbnail-timeline-fixed.png new file mode 100644 index 00000000..a2f184d9 Binary files /dev/null and b/docs/audit/2026-08-07/editor-core-after-fix-assets/02-reopen-thumbnail-timeline-fixed.png differ diff --git a/docs/audit/2026-08-07/editor-core-after-fix-assets/03-source-preview-fixed.png b/docs/audit/2026-08-07/editor-core-after-fix-assets/03-source-preview-fixed.png new file mode 100644 index 00000000..b59b37db Binary files /dev/null and b/docs/audit/2026-08-07/editor-core-after-fix-assets/03-source-preview-fixed.png differ diff --git a/docs/audit/2026-08-07/editor-core-after-fix-assets/04-single-track-playback-progress-frame-220.png b/docs/audit/2026-08-07/editor-core-after-fix-assets/04-single-track-playback-progress-frame-220.png new file mode 100644 index 00000000..86b5984b Binary files /dev/null and b/docs/audit/2026-08-07/editor-core-after-fix-assets/04-single-track-playback-progress-frame-220.png differ diff --git a/docs/audit/2026-08-07/editor-core-after-fix-assets/06-single-track-seek-midpoint.png b/docs/audit/2026-08-07/editor-core-after-fix-assets/06-single-track-seek-midpoint.png new file mode 100644 index 00000000..b42a898f Binary files /dev/null and b/docs/audit/2026-08-07/editor-core-after-fix-assets/06-single-track-seek-midpoint.png differ diff --git a/docs/audit/2026-08-07/editor-core-after-fix-assets/07-single-track-pause-stable.png b/docs/audit/2026-08-07/editor-core-after-fix-assets/07-single-track-pause-stable.png new file mode 100644 index 00000000..8dae4db4 Binary files /dev/null and b/docs/audit/2026-08-07/editor-core-after-fix-assets/07-single-track-pause-stable.png differ diff --git a/docs/audit/2026-08-07/editor-core-after-fix-assets/08-dual-track-composite-stable.png b/docs/audit/2026-08-07/editor-core-after-fix-assets/08-dual-track-composite-stable.png new file mode 100644 index 00000000..92f78d49 Binary files /dev/null and b/docs/audit/2026-08-07/editor-core-after-fix-assets/08-dual-track-composite-stable.png differ diff --git a/docs/audit/2026-08-07/editor-core-after-fix-assets/09-dual-track-playback-frame-239.png b/docs/audit/2026-08-07/editor-core-after-fix-assets/09-dual-track-playback-frame-239.png new file mode 100644 index 00000000..88944d7f Binary files /dev/null and b/docs/audit/2026-08-07/editor-core-after-fix-assets/09-dual-track-playback-frame-239.png differ diff --git a/docs/audit/2026-08-07/editor-core-after-fix-assets/10-dual-track-playback-frame-48.png b/docs/audit/2026-08-07/editor-core-after-fix-assets/10-dual-track-playback-frame-48.png new file mode 100644 index 00000000..8dd0a74a Binary files /dev/null and b/docs/audit/2026-08-07/editor-core-after-fix-assets/10-dual-track-playback-frame-48.png differ diff --git a/docs/audit/2026-08-07/editor-core-after-fix-assets/11-dual-track-seek-pause-frame-115.png b/docs/audit/2026-08-07/editor-core-after-fix-assets/11-dual-track-seek-pause-frame-115.png new file mode 100644 index 00000000..a5c4045f Binary files /dev/null and b/docs/audit/2026-08-07/editor-core-after-fix-assets/11-dual-track-seek-pause-frame-115.png differ diff --git a/docs/audit/2026-08-07/editor-core-after-fix-assets/12-export-dialog-contrast.png b/docs/audit/2026-08-07/editor-core-after-fix-assets/12-export-dialog-contrast.png new file mode 100644 index 00000000..f03f1e75 Binary files /dev/null and b/docs/audit/2026-08-07/editor-core-after-fix-assets/12-export-dialog-contrast.png differ diff --git a/docs/audit/2026-08-07/editor-core-after-fix-assets/13-export-keyboard-focus.png b/docs/audit/2026-08-07/editor-core-after-fix-assets/13-export-keyboard-focus.png new file mode 100644 index 00000000..c39160d2 Binary files /dev/null and b/docs/audit/2026-08-07/editor-core-after-fix-assets/13-export-keyboard-focus.png differ diff --git a/docs/audit/2026-08-07/editor-core-after-fix-assets/14-gui-export-completed.png b/docs/audit/2026-08-07/editor-core-after-fix-assets/14-gui-export-completed.png new file mode 100644 index 00000000..12d6f6cd Binary files /dev/null and b/docs/audit/2026-08-07/editor-core-after-fix-assets/14-gui-export-completed.png differ diff --git a/docs/audit/2026-08-07/editor-core-after-fix-assets/15-gui-export-frame-2s.png b/docs/audit/2026-08-07/editor-core-after-fix-assets/15-gui-export-frame-2s.png new file mode 100644 index 00000000..f9583b67 Binary files /dev/null and b/docs/audit/2026-08-07/editor-core-after-fix-assets/15-gui-export-frame-2s.png differ diff --git a/docs/audit/2026-08-07/editor-core-after-fix-assets/16-relaunch-home-cover-dual.png b/docs/audit/2026-08-07/editor-core-after-fix-assets/16-relaunch-home-cover-dual.png new file mode 100644 index 00000000..c0ced090 Binary files /dev/null and b/docs/audit/2026-08-07/editor-core-after-fix-assets/16-relaunch-home-cover-dual.png differ diff --git a/docs/audit/2026-08-07/editor-core-after-fix-assets/17-relaunch-editor-thumbnails-composite.png b/docs/audit/2026-08-07/editor-core-after-fix-assets/17-relaunch-editor-thumbnails-composite.png new file mode 100644 index 00000000..7ffe53ec Binary files /dev/null and b/docs/audit/2026-08-07/editor-core-after-fix-assets/17-relaunch-editor-thumbnails-composite.png differ diff --git a/docs/audit/2026-08-07/editor-core-after-fix-assets/18-relaunch-source-preview.png b/docs/audit/2026-08-07/editor-core-after-fix-assets/18-relaunch-source-preview.png new file mode 100644 index 00000000..03028935 Binary files /dev/null and b/docs/audit/2026-08-07/editor-core-after-fix-assets/18-relaunch-source-preview.png differ diff --git a/docs/audit/2026-08-07/editor-core-after-fix-assets/19-library-keyboard-action-focus.png b/docs/audit/2026-08-07/editor-core-after-fix-assets/19-library-keyboard-action-focus.png new file mode 100644 index 00000000..6d2c404a Binary files /dev/null and b/docs/audit/2026-08-07/editor-core-after-fix-assets/19-library-keyboard-action-focus.png differ diff --git a/docs/audit/2026-08-07/editor-core-after-fix-assets/README.md b/docs/audit/2026-08-07/editor-core-after-fix-assets/README.md new file mode 100644 index 00000000..648e6a0f --- /dev/null +++ b/docs/audit/2026-08-07/editor-core-after-fix-assets/README.md @@ -0,0 +1,62 @@ +# 正式包修复后 GUI 证据 + +验证日期:2026-08-08(Asia/Shanghai)。所有 UI 操作只针对 `/Applications/OpenTake.app`;未启动 Vite、Tauri dev 或旧备份实例。 + +## 截图索引 + +| 文件 | 证明内容 | +|---|---| +| `01-home-cover-fixed.png` | 单轨工程 Home 封面不再为空。 | +| `02-reopen-thumbnail-timeline-fixed.png` | 单轨工程重开后素材缩略图与时间线预览可见。 | +| `03-source-preview-fixed.png` | 选中源素材并稳定等待 5 秒后,源预览可见。 | +| `04-single-track-playback-progress-frame-220.png` | 4K60 单轨播放推进到 frame 220。 | +| `06-single-track-seek-midpoint.png` | 坐标 seek 到 frame 115,画面与播放头同步变化。 | +| `07-single-track-pause-stable.png` | 单轨暂停在 frame 187;2.2 秒后仍为 187。 | +| `08-dual-track-composite-stable.png` | fresh 双轨工程稳定 15 秒后,两素材缩略图与合成预览正常。 | +| `09-dual-track-playback-frame-239.png` | 双轨播放从 0 连续推进到 terminal frame 239。 | +| `10-dual-track-playback-frame-48.png` | 双轨第二轮从 0 推进到 frame 48,复合画面变化。 | +| `11-dual-track-seek-pause-frame-115.png` | 双轨 seek 到 frame 115;暂停后立即与 2 秒后均为 115。 | +| `12-export-dialog-contrast.png` | Export 对话框与深色主按钮文字;此图为 4K 初始选项。 | +| `13-export-keyboard-focus.png` | 720p 已选,Tab 焦点明确落在主“导出”按钮。 | +| `14-gui-export-completed.png` | GUI 导出结束后返回双轨编辑器。 | +| `15-gui-export-frame-2s.png` | GUI 产物 2 秒真实抽帧,显示双轨复合结果。 | +| `16-relaunch-home-cover-dual.png` | 完整退出再启动后,双轨与单轨 Home 封面均可见。 | +| `17-relaunch-editor-thumbnails-composite.png` | 重启后重开双轨工程、稳定 15 秒,两缩略图与合成预览仍正常。 | +| `18-relaunch-source-preview.png` | 重启重开后选中源、等待 5 秒,源预览仍正常。 | +| `19-library-keyboard-action-focus.png` | 零 hover 开始的 Tab 路径可到达 Library 三个卡片操作;截图为第二卡“取消收藏”焦点。 | + +Computer Use 服务原始捕获实际是 JPEG 字节,即使最初文件名后缀为 `.png`。原始字节已移动到 `diagnostics/original-computer-use-jpeg/`,再由 macOS `sips -s format png` 无损解码并重新编码为正式 PNG。最终 01–19 的每个 `.png` 都通过 `file` 验收并由 `view_image` 逐一目视复核。 + +`diagnostics/05-terminal-reset-after-pause-race.jpg` 是在 terminal reset 竞态后截到的 frame 0,不能证明暂停;`diagnostics/06-ax-set-value-ignored.jpg` 证明 AX slider setter 被前端忽略,不能证明 seek。二者因此不进入正式编号序列;有效 seek 与暂停分别采用 06、07 和 11。 + +## 双轨 fixture + +- 工程:`gui-validation-dual.opentake/` +- 画布:3840×2160、60 fps、240 frames / 4.000 s。 +- V1:`audit-4k60-h264.mp4`,H.264、3840×2160、60/1、4.000 s,frames 0–239。 +- V2:`audit-4k60-h264-hflip.mp4`,不同的 hflip 源,H.264、3840×2160、60/1、4.000 s,frames 0–239;SHA-256 `fca06a45eec96ac5b4c3618aa8bb0fca409e49eb2374f3268237c85a4c478fc8`。 +- V2 使用 0.8 opacity、0.42×0.42、center (0.75, 0.72),因此合成结果可由右下嵌套色条明确辨认。 +- 历史 `/Users/lvbaiqing/Documents/OpenTake/未命名 2.opentake` 的两个源均指向已离线 `/Volumes/mac/...`,故只记为 fixture BLOCKED,不用于判断正式包播放回归。 + +## GUI 导出与资源采样 + +- 产物:`gui-export-after-fix-720p.mp4`,SHA-256 `6ac341e9e8f89be91f65063fa21e65d296d8aed861d3f3b04939cc0a9c63824f`。 +- ffprobe:H.264、yuv420p、BT.709、1280×720、60/1 fps、240 frames、4.000000 s、单视频流、489,960 bytes。 +- `15-gui-export-frame-2s.png`:1280×720 真 PNG;SHA-256 `8b4b67914ff2f73f5fea1a53d60604eaab211961b4803e09f69fc1d7eb07d400`。 +- 双轨播放 24 次采样:OpenTake + 当次 WebKit 进程族各进程 RSS 峰值之和约 541 MB(约 528 MiB);FFmpeg/ffprobe 子进程始终 0。`ps` 瞬时 CPU 栏均为 0.0,不能当作精细 CPU profile。 +- GUI 导出采样捕获 1 个持续 encoder 与逐帧短 helper/ffprobe;观察窗口内同时可见 encoder + 1 helper,未出现无界并发。峰值示例为 OpenTake 2.2% CPU、WebContent 0.4% CPU。 +- 原始采样:`dual-playback-active-process-sample.txt`、`gui-export-resource-sample.txt`;解析结果不外推到 100 项冷缓存压力工程。 + +## 真实设备 A/V 播放 + +- 当前默认输出为 MacBook Air 扬声器,2 channels / 96 kHz。 +- 使用 mktemp 中的 10 秒真实 A/V fixture:H.264 1584×1080@30 动态红/蓝画面 + AAC 48 kHz stereo 880 Hz sine。 +- `OPENTAKE_REQUIRE_AUDIO_CALLBACK=1` 禁止 wall-clock fallback;安全 fixture 单次 PASS 后再连续三次 PASS:frames/playhead 分别为 85/91、84/90、84/89,全部 `clock=audio`,nonblack 1.000、neon green 0.000。 +- 首个 testsrc2 fixture 已经得到 `clock=audio`,但它自带 16.2% 纯绿色而误撞绿屏哨兵;失败日志保留,换不含 neon green 的动态 fixture 后稳定通过。 +- 完整方法、fixture hash、strict callback 语义和三份日志见 [playback-av-real-device.md](playback-av-real-device.md)。该证据把核心 A/V 设备/时钟集成提升为 PASS,但不外推主观可听响度或长时多轨 drift。 + +## 权限与限制 + +- 首次打开、退出重启、工程重开、源预览和导出均未出现新的文件/媒体/应用权限弹窗,也未更改任何 macOS 安全设置。 +- 本包为 ad-hoc 签名且未 notarize;`codesign --verify --deep --strict` 通过,Gatekeeper `spctl` 拒绝是该分发形态的预期限制。 +- 未覆盖扬声器麦克风 loopback、长时音频双轨 A/V drift、VoiceOver、200%/400% 缩放、100 项冷缓存峰值;这些边界不改写本次视频主链、真实设备核心 A/V 与确认缺陷的通过结论。 diff --git a/docs/audit/2026-08-07/editor-core-after-fix-assets/diagnostics/05-terminal-reset-after-pause-race.jpg b/docs/audit/2026-08-07/editor-core-after-fix-assets/diagnostics/05-terminal-reset-after-pause-race.jpg new file mode 100644 index 00000000..3e7e93fd Binary files /dev/null and b/docs/audit/2026-08-07/editor-core-after-fix-assets/diagnostics/05-terminal-reset-after-pause-race.jpg differ diff --git a/docs/audit/2026-08-07/editor-core-after-fix-assets/diagnostics/06-ax-set-value-ignored.jpg b/docs/audit/2026-08-07/editor-core-after-fix-assets/diagnostics/06-ax-set-value-ignored.jpg new file mode 100644 index 00000000..b167b674 Binary files /dev/null and b/docs/audit/2026-08-07/editor-core-after-fix-assets/diagnostics/06-ax-set-value-ignored.jpg differ diff --git a/docs/audit/2026-08-07/editor-core-after-fix-assets/gui-export-after-fix-720p.ffprobe.json b/docs/audit/2026-08-07/editor-core-after-fix-assets/gui-export-after-fix-720p.ffprobe.json new file mode 100644 index 00000000..38db41e3 --- /dev/null +++ b/docs/audit/2026-08-07/editor-core-after-fix-assets/gui-export-after-fix-720p.ffprobe.json @@ -0,0 +1,88 @@ +{ + "streams": [ + { + "index": 0, + "codec_name": "h264", + "codec_long_name": "H.264 / AVC / MPEG-4 AVC / MPEG-4 part 10", + "profile": "High", + "codec_type": "video", + "codec_tag_string": "avc1", + "codec_tag": "0x31637661", + "width": 1280, + "height": 720, + "coded_width": 1280, + "coded_height": 720, + "closed_captions": 0, + "film_grain": 0, + "has_b_frames": 2, + "pix_fmt": "yuv420p", + "level": 32, + "color_range": "tv", + "color_space": "bt709", + "color_transfer": "bt709", + "color_primaries": "bt709", + "chroma_location": "left", + "field_order": "progressive", + "refs": 1, + "is_avc": "true", + "nal_length_size": "4", + "id": "0x1", + "r_frame_rate": "60/1", + "avg_frame_rate": "60/1", + "time_base": "1/15360", + "start_pts": 0, + "start_time": "0.000000", + "duration_ts": 61440, + "duration": "4.000000", + "bit_rate": "972850", + "bits_per_raw_sample": "8", + "nb_frames": "240", + "extradata_size": 48, + "disposition": { + "default": 1, + "dub": 0, + "original": 0, + "comment": 0, + "lyrics": 0, + "karaoke": 0, + "forced": 0, + "hearing_impaired": 0, + "visual_impaired": 0, + "clean_effects": 0, + "attached_pic": 0, + "timed_thumbnails": 0, + "non_diegetic": 0, + "captions": 0, + "descriptions": 0, + "metadata": 0, + "dependent": 0, + "still_image": 0 + }, + "tags": { + "language": "und", + "handler_name": "VideoHandler", + "vendor_id": "[0][0][0][0]", + "encoder": "Lavc61.3.100 libx264" + } + } + ], + "format": { + "filename": "docs/audit/2026-08-07/editor-core-after-fix-assets/gui-export-after-fix-720p.mp4", + "nb_streams": 1, + "nb_programs": 0, + "nb_stream_groups": 0, + "format_name": "mov,mp4,m4a,3gp,3g2,mj2", + "format_long_name": "QuickTime / MOV", + "start_time": "0.000000", + "duration": "4.000000", + "size": "489960", + "bit_rate": "979920", + "probe_score": 100, + "tags": { + "major_brand": "isom", + "minor_version": "512", + "compatible_brands": "isomiso2avc1mp41", + "encoder": "Lavf61.1.100" + } + } +} diff --git a/docs/audit/2026-08-07/editor-core-after-fix-assets/gui-validation-dual.opentake/media.json b/docs/audit/2026-08-07/editor-core-after-fix-assets/gui-validation-dual.opentake/media.json new file mode 100644 index 00000000..19fdc677 --- /dev/null +++ b/docs/audit/2026-08-07/editor-core-after-fix-assets/gui-validation-dual.opentake/media.json @@ -0,0 +1,36 @@ +{ + "version": 2, + "entries": [ + { + "id": "audit-dual-source-a", + "name": "audit-4k60-source-a", + "type": "video", + "source": { + "external": { + "absolutePath": "/Users/lvbaiqing/TRUE 开发/PRIMARY-CN/OpenTake-generation/docs/audit/2026-08-07/editor-core-validation-evidence-a/artifacts/audit-4k60-h264.mp4" + } + }, + "duration": 4.0, + "sourceWidth": 3840, + "sourceHeight": 2160, + "sourceFPS": 60.0, + "hasAudio": false + }, + { + "id": "audit-dual-source-b", + "name": "audit-4k60-source-b-hflip", + "type": "video", + "source": { + "external": { + "absolutePath": "/Users/lvbaiqing/TRUE 开发/PRIMARY-CN/OpenTake-generation/docs/audit/2026-08-07/editor-core-after-fix-assets/audit-4k60-h264-hflip.mp4" + } + }, + "duration": 4.0, + "sourceWidth": 3840, + "sourceHeight": 2160, + "sourceFPS": 60.0, + "hasAudio": false + } + ], + "folders": [] +} diff --git a/docs/audit/2026-08-07/editor-core-after-fix-assets/gui-validation-dual.opentake/project.json b/docs/audit/2026-08-07/editor-core-after-fix-assets/gui-validation-dual.opentake/project.json new file mode 100644 index 00000000..c91c2ccb --- /dev/null +++ b/docs/audit/2026-08-07/editor-core-after-fix-assets/gui-validation-dual.opentake/project.json @@ -0,0 +1,90 @@ +{ + "fps": 60, + "width": 3840, + "height": 2160, + "settingsConfigured": true, + "tracks": [ + { + "id": "audit-dual-v1", + "type": "video", + "muted": false, + "hidden": false, + "syncLocked": true, + "clips": [ + { + "id": "audit-dual-v1-clip", + "mediaRef": "audit-dual-source-a", + "mediaType": "video", + "sourceClipType": "video", + "startFrame": 0, + "durationFrames": 240, + "trimStartFrame": 0, + "trimEndFrame": 0, + "speed": 1.0, + "volume": 1.0, + "fadeInFrames": 0, + "fadeOutFrames": 0, + "fadeInInterpolation": "linear", + "fadeOutInterpolation": "linear", + "opacity": 1.0, + "transform": { + "centerX": 0.5, + "centerY": 0.5, + "width": 1.0, + "height": 1.0, + "rotation": 0.0, + "flipHorizontal": false, + "flipVertical": false + }, + "crop": { + "left": 0.0, + "top": 0.0, + "right": 0.0, + "bottom": 0.0 + } + } + ] + }, + { + "id": "audit-dual-v2", + "type": "video", + "muted": false, + "hidden": false, + "syncLocked": true, + "clips": [ + { + "id": "audit-dual-v2-clip", + "mediaRef": "audit-dual-source-b", + "mediaType": "video", + "sourceClipType": "video", + "startFrame": 0, + "durationFrames": 240, + "trimStartFrame": 0, + "trimEndFrame": 0, + "speed": 1.0, + "volume": 1.0, + "fadeInFrames": 0, + "fadeOutFrames": 0, + "fadeInInterpolation": "linear", + "fadeOutInterpolation": "linear", + "opacity": 0.8, + "transform": { + "centerX": 0.75, + "centerY": 0.72, + "width": 0.42, + "height": 0.42, + "rotation": 0.0, + "flipHorizontal": false, + "flipVertical": false + }, + "crop": { + "left": 0.0, + "top": 0.0, + "right": 0.0, + "bottom": 0.0 + } + } + ] + } + ] +} diff --git a/docs/audit/2026-08-07/editor-core-after-fix-assets/gui-validation-dual.opentake/thumbnail.jpg b/docs/audit/2026-08-07/editor-core-after-fix-assets/gui-validation-dual.opentake/thumbnail.jpg new file mode 100644 index 00000000..38bb785e Binary files /dev/null and b/docs/audit/2026-08-07/editor-core-after-fix-assets/gui-validation-dual.opentake/thumbnail.jpg differ diff --git a/docs/audit/2026-08-07/editor-core-after-fix-assets/installation-ledger.md b/docs/audit/2026-08-07/editor-core-after-fix-assets/installation-ledger.md new file mode 100644 index 00000000..c18fc38f --- /dev/null +++ b/docs/audit/2026-08-07/editor-core-after-fix-assets/installation-ledger.md @@ -0,0 +1,75 @@ +# OpenTake 1.0.0-beta.2 正式构建与安装账本 + +日期:2026-08-08(Asia/Shanghai)。工作树保持未提交;本账本不代表发布到公证/商店渠道。 + +## 构建 + +最终命令: + +```sh +./web/node_modules/.bin/tauri build --bundles app,dmg \ + --config '{"bundle":{"macOS":{"signingIdentity":"-"}}}' +``` + +plain Tauri build 生成的首个 bundle 没有完整 bundle seal,`codesign --verify --deep --strict` 失败,因此从未安装;它保留在 `/tmp/OpenTake-unsigned-bundle-20260808-130918` 供诊断。用 Tauri 的 ad-hoc signing 配置重建后,main、ffmpeg、ffprobe 与 app bundle 都由打包流程签名。 + +- App:`target/release/bundle/macos/OpenTake.app` +- DMG:`target/release/bundle/dmg/OpenTake_1.0.0-beta.2_aarch64.dmg` +- DMG SHA-256:`bf9eb8ff32eb202fb4150fa7e6682222e11e8d05a3e9170ef8636f4ab2953aab` +- bundle 相对内容树 digest:`247d874c37b772ba6a5b86875e9e57880fc0c0becf3aff4728073a504f316815` +- bundle ID / version / minimum macOS:`com.opentake.desktop` / `1.0.0-beta.2` / `11.0` +- 架构:Mach-O arm64。 + +锁文件验证的签名前 sidecar:FFmpeg/ffprobe 7.0,GPL、无 nonfree;SHA-256 分别为 `326895b16940f238d76e902fc71150f10c388c281985756f9850ff800a2f1499` 与 `307e09bc01bd72bde5f441a1a6df68769da3b2b6e431accfbfc9cf3893ad00c4`。Tauri 签名会改变 sidecar 文件字节;签名后 hash 如下: + +- `opentake`: `27a55587612dc70b7f2d60740b0c80c75065d53208385bb4cc38d3da5619c327` +- `ffmpeg`: `a83e9395c338b9e759cfba5b797e4206e60d46572c05b7467d2a33e30f53fcc3` +- `ffprobe`: `8a5ff4c6b60ce86cc6e4c7b0bd026aa7a6e91ebfb7748dbbcae68b6ef1d04739` + +build app 的 `codesign --verify --deep --strict --verbose=4` 通过。DMG 以 readonly fresh mount 验证,内含 app 同样 strict/deep 通过,且 `diff -rq` 与 build app 无输出;验证后正常卸载。 + +签名属性为 `Signature=adhoc`、`TeamIdentifier=not set`、sealed resources v2。没有 Apple credentials,因此 Tauri 明确跳过 notarization;`spctl` 不接受该包是预期事实,不能描述为已公证发行。 + +## 可恢复安装 + +1. 先把 build app 复制到 `/Applications/.OpenTake.app.install-20260808-131143`,在 staging 上完成 strict/deep 和内容 hash 验证。 +2. 原 `/Applications/OpenTake.app` 移动到 `/Applications/OpenTake.app.backup-20260808-131143`,没有删除。 +3. staging 原子改名为 `/Applications/OpenTake.app`。 +4. installed app 再次 strict/deep 通过;`diff -rq target/release/bundle/macos/OpenTake.app /Applications/OpenTake.app` 无输出。 +5. installed main/ffmpeg/ffprobe hash 与 build app 上述三个 hash逐项相同。 + +旧包 main hash 为 `139f4749e1e6bc35b13f980c4b36353dd211be4b1a10f784435d875ce3d4d78a`;完整备份仍存在。第一次正式启动 PID 24234;完成 GUI 导出后 Cmd+Q 确认进程退出,再次只从 `/Applications/OpenTake.app` 启动,复验时 PID 52325。 + +## Fresh GUI 结论 + +- Home 封面、工程重开素材缩略图、源预览、时间线复合预览:PASS。 +- 4K60 单轨播放/seek/pause:PASS。 +- 4K60 fresh 双轨播放 0→239、再次 0→48、seek/pause:PASS。 +- GUI H.264 720p 导出 + ffprobe + 2 秒视觉帧:PASS。 +- Export 主按钮对比度/键盘焦点、Library 三个操作的零 hover Tab 路径:PASS。 +- 完整退出后重启与工程重开授权:PASS;无新增权限弹窗。 +- 未启动 dev/Vite 或额外 OpenTake bundle;验证结束时只有 installed 正式实例。 + +## 2026-08-08 19:36 安全收口后的最终重构建 + +安全终审关闭 ProjectMedia manifest 扩权与 Windows native-import 空解析问题后,旧候选包不再作为最终制品。主线程从已清理的 Cargo target 使用以下命令重新构建: + +```sh +./web/node_modules/.bin/tauri build --ci \ + --target aarch64-apple-darwin --bundles app,dmg \ + --config '{"bundle":{"macOS":{"signingIdentity":"-"}}}' +``` + +- App:`target/aarch64-apple-darwin/release/bundle/macos/OpenTake.app` +- DMG:`target/aarch64-apple-darwin/release/bundle/dmg/OpenTake_1.0.0-beta.2_aarch64.dmg` +- DMG SHA-256:`01ee3c5a468253fc449083ab3cf7ec62dc110f6c85ea8055185a573a0ae3ab45` +- bundle 相对内容树 digest:`68e7da55ce9140cb93f995dab3fe20de8c53cd574fe678f5d23f0ec51d0ee110` +- `opentake`:`aa43de8e3a6782e546b301dc9a1fee2b7739fda8ef1ef1af8c5ff52af03db8be` +- `ffmpeg`:`a83e9395c338b9e759cfba5b797e4206e60d46572c05b7467d2a33e30f53fcc3` +- `ffprobe`:`8a5ff4c6b60ce86cc6e4c7b0bd026aa7a6e91ebfb7748dbbcae68b6ef1d04739` + +App、main、两个 sidecar 均为 arm64,deep/strict codesign 通过;DMG CRC 验证通过。readonly 挂载后的 App 再次 deep/strict 通过,且与 build App 的 `diff -rq` 无输出、三个二进制 hash 完全相同。签名仍为 ad-hoc,未使用 Developer ID、未公证。 + +安装时先正常退出旧实例,将旧 `/Applications/OpenTake.app` 可恢复地移动到 `/Applications/OpenTake.app.backup-20260808-193612`;新 App 先复制到独立 staging、验证签名和内容,再原子改名为 `/Applications/OpenTake.app`。安装版与 build App 的 `diff -rq` 无输出,内容树 digest 相同。 + +最终安装版 PID 4730,二进制路径为 `/Applications/OpenTake.app/Contents/MacOS/opentake`,loopback listener 为 `127.0.0.1:58102`。Fresh GUI 快速复测:Home 正常;`gui-validation-dual` 双 4K60 素材缩略图、时间线复合预览正常;播放头从 0 连续推进到 239;Sort popup 具备单一选中项,按 Tab 后弹层关闭且焦点前进到 Filter。无权限弹窗、无遗留 ffmpeg/ffprobe/helper 子进程。 diff --git a/docs/audit/2026-08-07/editor-core-after-fix-assets/playback-av-real-device.md b/docs/audit/2026-08-07/editor-core-after-fix-assets/playback-av-real-device.md new file mode 100644 index 00000000..9a74393e --- /dev/null +++ b/docs/audit/2026-08-07/editor-core-after-fix-assets/playback-av-real-device.md @@ -0,0 +1,62 @@ +# 播放引擎真实设备 A/V Probe + +验证日期:2026-08-08(Asia/Shanghai)。本验证直接运行 `src-tauri/tests/playback_probe.rs` 的 `PlaybackEngine` 真实设备 probe;没有用导出音频替代播放验证,也没有修改产品代码或测试代码。 + +## 设备与 strict 条件 + +`system_profiler SPAudioDataType` 确认当前默认输出与系统输出均为“MacBook Air扬声器”,2 channels、96,000 Hz。测试设置: + +```sh +OPENTAKE_PROBE_VIDEO=/tmp/.../av-safe-1584x1080-30fps-aac.mp4 \ +OPENTAKE_REQUIRE_AUDIO_CALLBACK=1 \ +cargo test -p opentake-tauri --test playback_probe \ + probe_realtime_playback_with_audio_or_safe_fallback \ + -- --ignored --nocapture --exact +``` + +strict 模式要求 `build_clock_paused` 返回真实 `AudioPlayback`。该路径在返回前会建立 CPAL output stream,并要求 callback epoch 在 1 秒内连续推进至少两次;否则回退 wall clock 后 `audio_active=false`,strict 断言失败。因此日志中的 `clock=audio` 证明本次播放由真实设备 callback 的音频 master clock 驱动,而不是 wall-clock fallback。 + +## Fixture + +fixture 只放在 `mktemp` 目录,不写产品树;使用已安装 app 的 FFmpeg sidecar 生成: + +- H.264、1584×1080、30/1 fps、300 frames、10.000 s。 +- 深蓝动态背景 + 随时间水平移动的红框,不含 neon green;不是静态色视频。 +- AAC、48,000 Hz、stereo、470 audio frames、10.000 s、880 Hz sine。 +- 文件大小 185,584 bytes;SHA-256 `00b863e8392cacd0cf7aeafefb237a6687c17f2e668d4723d67262f1bdc6091c`。 + +媒体拥有真实音视频两条 stream;probe 会通过生产解码/混音路径建立 CPAL 输出,通过 `AudioClock` 推进播放头,同时由真实 GPU/render sink 收集视频帧和像素质量。 + +## 结果 + +首次安全 fixture 单次: + +```text +frames=85 playhead=90 clock=audio +nonblack_ratio=1.000 neon_green_ratio=0.000 +1 passed; 0 failed +``` + +随后连续三次 exact strict probe: + +| run | frames / 3 s | playhead | clock | nonblack | neon green | 结果 | +|---:|---:|---:|---|---:|---:|---| +| 1 | 85 | 91 | audio | 1.000 | 0.000 | PASS | +| 2 | 84 | 90 | audio | 1.000 | 0.000 | PASS | +| 3 | 84 | 89 | audio | 1.000 | 0.000 | PASS | + +验收阈值是 frames ≥ 73、playhead ≥ 73;三次均超过阈值,且视频帧全程非黑、无绿屏哨兵命中。 + +日志: + +- [首次 testsrc2 假阳性](playback-av-real-device-probe.log) +- [安全动态 fixture 单次 PASS](playback-av-real-device-probe-safe-fixture.log) +- [安全动态 fixture 三连 PASS](playback-av-real-device-probe-three-runs.log) + +首次使用 `testsrc2` 的运行已经显示 `frames=84 playhead=89 clock=audio`,但因为 testsrc2 画面本身包含 16.2% 纯绿色,触发“防解码绿屏”质量断言而失败。该失败只说明 fixture 与绿屏哨兵冲突;换成不含 neon green 的动态红/蓝图案后,同一产品代码和同一 strict probe 稳定通过。失败日志完整保留,没有选择性删除。 + +## 判定边界 + +据此可把“播放引擎核心 A/V 设备/时钟集成”从 PARTIAL 提升为 **PASS(真实 CPAL 设备、AAC 解码/混音路径、audio master clock、视频跟随)**。 + +本 probe 没有对扬声器做麦克风 loopback,因此不能客观证明用户感知到的声压/响度;也只有 3 秒单轨窗口,不能替代长时多轨 A/V drift、热插拔设备、系统静音或异常设备专项。这些边界继续保持 PARTIAL,不被三次短 probe 外推。 diff --git a/docs/audit/2026-08-07/editor-core-remediation-assets/p0-asset-scope-import.md b/docs/audit/2026-08-07/editor-core-remediation-assets/p0-asset-scope-import.md new file mode 100644 index 00000000..46310560 --- /dev/null +++ b/docs/audit/2026-08-07/editor-core-remediation-assets/p0-asset-scope-import.md @@ -0,0 +1,71 @@ +# P0 Asset / P2 Scope and Explicit Import Remediation Evidence + +日期:2026-08-08(Asia/Shanghai) +基线:`f9398a9302e57690caa3ea21ff995fdd0ce97706` +说明:本文记录修复阶段的新鲜诊断与回归;不覆盖基线审计报告和原始失败截图。 + +## 1. P0 `opentake-asset` 504 根因与修复 + +### RED / 诊断 + +- fresh custom-protocol release 诊断包中,实际缓存缩略图请求返回 `504 Gateway Timeout`;源视频和 Home 封面同样失败。 +- helper 已收到子进程,但阻塞在 `stdin.read_to_end()`;父进程同时等待 helper stdout,5 s I/O deadline 后返回 504。 +- Unix/macOS 上 Tokio `ChildStdin::poll_shutdown` 不会关闭管道写端,原实现的 `AsyncWriteExt::shutdown()` 因此没有向 helper 送达 EOF。 +- 新回归 `helper_request_pipe_reaches_eof_before_waiting_for_the_response` 在旧路径上 500 ms 超时,Cargo exit 101。 + +### GREEN / 最小修复 + +- 写入 JSON request 后显式 `drop(stdin)`,再等待 helper response;未改变 helper token/PID/同可执行文件校验、进程并发与 quarantine 上限、I/O deadline、MIME/body/range 边界。 +- 无诊断日志源码上 `safe_asset_protocol::tests` 为 **15 passed / 0 failed**(包含 EOF 回归与下述 active-project scope 竞态回归)。 +- 诊断 release/custom-protocol 包 fresh 复测:Home 封面 `200`,工程 thumb cache `200`,源视频 Range `206`,preview cache `200`,timeline sprite `200`;视觉上封面、卡片和选中素材预览恢复。 +- 此次只是修复诊断包,不冒充完整正式安装包验收;完整变更合并后仍须重建、安装和 fresh GUI 重测。 + +## 2. P2 active-project media authority + +### RED + +- Tauri persisted scope 保留历史 dialog/file/directory grants,旧项目 A 的外部媒体在打开 B 后仍能通过原始 scope 判定。 +- 新回归首先因 active-project authority 函数不存在而编译失败(Cargo exit 101)。 +- 首轮修复仅用 `project_epoch` 作为 `ProjectMedia` token;同一工程同时引用 A/B 两路径时,祖先 symlink 从 A 重绑到 B 后,新回归稳定 RED:`expected == rebound == ProjectMedia { project_epoch: 0 }`。 + +### GREEN / fail-closed 边界 + +- 不强行删改 persisted scope;协议在 helper 启动前和 retained final path 发布前各做一次二级 authority gate。 +- 静态 `$APPCACHE/**/*`、`$APPDATA/OpenTake/Library/**/*`、`$RESOURCE/**/*` 和 Home exact `thumbnail.jpg` 保持可用,但其他外部媒体必须同时存在于当前 `AppCore` manifest。 +- `ScopeOnly` / `ProjectMedia` authority 都携带 normalized exact requested path,`ProjectMedia` 另携带 `project_epoch`;发布字节前持有 project identity workflow lease 并重比较 final path/token,避免项目切换或同 epoch 路径重绑与发布之间的 TOCTOU。 +- 回归覆盖:即使 persisted scope 是递归目录 grant,当前工程未引用 sibling 仍拒绝;A→B 后 A 拒绝、B 允许;同 epoch 祖先 A→B 重绑的 helper response 返回 `403`;app cache 和 Home exact thumbnail 仍允许。 + +## 3. P2 explicit import bounds/deadline/identity + +### RED + +- `import_media` 接受任意长的路径列表,且使用无硬期限的 path-based `ffprobe`。 +- 新限额回归首先因 `ExplicitImportLimits` / admission 函数不存在而编译失败(Cargo exit 101)。 +- Rust review 又确认首轮实现仍在 deadline 之前调用可阻塞的 ambient `File::open`,且总字节数来自路径 metadata,计划与 commit 未绑定同一文件身份。 + +### GREEN + +- 明确文件导入在任何 manifest mutation 前限制为 5,000 个路径、100 GiB 总字节,溢出也 fail closed。总字节数改由 retained handle metadata 累加,不再依赖可被替换的前置 pathname metadata。 +- 打开边界复用 asset 协议的 no-follow/no-recall 策略:Unix 使用 `O_NONBLOCK | O_NOFOLLOW`,Windows 使用 `FILE_FLAG_OPEN_NO_RECALL`;FIFO 回归证明不会等待 writer。 +- 同一 retained handle 同时提供总字节 metadata 和 cancellable ffprobe(15 s deadline);manifest 记录 retained final path。commit 临界区内重新以 no-recall 方式打开用户选择路径,并通过 `same_file` 核对身份、计划时长度与重算 aggregate cap;路径替换或同 inode 原地增长都在 manifest/history/persist 发生前原子拒绝。 +- reviewer 复修后又为同 inode 原地增长补测:只比 identity 的中间实现稳定 RED,错误提交了增长后的文件;加入 admitted length/aggregate 重验后转 GREEN。 +- focused 回归 **5 passed / 0 failed**:限额拒绝且内存/磁盘 manifest 不变、路径替换原子拒绝、原地增长原子拒绝、FIFO 快速拒绝、正常持久化导入。 +- 此修复解决确认的输入上限和 probe deadline;用户可见的进度面板/主动取消按钮仍是独立产品能力,本文不将其伪装为已完成。 + +## 4. 共享工作树合并门禁 + +```text +cargo test --workspace +exit 0 + +cargo clippy --workspace --all-targets -- -D warnings +exit 0 + +cargo fmt --all -- --check +exit 0 + +git diff --check +exit 0 +``` + +上述是 asset/import/proxy 修复合并后的共享工作树结果,不是某个子 Agent 的隔离分支结果。此时尚未创建正式安装包;仍需 Rust reviewer 复审通过后才能进入 release/fresh GUI 验收。 diff --git a/docs/audit/2026-08-07/editor-core-remediation-assets/proxy-regression.md b/docs/audit/2026-08-07/editor-core-remediation-assets/proxy-regression.md new file mode 100644 index 00000000..ed1c5304 --- /dev/null +++ b/docs/audit/2026-08-07/editor-core-remediation-assets/proxy-regression.md @@ -0,0 +1,119 @@ +# Proxy 真实回归证据 + +> 执行日期:2026-08-08(Asia/Shanghai) +> 范围:`crates/opentake-media/src/proxy.rs` + +## 结论 + +`cargo test -p opentake-media proxy::tests -- --test-threads=1` 已从审计基线的 +「0 tests」变为 11 个真实 FFmpeg/FFprobe 回归,全部通过。用例使用 lavfi 现场 +生成视频/音频 fixture,不以 mock 代替转码与探测。 + +最终实现满足以下安全和生命周期不变量: + +- 源文件只做一次 fail-fast retained open;Unix 使用 `O_NONBLOCK|O_NOFOLLOW`, + Windows 使用 no-recall/reparse-point flags,并拒绝非普通文件。 +- 前后 SHA-256、FFmpeg stdin 与源身份复核使用同一 retained capability;路径名 + A→B→A、unlink 或 Cloud/FIFO 入口均不能改变已授权输入或无限阻塞 lease。 +- FFmpeg 从一开始只写目标同目录下权限收紧的私有 `TempDir` stage;环境可见的 + `.partial` 从未创建,因此不存在「FFmpeg 退出后、retained open 前」 + 的公开重绑定窗口,也没有可能删除攻击者 rebound 文件的 ambient cleanup。 +- stage 的 retained identity、真实 FFprobe 结果和取消点均复核后,使用 + `TempPath::persist_noclobber` 原子 no-replace 发布;竞争目标不被覆盖。 +- 输出真实 probe 断言恰好 1 条视频流、至多 1 条音频流,防止重复 `-map` 回归。 + +## RED 证据 + +### 基线没有真实 proxy unit 回归 + +```text +cargo test -p opentake-media proxy::tests -- --nocapture +running 0 tests +test result: ok. 0 passed; 415 filtered out +``` + +### Retained source 与取消/发布 race + +旧实现稳定复现过以下失败: + +```text +pathname_replacement_a_to_b_to_a_cannot_change_the_retained_source ... FAILED +left: (180, 320), right: (320, 180) + +cancellation_at_verification_probe_and_prepublication_cleans_partial ... FAILED +phase 910 did not return Cancelled + +destination_appearing_after_final_check_is_preserved_and_partial_is_cleaned ... FAILED +assertion failed: matches!(result, Err(MediaError::Ffmpeg(_))) +``` + +它们分别证明旧代码会在多个 pathname reopen 间接受 ABA 输入、后处理取消仍可发布, +以及 `exists` 后 `rename` 会覆盖竞争写者。 + +### 公开 partial 生命周期 + +早期补丁虽在 probe 后保留 capability,FFmpeg 仍先写公开 sibling partial;复审发现 +攻击者可在 FFmpeg 退出到 retained open 之间置换有效文件。新增用例在 progress=900 +观察旧实现,稳定发现公开 partial: + +```text +ffmpeg_output_is_never_exposed_at_the_ambient_partial_path ... FAILED +assertion failed: !ambient_partial.exists() +``` + +结构性修复后,FFmpeg 从创建时即写私有 stage。progress=990 对环境 sibling 写入普通 +文件或 FIFO 只是在测试外部 namespace:输出仍来自已验证 stage,发布失败也不会打开、 +跟随或删除该 sibling。 + +### 初始 source open 阻塞 + +新增 Unix 回归把 source 设为无 writer 的 FIFO,并同时覆盖 symlink。旧 `File::open` +可在进入 deadline 前阻塞;修复后的 retained opener 在测试的 5 秒上限内 fail closed。 + +## GREEN 用例(11/11) + +| 用例 | 断言 | +|---|---| +| `successful_proxy_is_bounded_probed_and_source_preserving` | 真实 640×360 H.264/AAC 转 320×180;恰好 1 video、至多 1 audio;源字节/SHA 不变 | +| `cancellation_during_transcode_kills_child_and_cleans_partial` | 返回 `Cancelled`、回收子进程、无输出或公开 partial | +| `changed_source_fails_identity_check_and_cleans_partial` | retained inode 就地改写返回 `Checksum`,不发布 | +| `pathname_replacement_a_to_b_to_a_cannot_change_the_retained_source` | 路径 ABA 后仍只能输出原 A 的 320×180 | +| `cancellation_at_verification_probe_and_prepublication_cleans_partial` | 910/950/990 三个后处理阶段均取消且不发布 | +| `destination_appearing_after_final_check_is_preserved_and_partial_is_cleaned` | 目标竞争者保持原字节,no-clobber 返回错误 | +| `verified_partial_rebound_at_prepublication_cannot_change_output_or_delete_replacement` | ambient sibling 重绑不能改变输出,也不被清理 | +| `ffmpeg_output_is_never_exposed_at_the_ambient_partial_path` | progress=900 时环境 `.partial` 不存在 | +| `rebound_fifo_does_not_block_or_get_deleted_on_publication_error` | ambient FIFO 不阻塞、不删除;竞争输出保留 | +| `initial_source_fifo_and_symlink_are_rejected_without_blocking` | 初始 FIFO/symlink fail-fast 拒绝 | +| `unlinked_source_namespace_does_not_break_the_retained_source` | POSIX unlink 后 retained source 仍可完成校验和发布 | + +## 验证记录 + +本机工具:FFmpeg/FFprobe 8.1.2(`/opt/homebrew/bin`)。 + +```text +cargo test -p opentake-media proxy::tests -- --nocapture --test-threads=1 +running 11 tests +test result: ok. 11 passed; 0 failed; 0 ignored + +cargo test -p opentake-media --test proxy -- --nocapture +running 1 test +test result: ok. 1 passed; 0 failed + +cargo clippy --workspace --all-targets -- -D warnings +exit 0 + +cargo fmt --all -- --check +exit 0 + +cargo test --workspace +exit 0 +# opentake-media unit: 434 passed / 1 ignored +# opentake-tauri unit: 536 passed +# 其余 workspace integration/doc-test binaries 全部通过 +``` + +## 限制 + +- 回归需要可用的 FFmpeg/FFprobe;正式 CI/打包仍须供应锁定 sidecar。 +- macOS 本机验证了当前实现;Windows no-recall opener 与 `persist_noclobber` 分支需要 + Windows CI/实机构建补充平台证据。 diff --git a/docs/audit/2026-08-07/editor-core-remediation-assets/resource-scheduler.md b/docs/audit/2026-08-07/editor-core-remediation-assets/resource-scheduler.md new file mode 100644 index 00000000..bdd1f619 --- /dev/null +++ b/docs/audit/2026-08-07/editor-core-remediation-assets/resource-scheduler.md @@ -0,0 +1,137 @@ +# P1 派生资源调度修复证据 + +修复时间:2026-08-08(Asia/Shanghai) +工作树 / HEAD 基线:`OpenTake-generation` / `f9398a9302e57690caa3ea21ff995fdd0ce97706` +修复范围:前端 waveform、素材卡/搜索缩略图、选中视频 preview poster 的统一 admission control;未修改 Rust、asset protocol、CSP 或 accessible-name UI。 + +## 1. RED → GREEN + +### RED + +先新增 `web/src/lib/derivedResourceScheduler.test.ts`,未实现调度器时运行: + +```text +pnpm exec vitest run src/lib/derivedResourceScheduler.test.ts --reporter=default +Exit 1 +Test Files 1 failed +Error: Cannot find module './derivedResourceScheduler' +``` + +测试先定义了旧实现不具备的契约:共享 active/pending 上限、跨调用点同键 single-flight、订阅独立取消、排队取消、project epoch 失效、preview latest-wins、AbortSignal、交互优先级。 + +### GREEN + +实现和接线后的验证: + +| 命令 | 结果 | +|---|---:| +| scheduler 单测 | **10 passed** / 0 failed | +| MediaPanel + MediaSearch + Preview + scheduler focused 集 | 6 files / **81 passed** / 0 failed | +| `pnpm test -- --run --reporter=default` | **136 files / 1132 passed** / 0 failed | +| `pnpm exec tsc -b --pretty false` | Exit 0 | +| `pnpm build` | Exit 0;`tsc -b` + Vite production build 成功 | +| `git diff --check` | Exit 0 | + +测试仍有仓库既有 React `act(...)` warning;build 仍有既有 dynamic-import/chunk-size warning。两者均未造成失败。项目没有安装 Prettier/ESLint/Biome 可执行依赖,`pnpm exec prettier --check ...` 因 command not found 未运行;TypeScript build 是本次格式外的强制静态检查。 + +## 2. 实现语义 + +统一调度器:`web/src/lib/derivedResourceScheduler.ts`。 + +- 全局共享 `maxActive = 4`、`maxPending = 64`。waveform、卡片 thumbnail、搜索 thumbnail、preview poster 不再各自放大 FFmpeg fan-out。 +- pending 达到硬上限后,普通请求不再无界增长闭包队列;交互请求会先汰汰低优先级排队项,队列全为交互项时汰汰最旧项,因此仍遵守 64 的硬上限且新选 preview 不会永久丢失。无可汰汰项时才返回 `admitted=false` + `null`。 +- identity = `projectEpoch + resource key`;相同 identity 只运行一个底层 Promise,多个订阅者共享结果。 +- 单个订阅者取消只结束自己的 Promise;仍有其他订阅者时不误伤共享任务。 +- 最后一个订阅者取消:queued 任务从队列中真实删除;普通 active 任务仅解除订阅者,保留同键物理 flight 直到结算,因而 React StrictMode/快速卸载→重挂不会并发启动第二份同键工作。该无订阅者 flight 不计入 publishable `inFlight`,但仍占 active 槽到物理 Promise 结束。 +- `activateProject(newEpoch)` 原子失效旧 epoch 的 queued/active 订阅;旧结果不会发布到新项目。 +- `latestGroup="preview-poster"` 保证新选中素材使旧 poster 失效;project/latest 失效会对 active entry 发出 AbortSignal 并禁止旧结果发布。 +- priority 为 `interactive > visible > background`;preview poster 可越过已排队 waveform,队列满时也会抢占低优先级/最旧同级交互项;已进入 Rust 的物理任务不抢占。 +- settled entry 在通知订阅者前从同键 Map 删除,避免完成与新请求之间的“附着到已结算 Promise”竞态。 + +## 3. 调用点接线 + +### MediaPanel + +- 删除原 `activeThumbnailRequests + pendingThumbnailRequests + mediaThumbnailInFlight` 独立队列。 +- 素材卡 thumbnail 使用统一 scheduler、项目 epoch、source-aware key 和原 256 项 LRU;IntersectionObserver admission 仍保留。 +- `AudioWaveform` 使用 background 优先级、source-aware key;素材卡只在进入 160px 预取视口后才 admission,unmount/prop/epoch 变化取消订阅并拒绝旧结果写回。 + +组件测试证据:同时挂载 6 个冷波形,仅 4 次 `getWaveform` 立即开始、2 个 pending;unmount 后 pending 从 2→0、publishable inFlight 从 6→0。4 个已进入 Rust 模拟工作的 active 槽在底层 Promise 完成后回到 0。另有 A epoch 结果晚到不覆盖 B epoch,以及已 disconnect 的旧 MediaCard `IntersectionObserver` callback 不能反向切回旧 epoch/启动缩略图的回归测试。 + +### MediaSearch + +- Moment/Spoken `HitThumbnail` 经统一 scheduler,key 包含 project epoch、media id、source path、source second。 +- query/项目变化或 unmount 时取消每个命中订阅。 + +组件测试证据:冷搜索返回 40 个不同时间点时,仅 4 次 `generateThumbnail` 立即开始、36 个 pending;unmount 后 pending 36→0、publishable inFlight 40→0。 + +### Preview + +- `MediaPreview` poster 使用 interactive priority + `latestGroup="preview-poster"`。 +- item/path/epoch 变化或 unmount 取消旧订阅,poster state 先清空。 + +组件测试证据:old→current 快速切换后,old poster 晚到不写入 `