@ludi-uni/ludi-agent-kit 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +55 -0
- package/LICENSE +21 -0
- package/README.md +107 -0
- package/adapters/codex/README.md +24 -0
- package/adapters/codex/skill-metadata/visual-verification/agents/openai.yaml +7 -0
- package/adapters/pi/README.md +88 -0
- package/adapters/pi/browser/agent-browser.mjs +193 -0
- package/adapters/pi/lib/invoke.mjs +55 -0
- package/adapters/pi/lib/list-models.mjs +29 -0
- package/adapters/pi/lib/settings-proposal.mjs +34 -0
- package/adapters/pi/lib/subagent.mjs +175 -0
- package/adapters/pi/loop-guard/index.js +51 -0
- package/adapters/pi/maintenance-policy.json +36 -0
- package/adapters/pi/mcp.template.json +4 -0
- package/adapters/pi/model-catalog.json +97 -0
- package/adapters/pi/models.json +13 -0
- package/adapters/pi/models.local.example.json +14 -0
- package/adapters/pi/orchestrator-ext/command.mjs +14 -0
- package/adapters/pi/orchestrator-ext/index.js +150 -0
- package/adapters/pi/settings.template.json +7 -0
- package/adapters/pi/shell-gate/index.js +70 -0
- package/adapters/pi/sync-pi.ps1 +137 -0
- package/agents/README.md +26 -0
- package/agents/browser.md +64 -0
- package/agents/coder.md +31 -0
- package/agents/orchestrator.md +37 -0
- package/agents/reviewer.md +32 -0
- package/agents/scout.md +35 -0
- package/agents/tester.md +28 -0
- package/agents/visual.md +28 -0
- package/context-pack/SPEC.md +101 -0
- package/context-pack/context-pack.schema.json +79 -0
- package/context-pack/examples/example-fix.md +44 -0
- package/docs/architecture.md +55 -0
- package/docs/migration-from-codex-setting.md +44 -0
- package/docs/model-maintenance.md +401 -0
- package/docs/orchestrator.md +155 -0
- package/docs/phase2-report.md +39 -0
- package/docs/roadmap.md +27 -0
- package/docs/third-party.md +15 -0
- package/lib/agents.mjs +79 -0
- package/lib/context-pack.mjs +215 -0
- package/lib/job.mjs +312 -0
- package/lib/language-policy.mjs +27 -0
- package/lib/maintenance-exec.mjs +377 -0
- package/lib/maintenance-runner.mjs +266 -0
- package/lib/maintenance.mjs +422 -0
- package/lib/normalize.mjs +101 -0
- package/lib/observe/differ.mjs +185 -0
- package/lib/observe/observation.mjs +147 -0
- package/lib/observe/observers.mjs +134 -0
- package/lib/observe/sources.mjs +154 -0
- package/lib/orchestrator/activity.mjs +249 -0
- package/lib/orchestrator/api.mjs +151 -0
- package/lib/orchestrator/contract.mjs +68 -0
- package/lib/orchestrator/escalation.mjs +84 -0
- package/lib/orchestrator/evaluator.mjs +92 -0
- package/lib/orchestrator/failures.mjs +88 -0
- package/lib/orchestrator/health.mjs +53 -0
- package/lib/orchestrator/orchestrator.mjs +483 -0
- package/lib/orchestrator/permissions.mjs +64 -0
- package/lib/orchestrator/planner.mjs +194 -0
- package/lib/orchestrator/policy.mjs +134 -0
- package/lib/orchestrator/router.mjs +45 -0
- package/lib/orchestrator/runner.mjs +278 -0
- package/lib/orchestrator/shell-policy.mjs +52 -0
- package/lib/orchestrator/store.mjs +581 -0
- package/lib/orchestrator/task-store.mjs +79 -0
- package/lib/orchestrator/turn-budget.mjs +63 -0
- package/lib/orchestrator/worktree.mjs +72 -0
- package/lib/pipeline.mjs +279 -0
- package/lib/registry.mjs +63 -0
- package/lib/resolve.mjs +35 -0
- package/lib/routing.mjs +137 -0
- package/lib/telemetry.mjs +222 -0
- package/mcp/README.md +11 -0
- package/mcp/servers.json +13 -0
- package/orchestration/decision-policy.json +66 -0
- package/package.json +56 -0
- package/routing/README.md +24 -0
- package/routing/routing.json +81 -0
- package/routing/routing.schema.json +66 -0
- package/rules/README.md +10 -0
- package/rules/common.md +52 -0
- package/rules/loop-prevention.md +15 -0
- package/rules/repo-local.md +6 -0
- package/scripts/check-environment.ps1 +22 -0
- package/scripts/context-pack.mjs +17 -0
- package/scripts/e2e-investigate-repro.mjs +66 -0
- package/scripts/model-maintenance-job.mjs +59 -0
- package/scripts/observe-models.mjs +97 -0
- package/scripts/orchestrate.mjs +137 -0
- package/scripts/reevaluate-models.mjs +95 -0
- package/scripts/report-model-maintenance.mjs +70 -0
- package/scripts/resolve-capabilities.mjs +39 -0
- package/scripts/run-pipeline.mjs +56 -0
- package/scripts/sync-agents-md.ps1 +10 -0
- package/scripts/validate.mjs +71 -0
- package/skills/README.md +14 -0
- package/skills/pi-workflow/SKILL.md +26 -0
- package/skills/pi-workflow/references/code-investigation-and-fix.md +16 -0
- package/skills/pi-workflow/references/research.md +14 -0
- package/skills/pi-workflow/references/review.md +11 -0
- package/skills/pi-workflow/references/visual-work.md +14 -0
- package/skills/project-management/SKILL.md +106 -0
- package/skills/project-management/references/operations.md +52 -0
- package/skills/visual-verification/SKILL.md +88 -0
- package/skills/visual-verification/scripts/analyze-speech.ps1 +346 -0
- package/skills/visual-verification/scripts/backends/whisperx_backend.py +234 -0
- package/skills/visual-verification/scripts/common.ps1 +387 -0
- package/skills/visual-verification/scripts/contact-sheet.ps1 +121 -0
- package/skills/visual-verification/scripts/desktop-discover.ps1 +45 -0
- package/skills/visual-verification/scripts/desktop-inspect.ps1 +67 -0
- package/skills/visual-verification/scripts/desktop-record.ps1 +97 -0
- package/skills/visual-verification/scripts/desktop-screenshot.ps1 +65 -0
- package/skills/visual-verification/scripts/evaluate-sync.ps1 +249 -0
- package/skills/visual-verification/scripts/extract-frames.ps1 +79 -0
- package/skills/visual-verification/scripts/inspect-media.ps1 +138 -0
- package/skills/visual-verification/scripts/record-av.ps1 +102 -0
- package/skills/visual-verification/scripts/record.ps1 +72 -0
- package/skills/visual-verification/scripts/screenshot.ps1 +44 -0
- package/skills/visual-verification/scripts/waveform.ps1 +450 -0
- package/skills/visual-verification/scripts/winapp-common.ps1 +465 -0
- package/tests/activity.test.mjs +252 -0
- package/tests/attempt-budget.test.mjs +102 -0
- package/tests/browser.test.mjs +121 -0
- package/tests/context-pack.test.mjs +98 -0
- package/tests/dirty-gate.test.mjs +211 -0
- package/tests/e2e-browser.mjs +66 -0
- package/tests/e2e-real-orchestrator-resume.mjs +101 -0
- package/tests/e2e-real-orchestrator.mjs +41 -0
- package/tests/e2e-real-pi.mjs +27 -0
- package/tests/e2e-real-tool-orchestrator.mjs +66 -0
- package/tests/fixtures/browser-page/index.html +20 -0
- package/tests/fixtures/maintenance/availability.txt +5 -0
- package/tests/fixtures/maintenance/catalog.json +74 -0
- package/tests/fixtures/maintenance/events.json +13 -0
- package/tests/fixtures/math-repo/README.md +3 -0
- package/tests/fixtures/math-repo/package.json +7 -0
- package/tests/fixtures/math-repo/src/math.js +11 -0
- package/tests/fixtures/math-repo/test/math.test.js +7 -0
- package/tests/fixtures/observe/announcements.json +8 -0
- package/tests/fixtures/orch-concurrent-child.mjs +44 -0
- package/tests/fixtures/orch-persist-child.mjs +61 -0
- package/tests/job.test.mjs +230 -0
- package/tests/kit.test.mjs +79 -0
- package/tests/language-policy.test.mjs +93 -0
- package/tests/loop-guard.test.mjs +60 -0
- package/tests/maintenance-exec.test.mjs +218 -0
- package/tests/maintenance-runner.test.mjs +222 -0
- package/tests/maintenance.test.mjs +195 -0
- package/tests/observe.test.mjs +283 -0
- package/tests/observer-registry.test.mjs +157 -0
- package/tests/orchestrator-cleanup.test.mjs +358 -0
- package/tests/orchestrator-command.test.mjs +14 -0
- package/tests/orchestrator-persist.test.mjs +375 -0
- package/tests/orchestrator-tools.test.mjs +215 -0
- package/tests/orchestrator.test.mjs +396 -0
- package/tests/package.test.mjs +37 -0
- package/tests/pipeline.test.mjs +239 -0
- package/tests/planner-classification.test.mjs +81 -0
- package/tests/planner-split.test.mjs +67 -0
- package/tests/qoder-observer.test.mjs +266 -0
- package/tests/reassign-progression.test.mjs +104 -0
- package/tests/retry-escalation.test.mjs +120 -0
- package/tests/routing.test.mjs +110 -0
- package/tests/sqlite-concurrency.test.mjs +178 -0
- package/tests/task-global-e2e.test.mjs +63 -0
- package/tests/task-global-failed.test.mjs +134 -0
- package/tests/telemetry.test.mjs +173 -0
- package/tests/test-sync-pi.ps1 +56 -0
- package/tests/turn-budget.test.mjs +106 -0
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
[CmdletBinding()]
|
|
2
|
+
param(
|
|
3
|
+
[string]$App,
|
|
4
|
+
[string]$ProcessName,
|
|
5
|
+
[string]$WindowTitle,
|
|
6
|
+
[string]$ClassName,
|
|
7
|
+
[Nullable[int]]$ProcessId,
|
|
8
|
+
[Nullable[long]]$Hwnd,
|
|
9
|
+
[Nullable[int]]$Width,
|
|
10
|
+
[Nullable[int]]$Height,
|
|
11
|
+
[double]$Duration = 5,
|
|
12
|
+
[int]$Fps = 5,
|
|
13
|
+
[switch]$Frames,
|
|
14
|
+
[int]$MaxEdge = 1280,
|
|
15
|
+
[string]$OutputPath,
|
|
16
|
+
[string]$RunDirectory
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
Set-StrictMode -Version Latest
|
|
20
|
+
$ErrorActionPreference = 'Stop'
|
|
21
|
+
. (Join-Path $PSScriptRoot 'common.ps1')
|
|
22
|
+
. (Join-Path $PSScriptRoot 'winapp-common.ps1')
|
|
23
|
+
|
|
24
|
+
try {
|
|
25
|
+
$version = Get-WinAppVersionInfo
|
|
26
|
+
Assert-WinAppCaptureReady -VersionInfo $version
|
|
27
|
+
$run = New-VisualRun -RunDirectory $RunDirectory -Kind 'visual'
|
|
28
|
+
if ([string]::IsNullOrWhiteSpace($OutputPath)) { $OutputPath = Join-Path $run.RunDirectory 'recording.mp4' }
|
|
29
|
+
$resolvedOutputPath = [IO.Path]::GetFullPath($OutputPath)
|
|
30
|
+
$metadataOutputPath = Join-Path $run.RunDirectory 'recording.json'
|
|
31
|
+
$representativeOutputPath = Join-Path $run.RunDirectory 'representative-frames.json'
|
|
32
|
+
Assert-OutputAvailable -Path $resolvedOutputPath
|
|
33
|
+
Assert-OutputAvailable -Path $metadataOutputPath
|
|
34
|
+
if ($Frames) { Assert-OutputAvailable -Path $representativeOutputPath }
|
|
35
|
+
$expectedFramesDirectory = Join-Path (Split-Path -Parent $resolvedOutputPath) (([IO.Path]::GetFileNameWithoutExtension($resolvedOutputPath)) + '.frames')
|
|
36
|
+
if ($Frames -and (Test-Path -LiteralPath $expectedFramesDirectory)) { throw (New-WinAppException -Code 'RECORD_FAILED' -Message "Frame output already exists: $expectedFramesDirectory") }
|
|
37
|
+
$target = Get-WinAppWindowTarget -Executable $version.executable -App $App -ProcessName $ProcessName -WindowTitle $WindowTitle -ClassName $ClassName -ProcessId $ProcessId -Hwnd $Hwnd -Width $Width -Height $Height
|
|
38
|
+
$arguments = New-WinAppRecordArguments -Hwnd $target.hwnd -OutputPath $resolvedOutputPath -Duration $Duration -Fps $Fps -Frames:$Frames -MaxEdge $MaxEdge
|
|
39
|
+
$response = Invoke-WinAppJson -Executable $version.executable -Arguments $arguments -Operation record
|
|
40
|
+
Assert-WinAppMp4 -Path $resolvedOutputPath
|
|
41
|
+
$recordWidth = [int](Get-WinAppPropertyValue $response.data 'width' 0)
|
|
42
|
+
$recordHeight = [int](Get-WinAppPropertyValue $response.data 'height' 0)
|
|
43
|
+
if ($recordWidth -le 0 -or $recordHeight -le 0) { throw (New-WinAppException -Code 'RECORD_FAILED' -Message 'WinApp CLI returned zero recording dimensions.') }
|
|
44
|
+
if ([string](Get-WinAppPropertyValue $response.data 'codec') -ne 'h264') { throw (New-WinAppException -Code 'RECORD_FAILED' -Message 'WinApp CLI did not report an H.264 recording.') }
|
|
45
|
+
|
|
46
|
+
$artifacts = [Collections.Generic.List[object]]::new()
|
|
47
|
+
[void]$artifacts.Add([pscustomobject]@{ role = 'capture'; path = $resolvedOutputPath; mime = 'video/mp4' })
|
|
48
|
+
$representative = @()
|
|
49
|
+
$frameArtifacts = Get-WinAppPropertyValue $response.data 'frameArtifacts'
|
|
50
|
+
if ($Frames) {
|
|
51
|
+
if ($null -eq $frameArtifacts) { throw (New-WinAppException -Code 'RECORD_FAILED' -Message 'Frame artifacts were requested but not reported.') }
|
|
52
|
+
$framesDirectory = [IO.Path]::GetFullPath([string](Get-WinAppPropertyValue $frameArtifacts 'directory'))
|
|
53
|
+
$manifestPath = [IO.Path]::GetFullPath([string](Get-WinAppPropertyValue $frameArtifacts 'manifest'))
|
|
54
|
+
$indexPath = [IO.Path]::GetFullPath([string](Get-WinAppPropertyValue $frameArtifacts 'index'))
|
|
55
|
+
if (-not (Test-Path -LiteralPath $framesDirectory -PathType Container) -or -not (Test-Path -LiteralPath $manifestPath -PathType Leaf) -or -not (Test-Path -LiteralPath $indexPath -PathType Leaf)) { throw (New-WinAppException -Code 'RECORD_FAILED' -Message 'One or more requested frame artifacts are missing.') }
|
|
56
|
+
$manifest = Get-Content -LiteralPath $manifestPath -Raw | ConvertFrom-Json -ErrorAction Stop
|
|
57
|
+
if ([string](Get-WinAppPropertyValue $manifest 'status') -ne 'complete') { throw (New-WinAppException -Code 'RECORD_FAILED' -Message 'Frame manifest is not complete.') }
|
|
58
|
+
$samples = @(Get-Content -LiteralPath $indexPath | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | ForEach-Object { $_ | ConvertFrom-Json -ErrorAction Stop })
|
|
59
|
+
if ($samples.Count -eq 0) { throw (New-WinAppException -Code 'RECORD_FAILED' -Message 'frames.ndjson contains no samples.') }
|
|
60
|
+
$representative = @(Select-WinAppRepresentativeFrames -Samples $samples -FramesRoot $framesDirectory)
|
|
61
|
+
foreach ($frame in $representative) { if (-not (Test-Path -LiteralPath $frame.path -PathType Leaf)) { throw (New-WinAppException -Code 'RECORD_FAILED' -Message "Representative frame is missing: $($frame.path)") } }
|
|
62
|
+
$representativePath = Write-WinAppJsonFile -Value ([pscustomobject]@{ strategy = 'first_middle_last'; samples = $representative }) -Path $representativeOutputPath
|
|
63
|
+
[void]$artifacts.Add([pscustomobject]@{ role = 'frame-directory'; path = $framesDirectory; mime = 'application/x-directory' })
|
|
64
|
+
[void]$artifacts.Add([pscustomobject]@{ role = 'frame-manifest'; path = $manifestPath; mime = 'application/json' })
|
|
65
|
+
[void]$artifacts.Add([pscustomobject]@{ role = 'frame-index'; path = $indexPath; mime = 'application/x-ndjson' })
|
|
66
|
+
[void]$artifacts.Add([pscustomobject]@{ role = 'representative-frames'; path = $representativePath; mime = 'application/json' })
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
$commandResult = [pscustomobject]@{ exit_code = $response.process.exit_code; arguments = @($arguments) }
|
|
70
|
+
$metadata = New-WinAppOperationMetadata -Operation 'record' -Version $version.actual -VersionClassification $version.classification -Target $target -ArtifactPath $resolvedOutputPath -Width $recordWidth -Height $recordHeight -CommandResult $commandResult
|
|
71
|
+
$metadata | Add-Member -NotePropertyName recording -NotePropertyValue ([pscustomobject]@{
|
|
72
|
+
codec = 'h264'; duration_sec = [double](Get-WinAppPropertyValue $response.data 'durationSec' 0); fps = [double](Get-WinAppPropertyValue $response.data 'fps' 0); capture_mode = [string](Get-WinAppPropertyValue $response.data 'mode'); frames_enabled = [bool]$Frames; representative_frames = $representative
|
|
73
|
+
})
|
|
74
|
+
$targetPath = Write-WinAppTargetDocument -Path (Join-Path $run.RunDirectory 'desktop-window.json') -Target $target -VersionInfo $version
|
|
75
|
+
$metadataPath = Write-WinAppJsonFile -Value $metadata -Path $metadataOutputPath
|
|
76
|
+
[void]$artifacts.Add([pscustomobject]@{ role = 'desktop-window'; path = $targetPath; mime = 'application/json' })
|
|
77
|
+
[void]$artifacts.Add([pscustomobject]@{ role = 'recording-metadata'; path = $metadataPath; mime = 'application/json' })
|
|
78
|
+
$warnings = @()
|
|
79
|
+
if ($version.classification -eq 'UNTESTED_NEWER') { $warnings += 'WINAPP_VERSION_UNTESTED' }
|
|
80
|
+
$resultJson = Write-ResultJson -Result ([pscustomobject]@{
|
|
81
|
+
schema_version = 'agent-verification-lab.result.v1'
|
|
82
|
+
status = 'ok'
|
|
83
|
+
kind = $run.Kind
|
|
84
|
+
run_id = $run.RunId
|
|
85
|
+
run_directory = $run.RunDirectory
|
|
86
|
+
artifacts = @($artifacts)
|
|
87
|
+
backend = $metadata.backend
|
|
88
|
+
target = $target
|
|
89
|
+
recording = $metadata.recording
|
|
90
|
+
warnings = $warnings
|
|
91
|
+
})
|
|
92
|
+
Write-VisualResult -RunDirectory $run.RunDirectory -OutputPath $resolvedOutputPath -Kind 'desktop-recording' -ResultJsonPath $resultJson
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
Write-WinAppFailure -ErrorRecord $_ -Operation record
|
|
96
|
+
exit 1
|
|
97
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
[CmdletBinding()]
|
|
2
|
+
param(
|
|
3
|
+
[string]$App,
|
|
4
|
+
[string]$ProcessName,
|
|
5
|
+
[string]$WindowTitle,
|
|
6
|
+
[string]$ClassName,
|
|
7
|
+
[Nullable[int]]$ProcessId,
|
|
8
|
+
[Nullable[long]]$Hwnd,
|
|
9
|
+
[Nullable[int]]$Width,
|
|
10
|
+
[Nullable[int]]$Height,
|
|
11
|
+
[string]$OutputPath,
|
|
12
|
+
[string]$RunDirectory
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
Set-StrictMode -Version Latest
|
|
16
|
+
$ErrorActionPreference = 'Stop'
|
|
17
|
+
. (Join-Path $PSScriptRoot 'common.ps1')
|
|
18
|
+
. (Join-Path $PSScriptRoot 'winapp-common.ps1')
|
|
19
|
+
|
|
20
|
+
try {
|
|
21
|
+
$version = Get-WinAppVersionInfo
|
|
22
|
+
Assert-WinAppCaptureReady -VersionInfo $version
|
|
23
|
+
$run = New-VisualRun -RunDirectory $RunDirectory -Kind 'visual'
|
|
24
|
+
if ([string]::IsNullOrWhiteSpace($OutputPath)) { $OutputPath = Join-Path $run.RunDirectory 'screenshot.png' }
|
|
25
|
+
$resolvedOutputPath = [IO.Path]::GetFullPath($OutputPath)
|
|
26
|
+
$metadataOutputPath = Join-Path $run.RunDirectory 'screenshot.json'
|
|
27
|
+
Assert-OutputAvailable -Path $resolvedOutputPath
|
|
28
|
+
Assert-OutputAvailable -Path $metadataOutputPath
|
|
29
|
+
$target = Get-WinAppWindowTarget -Executable $version.executable -App $App -ProcessName $ProcessName -WindowTitle $WindowTitle -ClassName $ClassName -ProcessId $ProcessId -Hwnd $Hwnd -Width $Width -Height $Height
|
|
30
|
+
$arguments = New-WinAppScreenshotArguments -Hwnd $target.hwnd -OutputPath $resolvedOutputPath
|
|
31
|
+
$response = Invoke-WinAppJson -Executable $version.executable -Arguments $arguments -Operation screenshot
|
|
32
|
+
$dimensions = Get-PngDimensions -Path $resolvedOutputPath
|
|
33
|
+
$reportedPath = [string](Get-WinAppPropertyValue $response.data 'filePath')
|
|
34
|
+
if (-not [string]::IsNullOrWhiteSpace($reportedPath) -and [IO.Path]::GetFullPath($reportedPath) -ne $resolvedOutputPath) {
|
|
35
|
+
throw (New-WinAppException -Code 'CAPTURE_FAILED' -Message 'WinApp CLI reported a different screenshot output path.')
|
|
36
|
+
}
|
|
37
|
+
$associated = @(Get-WinAppPropertyValue $response.data 'windows' @())
|
|
38
|
+
$commandResult = [pscustomobject]@{ exit_code = $response.process.exit_code; arguments = @($arguments) }
|
|
39
|
+
$metadata = New-WinAppOperationMetadata -Operation 'screenshot' -Version $version.actual -VersionClassification $version.classification -Target $target -ArtifactPath $resolvedOutputPath -Width $dimensions.width -Height $dimensions.height -AssociatedWindows $associated -CommandResult $commandResult
|
|
40
|
+
$targetPath = Write-WinAppTargetDocument -Path (Join-Path $run.RunDirectory 'desktop-window.json') -Target $target -VersionInfo $version
|
|
41
|
+
$metadataPath = Write-WinAppJsonFile -Value $metadata -Path $metadataOutputPath
|
|
42
|
+
$warnings = @()
|
|
43
|
+
if ($version.classification -eq 'UNTESTED_NEWER') { $warnings += 'WINAPP_VERSION_UNTESTED' }
|
|
44
|
+
$resultJson = Write-ResultJson -Result ([pscustomobject]@{
|
|
45
|
+
schema_version = 'agent-verification-lab.result.v1'
|
|
46
|
+
status = 'ok'
|
|
47
|
+
kind = $run.Kind
|
|
48
|
+
run_id = $run.RunId
|
|
49
|
+
run_directory = $run.RunDirectory
|
|
50
|
+
artifacts = @(
|
|
51
|
+
[pscustomobject]@{ role = 'capture'; path = $resolvedOutputPath; mime = 'image/png' },
|
|
52
|
+
[pscustomobject]@{ role = 'desktop-window'; path = $targetPath; mime = 'application/json' },
|
|
53
|
+
[pscustomobject]@{ role = 'screenshot-metadata'; path = $metadataPath; mime = 'application/json' }
|
|
54
|
+
)
|
|
55
|
+
backend = $metadata.backend
|
|
56
|
+
target = $target
|
|
57
|
+
capture = [pscustomobject]@{ status = 'PASS'; semantic_verification = 'REQUIRES_VISUAL_INSPECTION'; mode = 'default_window_capture'; capture_screen = $false }
|
|
58
|
+
warnings = $warnings
|
|
59
|
+
})
|
|
60
|
+
Write-VisualResult -RunDirectory $run.RunDirectory -OutputPath $resolvedOutputPath -Kind 'desktop-screenshot' -ResultJsonPath $resultJson
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
Write-WinAppFailure -ErrorRecord $_ -Operation screenshot
|
|
64
|
+
exit 1
|
|
65
|
+
}
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
[CmdletBinding()]
|
|
2
|
+
param(
|
|
3
|
+
[Parameter(Mandatory)]
|
|
4
|
+
[ValidateNotNullOrEmpty()]
|
|
5
|
+
[string]$AudioEventsJson,
|
|
6
|
+
[Parameter(Mandatory)]
|
|
7
|
+
[double]$VisualEventSeconds,
|
|
8
|
+
[string]$SpeechJson,
|
|
9
|
+
[Nullable[double]]$GestureSeconds,
|
|
10
|
+
[ValidateRange(1, 1000)]
|
|
11
|
+
[double]$ToleranceMilliseconds = 60,
|
|
12
|
+
[string]$OutputJson
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
Set-StrictMode -Version Latest
|
|
16
|
+
|
|
17
|
+
$toleranceMillisecondsWasExplicit = $PSBoundParameters.ContainsKey('ToleranceMilliseconds')
|
|
18
|
+
$signConvention = 'audio-minus-visual; audio-late is positive and audio-early is negative'
|
|
19
|
+
|
|
20
|
+
function Get-PropertyOrNull {
|
|
21
|
+
[OutputType([object])]
|
|
22
|
+
param(
|
|
23
|
+
[AllowNull()][object]$Object,
|
|
24
|
+
[Parameter(Mandatory)][string]$Name
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
if ($null -eq $Object) {
|
|
28
|
+
return $null
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
$property = $Object.PSObject.Properties[$Name]
|
|
32
|
+
if ($null -eq $property) {
|
|
33
|
+
return $null
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return $property.Value
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function ConvertTo-FiniteSeconds {
|
|
40
|
+
[OutputType([double])]
|
|
41
|
+
param(
|
|
42
|
+
[Parameter(Mandatory)][AllowNull()][object]$Value,
|
|
43
|
+
[Parameter(Mandatory)][string]$Description
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
$seconds = [Convert]::ToDouble($Value, [Globalization.CultureInfo]::InvariantCulture)
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
throw "$Description must be a numeric timestamp in seconds."
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if ([double]::IsNaN($seconds) -or [double]::IsInfinity($seconds)) {
|
|
54
|
+
throw "$Description must be a finite timestamp in seconds."
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return $seconds
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function Read-JsonDocument {
|
|
61
|
+
[OutputType([object])]
|
|
62
|
+
param(
|
|
63
|
+
[Parameter(Mandatory)][string]$Path,
|
|
64
|
+
[Parameter(Mandatory)][string]$Description
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) {
|
|
68
|
+
throw "$Description does not exist or is not a file: $Path"
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
try {
|
|
72
|
+
return (Get-Content -LiteralPath $Path -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop)
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
throw "$Description is not valid JSON: $Path. $($_.Exception.Message)"
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function New-TimestampMeasurement {
|
|
80
|
+
[OutputType([pscustomobject])]
|
|
81
|
+
param([AllowNull()][object]$Seconds)
|
|
82
|
+
|
|
83
|
+
if ($null -eq $Seconds) {
|
|
84
|
+
return $null
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return [pscustomobject]@{
|
|
88
|
+
seconds = [Math]::Round([double]$Seconds, 6)
|
|
89
|
+
milliseconds = [Math]::Round(([double]$Seconds) * 1000, 3)
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function New-OffsetMeasurement {
|
|
94
|
+
[OutputType([pscustomobject])]
|
|
95
|
+
param(
|
|
96
|
+
[AllowNull()][object]$MeasuredSeconds,
|
|
97
|
+
[Parameter(Mandatory)][double]$VisualSeconds
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
if ($null -eq $MeasuredSeconds) {
|
|
101
|
+
return $null
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
$offsetSeconds = ([double]$MeasuredSeconds) - $VisualSeconds
|
|
105
|
+
return [pscustomobject]@{
|
|
106
|
+
seconds = [Math]::Round($offsetSeconds, 6)
|
|
107
|
+
milliseconds = [Math]::Round($offsetSeconds * 1000, 3)
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function Write-NewOutputJson {
|
|
112
|
+
[CmdletBinding()]
|
|
113
|
+
param(
|
|
114
|
+
[Parameter(Mandatory)][string]$Path,
|
|
115
|
+
[Parameter(Mandatory)][string]$Json
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
$stream = $null
|
|
119
|
+
try {
|
|
120
|
+
# CreateNew is the final authority: it also rejects a file created after validation.
|
|
121
|
+
$stream = [IO.File]::Open($Path, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None)
|
|
122
|
+
}
|
|
123
|
+
catch [IO.IOException] {
|
|
124
|
+
throw "OutputJson already exists or could not be exclusively created: $Path"
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
try {
|
|
128
|
+
$bytes = [Text.UTF8Encoding]::new($false).GetBytes($Json)
|
|
129
|
+
$stream.Write($bytes, 0, $bytes.Length)
|
|
130
|
+
$stream.Flush($true)
|
|
131
|
+
}
|
|
132
|
+
finally {
|
|
133
|
+
$stream.Dispose()
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
$audioEventsPath = [IO.Path]::GetFullPath($AudioEventsJson)
|
|
138
|
+
$visualSeconds = ConvertTo-FiniteSeconds -Value $VisualEventSeconds -Description 'VisualEventSeconds'
|
|
139
|
+
$resolvedOutputJson = $null
|
|
140
|
+
if (-not [string]::IsNullOrWhiteSpace($OutputJson)) {
|
|
141
|
+
$resolvedOutputJson = [IO.Path]::GetFullPath($OutputJson)
|
|
142
|
+
$outputDirectory = Split-Path -Parent $resolvedOutputJson
|
|
143
|
+
if (-not (Test-Path -LiteralPath $outputDirectory -PathType Container)) {
|
|
144
|
+
throw "OutputJson directory does not exist: $outputDirectory"
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
$audioDocument = Read-JsonDocument -Path $audioEventsPath -Description 'AudioEventsJson'
|
|
149
|
+
if ((Get-PropertyOrNull -Object $audioDocument -Name 'schema_version') -ne 'agent-verification-lab.audio-events.v1') {
|
|
150
|
+
throw "AudioEventsJson must use schema_version 'agent-verification-lab.audio-events.v1': $audioEventsPath"
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
$onsets = [System.Collections.Generic.List[double]]::new()
|
|
154
|
+
foreach ($event in @(Get-PropertyOrNull -Object $audioDocument -Name 'events')) {
|
|
155
|
+
if ($null -eq $event -or (Get-PropertyOrNull -Object $event -Name 'type') -ne 'onset') {
|
|
156
|
+
continue
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
$eventTime = Get-PropertyOrNull -Object $event -Name 'time'
|
|
160
|
+
if ($null -eq $eventTime) {
|
|
161
|
+
throw "Audio onset in AudioEventsJson has no time: $audioEventsPath"
|
|
162
|
+
}
|
|
163
|
+
[void]$onsets.Add((ConvertTo-FiniteSeconds -Value $eventTime -Description 'Audio onset time'))
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
[Nullable[double]]$audioOnsetSeconds = $null
|
|
167
|
+
if ($onsets.Count -gt 0) {
|
|
168
|
+
$audioOnsetSeconds = @($onsets | Sort-Object)[0]
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
[Nullable[double]]$speechFirstWordSeconds = $null
|
|
172
|
+
$speechStatus = 'NOT_SUPPLIED'
|
|
173
|
+
if (-not [string]::IsNullOrWhiteSpace($SpeechJson)) {
|
|
174
|
+
$speechPath = [IO.Path]::GetFullPath($SpeechJson)
|
|
175
|
+
$speechDocument = Read-JsonDocument -Path $speechPath -Description 'SpeechJson'
|
|
176
|
+
if ((Get-PropertyOrNull -Object $speechDocument -Name 'schema_version') -ne 'agent-verification-lab.speech.v1') {
|
|
177
|
+
throw "SpeechJson must use schema_version 'agent-verification-lab.speech.v1': $speechPath"
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
$wordStarts = [System.Collections.Generic.List[double]]::new()
|
|
181
|
+
foreach ($word in @(Get-PropertyOrNull -Object $speechDocument -Name 'words')) {
|
|
182
|
+
if ($null -eq $word) {
|
|
183
|
+
continue
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
$wordStart = Get-PropertyOrNull -Object $word -Name 'start'
|
|
187
|
+
if ($null -eq $wordStart) {
|
|
188
|
+
throw "SpeechJson word has no start timestamp: $speechPath"
|
|
189
|
+
}
|
|
190
|
+
[void]$wordStarts.Add((ConvertTo-FiniteSeconds -Value $wordStart -Description 'Speech word start'))
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if ($wordStarts.Count -gt 0) {
|
|
194
|
+
$speechFirstWordSeconds = @($wordStarts | Sort-Object)[0]
|
|
195
|
+
$speechStatus = 'MEASURED'
|
|
196
|
+
}
|
|
197
|
+
else {
|
|
198
|
+
$speechStatus = 'NO_WORDS'
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
[Nullable[double]]$gestureTimestamp = $null
|
|
203
|
+
if ($null -ne $GestureSeconds) {
|
|
204
|
+
$gestureTimestamp = ConvertTo-FiniteSeconds -Value $GestureSeconds -Description 'GestureSeconds'
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
$audioOffset = New-OffsetMeasurement -MeasuredSeconds $audioOnsetSeconds -VisualSeconds $visualSeconds
|
|
208
|
+
$status = if ($null -eq $audioOffset) {
|
|
209
|
+
'NO_AUDIO_ONSET'
|
|
210
|
+
}
|
|
211
|
+
elseif ([Math]::Abs([double]$audioOffset.milliseconds) -le $ToleranceMilliseconds) {
|
|
212
|
+
'IN_TOLERANCE'
|
|
213
|
+
}
|
|
214
|
+
else {
|
|
215
|
+
'OUT_OF_TOLERANCE'
|
|
216
|
+
}
|
|
217
|
+
$toleranceText = $ToleranceMilliseconds.ToString('0.###', [Globalization.CultureInfo]::InvariantCulture)
|
|
218
|
+
$toleranceBasis = if (-not $toleranceMillisecondsWasExplicit -and $ToleranceMilliseconds -eq 60) {
|
|
219
|
+
'60 ms default: 20 ms audio analysis window plus conservative timestamp quantization allowance.'
|
|
220
|
+
}
|
|
221
|
+
else {
|
|
222
|
+
"Caller-selected tolerance: ${toleranceText} ms."
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
$result = [pscustomobject]@{
|
|
226
|
+
schema_version = 'agent-verification-lab.sync.v1'
|
|
227
|
+
status = $status
|
|
228
|
+
sign_convention = $signConvention
|
|
229
|
+
tolerance = [pscustomobject]@{
|
|
230
|
+
seconds = [Math]::Round($ToleranceMilliseconds / 1000.0, 6)
|
|
231
|
+
milliseconds = [Math]::Round($ToleranceMilliseconds, 3)
|
|
232
|
+
basis = $toleranceBasis
|
|
233
|
+
}
|
|
234
|
+
visual_event = New-TimestampMeasurement -Seconds $visualSeconds
|
|
235
|
+
audio_onset = New-TimestampMeasurement -Seconds $audioOnsetSeconds
|
|
236
|
+
audio_minus_visual = $audioOffset
|
|
237
|
+
speech_status = $speechStatus
|
|
238
|
+
speech_first_word = New-TimestampMeasurement -Seconds $speechFirstWordSeconds
|
|
239
|
+
speech_first_word_minus_visual = New-OffsetMeasurement -MeasuredSeconds $speechFirstWordSeconds -VisualSeconds $visualSeconds
|
|
240
|
+
gesture = New-TimestampMeasurement -Seconds $gestureTimestamp
|
|
241
|
+
gesture_minus_visual = New-OffsetMeasurement -MeasuredSeconds $gestureTimestamp -VisualSeconds $visualSeconds
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
$json = $result | ConvertTo-Json -Depth 6
|
|
245
|
+
if ($null -ne $resolvedOutputJson) {
|
|
246
|
+
Write-NewOutputJson -Path $resolvedOutputJson -Json $json
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
Write-Output $json
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
[CmdletBinding()]
|
|
2
|
+
param(
|
|
3
|
+
[Parameter(Mandatory)]
|
|
4
|
+
[ValidateNotNullOrEmpty()]
|
|
5
|
+
[string]$InputVideo,
|
|
6
|
+
[double]$Interval = 0.5,
|
|
7
|
+
[int]$MaxFrames = 20,
|
|
8
|
+
[string]$OutputDirectory,
|
|
9
|
+
[string]$RunDirectory,
|
|
10
|
+
[switch]$Overwrite
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
$common = Join-Path $PSScriptRoot 'common.ps1'
|
|
14
|
+
. $common
|
|
15
|
+
|
|
16
|
+
if (-not (Test-Path -LiteralPath $InputVideo -PathType Leaf)) {
|
|
17
|
+
throw "Input video does not exist or is not a file: $InputVideo"
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
if ([double]::IsNaN($Interval) -or [double]::IsInfinity($Interval) -or $Interval -le 0) {
|
|
21
|
+
throw 'Interval must be positive.'
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if ($MaxFrames -lt 1 -or $MaxFrames -gt 100) {
|
|
25
|
+
throw 'MaxFrames must be between 1 and 100.'
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
$resolvedInputVideo = [IO.Path]::GetFullPath($InputVideo)
|
|
29
|
+
$run = New-VisualRun -RunDirectory $RunDirectory -Kind 'visual'
|
|
30
|
+
$resolvedRunDirectory = $run.RunDirectory
|
|
31
|
+
if ([string]::IsNullOrWhiteSpace($OutputDirectory)) {
|
|
32
|
+
$OutputDirectory = Join-Path $resolvedRunDirectory 'frames'
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
$resolvedOutputDirectory = [IO.Path]::GetFullPath($OutputDirectory)
|
|
36
|
+
if (Test-Path -LiteralPath $resolvedOutputDirectory) {
|
|
37
|
+
$outputItem = Get-Item -LiteralPath $resolvedOutputDirectory -Force
|
|
38
|
+
if (-not $outputItem.PSIsContainer) {
|
|
39
|
+
throw "Output directory path is a file: $resolvedOutputDirectory"
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
$existingItems = @(Get-ChildItem -LiteralPath $resolvedOutputDirectory -Force)
|
|
43
|
+
if ($existingItems.Count -gt 0 -and -not $Overwrite) {
|
|
44
|
+
throw "Output directory is not empty; specify -Overwrite to replace frame outputs: $resolvedOutputDirectory"
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if ($Overwrite) {
|
|
48
|
+
Get-ChildItem -LiteralPath $resolvedOutputDirectory -Filter 'frame-*.png' -File -Force | Remove-Item -Force
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
New-Item -ItemType Directory -Path $resolvedOutputDirectory -ErrorAction Stop | Out-Null
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
$invariantCulture = [Globalization.CultureInfo]::InvariantCulture
|
|
56
|
+
$intervalText = $Interval.ToString('G17', $invariantCulture)
|
|
57
|
+
$ffmpegArguments = [string[]]@(
|
|
58
|
+
'-i', $resolvedInputVideo,
|
|
59
|
+
'-vf', "fps=1/$intervalText",
|
|
60
|
+
'-frames:v', $MaxFrames.ToString($invariantCulture),
|
|
61
|
+
'-start_number', '1'
|
|
62
|
+
)
|
|
63
|
+
if ($Overwrite) {
|
|
64
|
+
$ffmpegArguments += '-y'
|
|
65
|
+
}
|
|
66
|
+
$ffmpegArguments += (Join-Path $resolvedOutputDirectory 'frame-%03d.png')
|
|
67
|
+
|
|
68
|
+
Invoke-Ffmpeg -FfmpegPath (Get-FfmpegPath) -Arguments $ffmpegArguments -Operation "frame extraction from $resolvedInputVideo"
|
|
69
|
+
$resultJson = Write-ResultJson -Result ([pscustomobject]@{
|
|
70
|
+
schema_version = 'agent-verification-lab.result.v1'
|
|
71
|
+
status = 'ok'
|
|
72
|
+
kind = $run.Kind
|
|
73
|
+
run_id = $run.RunId
|
|
74
|
+
run_directory = $resolvedRunDirectory
|
|
75
|
+
artifacts = @([pscustomobject]@{ role = 'frames'; path = $resolvedOutputDirectory; mime = 'application/x-directory' })
|
|
76
|
+
backend = [pscustomobject]@{ name = 'ffmpeg'; version = $null }
|
|
77
|
+
warnings = @()
|
|
78
|
+
})
|
|
79
|
+
Write-VisualResult -RunDirectory $resolvedRunDirectory -OutputPath $resolvedOutputDirectory -Kind 'frames' -ResultJsonPath $resultJson
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
[CmdletBinding()]
|
|
2
|
+
param(
|
|
3
|
+
[Parameter(Mandatory)]
|
|
4
|
+
[ValidateNotNullOrEmpty()]
|
|
5
|
+
[string]$Input,
|
|
6
|
+
[string]$OutputJson
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
Set-StrictMode -Version Latest
|
|
10
|
+
|
|
11
|
+
$inputPath = $PSBoundParameters['Input']
|
|
12
|
+
|
|
13
|
+
function Get-PropertyOrNull {
|
|
14
|
+
[OutputType([object])]
|
|
15
|
+
param(
|
|
16
|
+
[AllowNull()]
|
|
17
|
+
[object]$Object,
|
|
18
|
+
[Parameter(Mandatory)]
|
|
19
|
+
[string]$Name
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
if ($null -eq $Object) {
|
|
23
|
+
return $null
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
$property = $Object.PSObject.Properties[$Name]
|
|
27
|
+
if ($null -eq $property) {
|
|
28
|
+
return $null
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return $property.Value
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function Get-FirstStreamOfType {
|
|
35
|
+
[OutputType([object])]
|
|
36
|
+
param(
|
|
37
|
+
[AllowNull()]
|
|
38
|
+
[object[]]$Streams,
|
|
39
|
+
[Parameter(Mandatory)]
|
|
40
|
+
[string]$CodecType
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
$matches = @($Streams | Where-Object { (Get-PropertyOrNull -Object $_ -Name 'codec_type') -eq $CodecType })
|
|
44
|
+
if ($matches.Count -eq 0) {
|
|
45
|
+
return $null
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return $matches[0]
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function New-StreamSummary {
|
|
52
|
+
[OutputType([pscustomobject])]
|
|
53
|
+
param(
|
|
54
|
+
[AllowNull()]
|
|
55
|
+
[object]$Stream
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
return [pscustomobject]@{
|
|
59
|
+
start_time = Get-PropertyOrNull -Object $Stream -Name 'start_time'
|
|
60
|
+
duration = Get-PropertyOrNull -Object $Stream -Name 'duration'
|
|
61
|
+
time_base = Get-PropertyOrNull -Object $Stream -Name 'time_base'
|
|
62
|
+
codec = Get-PropertyOrNull -Object $Stream -Name 'codec_name'
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (-not (Test-Path -LiteralPath $inputPath -PathType Leaf)) {
|
|
67
|
+
throw "Input media file does not exist or is not a file: $inputPath"
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
$resolvedInput = [IO.Path]::GetFullPath($inputPath)
|
|
71
|
+
if (-not [string]::IsNullOrWhiteSpace($OutputJson)) {
|
|
72
|
+
$resolvedOutputJson = [IO.Path]::GetFullPath($OutputJson)
|
|
73
|
+
$outputDirectory = Split-Path -Parent $resolvedOutputJson
|
|
74
|
+
if (-not (Test-Path -LiteralPath $outputDirectory -PathType Container)) {
|
|
75
|
+
throw "OutputJson directory does not exist: $outputDirectory"
|
|
76
|
+
}
|
|
77
|
+
if (Test-Path -LiteralPath $resolvedOutputJson) {
|
|
78
|
+
throw "OutputJson already exists; refusing to overwrite: $resolvedOutputJson"
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
try {
|
|
83
|
+
$ffprobeCommand = Get-Command ffprobe -ErrorAction Stop
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
throw 'ffprobe was not found on PATH.'
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if ($ffprobeCommand.CommandType -ne [System.Management.Automation.CommandTypes]::Application) {
|
|
90
|
+
throw "ffprobe resolved to '$($ffprobeCommand.CommandType)', not an executable application."
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
$ffprobeArguments = @(
|
|
94
|
+
'-v', 'error',
|
|
95
|
+
'-show_entries', 'format=start_time,duration,size:stream=codec_type,codec_name,start_time,duration,time_base,avg_frame_rate,width,height,sample_rate',
|
|
96
|
+
'-of', 'json',
|
|
97
|
+
$resolvedInput
|
|
98
|
+
)
|
|
99
|
+
$ffprobeOutput = & $ffprobeCommand.Source @ffprobeArguments 2>&1
|
|
100
|
+
$ffprobeExitCode = $LASTEXITCODE
|
|
101
|
+
if ($ffprobeExitCode -ne 0) {
|
|
102
|
+
$diagnostics = ($ffprobeOutput | ForEach-Object { $_.ToString() }) -join [Environment]::NewLine
|
|
103
|
+
throw "ffprobe inspection failed with exit code $ffprobeExitCode for '$resolvedInput'.$([Environment]::NewLine)$diagnostics"
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
try {
|
|
107
|
+
$probe = (($ffprobeOutput | ForEach-Object { $_.ToString() }) -join [Environment]::NewLine | ConvertFrom-Json -ErrorAction Stop)
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
throw "ffprobe returned invalid JSON for '$resolvedInput': $($_.Exception.Message)"
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
$videoStream = Get-FirstStreamOfType -Streams @($probe.streams) -CodecType 'video'
|
|
114
|
+
$audioStream = Get-FirstStreamOfType -Streams @($probe.streams) -CodecType 'audio'
|
|
115
|
+
$video = New-StreamSummary -Stream $videoStream
|
|
116
|
+
$video | Add-Member -NotePropertyName frame_rate -NotePropertyValue (Get-PropertyOrNull -Object $videoStream -Name 'avg_frame_rate')
|
|
117
|
+
$video | Add-Member -NotePropertyName width -NotePropertyValue (Get-PropertyOrNull -Object $videoStream -Name 'width')
|
|
118
|
+
$video | Add-Member -NotePropertyName height -NotePropertyValue (Get-PropertyOrNull -Object $videoStream -Name 'height')
|
|
119
|
+
$audio = New-StreamSummary -Stream $audioStream
|
|
120
|
+
$audio | Add-Member -NotePropertyName sample_rate -NotePropertyValue (Get-PropertyOrNull -Object $audioStream -Name 'sample_rate')
|
|
121
|
+
|
|
122
|
+
$result = [pscustomobject]@{
|
|
123
|
+
schema_version = 'agent-verification-lab.media.v1'
|
|
124
|
+
format = [pscustomobject]@{
|
|
125
|
+
start_time = Get-PropertyOrNull -Object $probe.format -Name 'start_time'
|
|
126
|
+
duration = Get-PropertyOrNull -Object $probe.format -Name 'duration'
|
|
127
|
+
size = Get-PropertyOrNull -Object $probe.format -Name 'size'
|
|
128
|
+
}
|
|
129
|
+
video = $video
|
|
130
|
+
audio = $audio
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
$json = $result | ConvertTo-Json -Depth 4
|
|
134
|
+
if (-not [string]::IsNullOrWhiteSpace($OutputJson)) {
|
|
135
|
+
Set-Content -LiteralPath $resolvedOutputJson -Value $json -Encoding utf8NoBOM -NoNewline -ErrorAction Stop
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
Write-Output $json
|