Skip to content

Commit cfdb1c1

Browse files
committed
test(verify): run both Windows decoders and flag silent decoder fallbacks
Windows now runs the Media Foundation default and the forced FFmpeg decoder as separate cases (labels mf and ffmpeg). Every case records which decoder each stream actually used and fails if it is not the requested one, so a Media Foundation or AVAssetReader fallback to FFmpeg cannot pass under the wrong label. Adds a temporary workflow that runs the suite and the decoder model fuzz on windows-2022 and ubuntu-24.04.
1 parent 48357f0 commit cfdb1c1

5 files changed

Lines changed: 323 additions & 3 deletions

File tree

Lines changed: 258 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
1+
name: cap-verify platforms (temporary triage)
2+
3+
# Temporary workflow for the PLATFORMS track: runs the cap-verify suite and
4+
# the decoder model fuzz on Windows and Linux runners, several times, and
5+
# uploads the per-case JSON reports. Not for merging; the CI-INFRA track owns
6+
# engine-verify.yml.
7+
8+
on:
9+
push:
10+
branches:
11+
- ci/verify-platforms
12+
workflow_dispatch:
13+
inputs:
14+
repeats:
15+
description: "Times to run each test binary"
16+
default: "3"
17+
case:
18+
description: "CAP_VERIFY_CASE filter (glob, comma separated)"
19+
default: ""
20+
21+
concurrency:
22+
group: verify-platforms-${{ github.ref_name }}
23+
cancel-in-progress: true
24+
25+
permissions:
26+
contents: read
27+
28+
env:
29+
CARGO_TERM_COLOR: never
30+
CARGO_INCREMENTAL: "0"
31+
RUST_BACKTRACE: "1"
32+
33+
jobs:
34+
suite:
35+
name: suite (${{ matrix.runner }})
36+
strategy:
37+
fail-fast: false
38+
matrix:
39+
runner:
40+
- windows-2022
41+
- ubuntu-24.04
42+
runs-on: ${{ matrix.runner }}
43+
timeout-minutes: 150
44+
env:
45+
REPEATS: ${{ github.event.inputs.repeats || '3' }}
46+
CAP_VERIFY_CASE: ${{ github.event.inputs.case || '' }}
47+
CAP_VERIFY_FIXTURE_DIR: ${{ github.workspace }}/verify-fixtures
48+
OUT: ${{ github.workspace }}/verify-out
49+
steps:
50+
- uses: actions/checkout@v4
51+
52+
- uses: dtolnay/rust-toolchain@1.88.0
53+
54+
- name: Rust cache (restore only)
55+
uses: ./.github/actions/setup-rust-cache
56+
with:
57+
target: ${{ runner.os == 'Windows' && 'x86_64-pc-windows-msvc' || 'x86_64-unknown-linux-gnu' }}
58+
prefix-key: v1-rust-sync
59+
save-cache: "false"
60+
61+
- uses: ./.github/actions/install-desktop-deps
62+
63+
- uses: actions/setup-node@v4
64+
with:
65+
node-version: 24
66+
67+
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
68+
with:
69+
python-version: "3.13"
70+
71+
- name: Native dependencies
72+
env:
73+
RUST_TARGET_TRIPLE: ${{ runner.os == 'Linux' && 'x86_64-unknown-linux-gnu' || 'x86_64-pc-windows-msvc' }}
74+
run: node scripts/setup.js
75+
76+
- name: FFmpeg DLLs on PATH (Windows)
77+
if: runner.os == 'Windows'
78+
shell: pwsh
79+
run: Add-Content -Path $env:GITHUB_PATH -Value "${{ github.workspace }}\\target\\ffmpeg\\bin"
80+
81+
- name: Native libs on LD_LIBRARY_PATH (Linux)
82+
if: runner.os == 'Linux'
83+
run: echo "LD_LIBRARY_PATH=${{ github.workspace }}/target/native-deps/lib:${{ github.workspace }}/target/debug" >> "$GITHUB_ENV"
84+
85+
- name: Software Vulkan (Linux)
86+
if: runner.os == 'Linux'
87+
run: |
88+
sudo apt-get update
89+
sudo apt-get install -y mesa-vulkan-drivers libvulkan1 vulkan-tools
90+
vulkaninfo --summary || true
91+
92+
- name: Virtual audio device (Windows)
93+
if: runner.os == 'Windows'
94+
timeout-minutes: 10
95+
shell: pwsh
96+
run: |
97+
Invoke-WebRequest -Uri https://github.com/duncanthrax/scream/releases/download/3.6/Scream3.6.zip -OutFile Scream3.6.zip
98+
$expected = "25ea5e778b4e6995a98d448b9b5f6d321f681663f1aeeec69d8e63183d008b19"
99+
$actual = (Get-FileHash -Algorithm SHA256 Scream3.6.zip).Hash.ToLowerInvariant()
100+
if ($actual -ne $expected) { throw "Scream3.6.zip SHA-256 mismatch: got $actual, expected $expected" }
101+
Expand-Archive -Path Scream3.6.zip -DestinationPath Scream
102+
$cert = (Get-AuthenticodeSignature Scream\Install\driver\Scream.sys).SignerCertificate
103+
$store = [System.Security.Cryptography.X509Certificates.X509Store]::new("TrustedPublisher", "LocalMachine")
104+
$store.Open("ReadWrite")
105+
$store.Add($cert)
106+
$store.Close()
107+
Scream\Install\helpers\devcon.exe install Scream\Install\driver\Scream.inf *Scream
108+
if ($LASTEXITCODE -gt 1) { throw "devcon install failed with exit code $LASTEXITCODE" }
109+
Start-Service -Name Audiosrv -ErrorAction SilentlyContinue
110+
111+
- name: Runner facts
112+
shell: bash
113+
run: |
114+
mkdir -p "$OUT"
115+
{
116+
echo "runner=${{ matrix.runner }}"
117+
nproc 2>/dev/null || echo "NUMBER_OF_PROCESSORS=$NUMBER_OF_PROCESSORS"
118+
rustc -V
119+
} | tee "$OUT/runner.txt"
120+
121+
- name: Build tests
122+
shell: bash
123+
run: |
124+
start=$(date +%s)
125+
cargo test --locked -p cap-verify --no-run 2>&1 | tail -40
126+
echo "build_seconds=$(( $(date +%s) - start ))" | tee -a "$OUT/timings.txt"
127+
128+
- name: Run suite (${{ env.REPEATS }}x)
129+
shell: bash
130+
run: |
131+
set +e
132+
status=0
133+
for run in $(seq 1 "$REPEATS"); do
134+
for test in codec export playback seek; do
135+
export CAP_VERIFY_REPORT_DIR="$OUT/run$run/$test"
136+
mkdir -p "$CAP_VERIFY_REPORT_DIR"
137+
start=$(date +%s)
138+
cargo test --locked -p cap-verify --test "$test" -- --nocapture > "$OUT/run$run/$test.log" 2>&1
139+
code=$?
140+
secs=$(( $(date +%s) - start ))
141+
echo "run$run $test exit=$code seconds=$secs" | tee -a "$OUT/timings.txt"
142+
grep -E "^\[cap-verify\] (pass|xfail|FAIL|XPASS)|^test .* \.\.\. |panicked|error" "$OUT/run$run/$test.log" | head -80
143+
[ $code -ne 0 ] && status=1
144+
done
145+
done
146+
exit $status
147+
148+
- name: Summarise
149+
if: always()
150+
shell: bash
151+
run: |
152+
python - "$OUT" >> "$GITHUB_STEP_SUMMARY" <<'PY'
153+
import glob, json, os, sys, collections
154+
out = sys.argv[1]
155+
cases = collections.OrderedDict()
156+
for path in sorted(glob.glob(os.path.join(out, "run*", "*", "*.jsonl"))):
157+
run = path.split(os.sep)[-3]
158+
for line in open(path, encoding="utf-8"):
159+
r = json.loads(line)
160+
cases.setdefault(r["id"], []).append((run, r["outcome"], r["seconds"], r["summary"].splitlines()[0][:160]))
161+
print(f"## cap-verify on {os.environ.get('RUNNER_OS')}")
162+
print()
163+
print("| case | outcomes | seconds | first line |")
164+
print("|---|---|---|---|")
165+
for cid, rs in cases.items():
166+
outs = " ".join(o for _, o, _, _ in rs)
167+
secs = " ".join(f"{s:.1f}" for _, _, s, _ in rs)
168+
print(f"| {cid} | {outs} | {secs} | {rs[0][3].replace('|', '/')} |")
169+
print()
170+
for line in open(os.path.join(out, "timings.txt"), encoding="utf-8"):
171+
print(f"- {line.strip()}")
172+
PY
173+
174+
- name: Upload reports
175+
if: always()
176+
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
177+
with:
178+
name: verify-${{ matrix.runner }}
179+
path: ${{ github.workspace }}/verify-out
180+
if-no-files-found: warn
181+
182+
decoder-model:
183+
name: decoder model (${{ matrix.runner }})
184+
strategy:
185+
fail-fast: false
186+
matrix:
187+
runner:
188+
- windows-2022
189+
- ubuntu-24.04
190+
runs-on: ${{ matrix.runner }}
191+
timeout-minutes: 120
192+
env:
193+
REPEATS: ${{ github.event.inputs.repeats || '3' }}
194+
OUT: ${{ github.workspace }}/model-out
195+
steps:
196+
- uses: actions/checkout@v4
197+
with:
198+
ref: ci/verify-decoder-model
199+
200+
- uses: dtolnay/rust-toolchain@1.88.0
201+
202+
- name: Rust cache (restore only)
203+
uses: ./.github/actions/setup-rust-cache
204+
with:
205+
target: ${{ runner.os == 'Windows' && 'x86_64-pc-windows-msvc' || 'x86_64-unknown-linux-gnu' }}
206+
prefix-key: v1-rust-sync
207+
save-cache: "false"
208+
209+
- uses: ./.github/actions/install-desktop-deps
210+
211+
- uses: actions/setup-node@v4
212+
with:
213+
node-version: 24
214+
215+
- name: Native dependencies
216+
env:
217+
RUST_TARGET_TRIPLE: ${{ runner.os == 'Linux' && 'x86_64-unknown-linux-gnu' || 'x86_64-pc-windows-msvc' }}
218+
run: node scripts/setup.js
219+
220+
- name: FFmpeg DLLs on PATH (Windows)
221+
if: runner.os == 'Windows'
222+
shell: pwsh
223+
run: Add-Content -Path $env:GITHUB_PATH -Value "${{ github.workspace }}\\target\\ffmpeg\\bin"
224+
225+
- name: Native libs on LD_LIBRARY_PATH (Linux)
226+
if: runner.os == 'Linux'
227+
run: echo "LD_LIBRARY_PATH=${{ github.workspace }}/target/native-deps/lib:${{ github.workspace }}/target/debug" >> "$GITHUB_ENV"
228+
229+
- name: Build
230+
shell: bash
231+
run: |
232+
mkdir -p "$OUT"
233+
start=$(date +%s)
234+
cargo test --locked -p cap-rendering --lib --no-run 2>&1 | tail -20
235+
echo "build_seconds=$(( $(date +%s) - start ))" | tee -a "$OUT/timings.txt"
236+
237+
- name: Run decoder model (${{ env.REPEATS }}x)
238+
shell: bash
239+
run: |
240+
set +e
241+
status=0
242+
for run in $(seq 1 "$REPEATS"); do
243+
start=$(date +%s)
244+
cargo test --locked -p cap-rendering --lib decoder::model -- --nocapture > "$OUT/run$run.log" 2>&1
245+
code=$?
246+
echo "run$run exit=$code seconds=$(( $(date +%s) - start ))" | tee -a "$OUT/timings.txt"
247+
tail -60 "$OUT/run$run.log"
248+
[ $code -ne 0 ] && status=1
249+
done
250+
exit $status
251+
252+
- name: Upload logs
253+
if: always()
254+
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
255+
with:
256+
name: decoder-model-${{ matrix.runner }}
257+
path: ${{ github.workspace }}/model-out
258+
if-no-files-found: warn

‎crates/verify/tests/common/mod.rs‎

Lines changed: 59 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,8 @@ pub enum Format {
8888

8989
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
9090
pub enum Decoder {
91-
/// The platform default (AVAssetReader on macOS, FFmpeg elsewhere).
91+
/// The platform default: AVAssetReader on macOS, Media Foundation on
92+
/// Windows, FFmpeg on Linux.
9293
Default,
9394
/// `--force-ffmpeg-decoder`.
9495
Ffmpeg,
@@ -98,19 +99,69 @@ impl Decoder {
9899
pub fn label(self) -> &'static str {
99100
match self {
100101
Decoder::Default if cfg!(target_os = "macos") => "avf",
102+
Decoder::Default if cfg!(target_os = "windows") => "mf",
101103
Decoder::Default => "ffmpeg",
102104
Decoder::Ffmpeg => "ffmpeg",
103105
}
104106
}
105107

106108
/// Decoders that are actually distinct on this platform.
107109
pub fn all() -> Vec<Decoder> {
108-
if cfg!(target_os = "macos") {
110+
if cfg!(any(target_os = "macos", target_os = "windows")) {
109111
vec![Decoder::Default, Decoder::Ffmpeg]
110112
} else {
111113
vec![Decoder::Default]
112114
}
113115
}
116+
117+
/// The `DecoderType` display prefix this choice must produce. A silent
118+
/// fallback (Media Foundation or AVAssetReader failing over to FFmpeg)
119+
/// would otherwise test the wrong decoder under this label.
120+
pub fn expected_type(self) -> &'static str {
121+
match self {
122+
Decoder::Default if cfg!(target_os = "macos") => "AVAssetReader",
123+
Decoder::Default if cfg!(target_os = "windows") => "MediaFoundation",
124+
_ => "FFmpeg",
125+
}
126+
}
127+
}
128+
129+
/// Records which decoder each stream actually used and flags any stream
130+
/// that is not on the requested decoder.
131+
pub fn check_decoders(case: &mut Case, decoder: Decoder, segments: &[cap_editor::SegmentMedia]) {
132+
let mut used = Vec::new();
133+
for (i, segment) in segments.iter().enumerate() {
134+
let streams =
135+
std::iter::once(("screen", Some(segment.decoders.screen_decoder_status()))).chain(
136+
std::iter::once(("camera", segment.decoders.camera_decoder_status())),
137+
);
138+
for (stream, status) in streams {
139+
let Some(status) = status else { continue };
140+
let kind = status.decoder_type.to_string();
141+
used.push(format!("{stream}{i}={kind}"));
142+
if !kind.starts_with(decoder.expected_type()) {
143+
case.problem(format!(
144+
"segment {i} {stream} decoded by {kind}, expected {} (fallback reason: {})",
145+
decoder.expected_type(),
146+
status.fallback_reason.as_deref().unwrap_or("none")
147+
));
148+
}
149+
}
150+
}
151+
case.note(format!("decoders: {}", used.join(" ")));
152+
}
153+
154+
/// Opens the project's decoders the way the exporter does and checks them.
155+
/// The exporter's own decoders are private, so this probes the same
156+
/// `create_segments` decision on the same files.
157+
pub async fn probe_decoders(case: &mut Case, project: &Project, decoder: Decoder) {
158+
let Some(meta) = project.meta.studio_meta() else {
159+
return;
160+
};
161+
match cap_editor::create_segments(&project.meta, meta, decoder == Decoder::Ffmpeg).await {
162+
Ok(segments) => check_decoders(case, decoder, &segments),
163+
Err(e) => case.problem(format!("decoder probe failed: {e}")),
164+
}
114165
}
115166

116167
pub struct ExportRun {
@@ -342,6 +393,12 @@ impl Player {
342393
})
343394
}
344395

396+
/// Flags streams that are not on `decoder` (checked after the run, so a
397+
/// mid-run fallback is caught too).
398+
pub fn check_decoders(&self, case: &mut Case, decoder: Decoder) {
399+
check_decoders(case, decoder, &self.segments);
400+
}
401+
345402
pub fn prefer_sequential(&self) {
346403
for segment in &self.segments {
347404
segment.decoders.prefer_sequential_access();

‎crates/verify/tests/export.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ fn run(
5959
format,
6060
decoder,
6161
};
62+
rt.block_on(common::probe_decoders(&mut case, &project, decoder));
6263
let first = dir.path().join(format!("a.{ext}"));
6364
match rt.block_on(common::export(&project, config.clone(), &run, &first)) {
6465
Err(e) => case.problem(format!("export failed: {e}")),

‎crates/verify/tests/playback.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ fn sequential(
7070
for &f in &frames {
7171
readouts.push(player.frame(f, fps, f == first).await?);
7272
}
73+
player.check_decoders(&mut case, decoder);
7374
Ok::<_, String>(readouts)
7475
});
7576
match result {

‎crates/verify/tests/seek.rs‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ fn run(
4646
for &(frame, seek) in &requests {
4747
readouts.push(player.frame(frame, fps, seek).await?);
4848
}
49+
player.check_decoders(&mut case, decoder);
4950
Ok::<_, String>(readouts)
5051
});
5152
let frames: Vec<u32> = requests.iter().map(|r| r.0).collect();
@@ -233,7 +234,9 @@ fn cold_open() {
233234
for &frame in &frames {
234235
let result = rt.block_on(async {
235236
let mut player = Player::open(&project, config.clone(), decoder, RES).await?;
236-
player.frame(frame, 30, true).await
237+
let readout = player.frame(frame, 30, true).await;
238+
player.check_decoders(&mut case, decoder);
239+
readout
237240
});
238241
match result {
239242
Ok(readout) => readouts.push(readout),

0 commit comments

Comments
 (0)