From 7480c7d3b31a89c2e6a6d60a92d0b10e86dcf74a Mon Sep 17 00:00:00 2001 From: SteveYu000 <111431635+SteveYu000@users.noreply.github.com> Date: Sun, 1 Nov 2026 20:26:18 +0800 Subject: [PATCH 1/2] Preserve container sample aspect ratio through smart rendering AV1 MP4/Matroska display aspect can live on AVStream while codecpar has no SAR. Read the effective ratio using av_guess_sample_aspect_ratio, keep the existing codecpar fallback, and carry it to boundary encoders and the output stream. Add a standalone SAR suite covering copy, mixed and encoded ranges for AVC/HEVC/AV1 in MP4/MKV, plus public outline checks using conformdiag. Register only this suite in CI and document its entry point. Validation: same-input exports improve from 30/36 to 36/36, outline checks from 10/12 to 12/12. Workspace tests 570 passed with four known Windows baseline failures filtered; clippy exits 0. Source clocks, VFR handling, AV1 configuration policy and M2TS behaviour are unchanged. SAR-only split from PR #10. --- docs/technical/building.md | 1 + rust/crates/core/src/cut.rs | 15 ++++- rust/crates/core/src/lib.rs | 7 ++- tests/ci.sh | 2 +- tests/run_sar_tests.sh | 5 ++ tests/sar_tests.py | 112 ++++++++++++++++++++++++++++++++++++ 6 files changed, 139 insertions(+), 3 deletions(-) create mode 100644 tests/run_sar_tests.sh create mode 100644 tests/sar_tests.py diff --git a/docs/technical/building.md b/docs/technical/building.md index 2543cf6..8826a90 100644 --- a/docs/technical/building.md +++ b/docs/technical/building.md @@ -85,6 +85,7 @@ bash tests/run_dvd_tests.sh # a DVD-Video disc, as a folder and as an bash tests/run_bd_audio_tests.sh # the sound a disc carries, written out 39 bash tests/run_vc1_tests.sh # the VC-1 encoder, put through a decoder 4 bash tests/run_vp9_av1_tests.sh # VP9 and AV1: cut, and still the source's bytes 5 +bash tests/run_sar_tests.sh # container SAR: input outline and three cuts 48 bash tests/run_vfr_tests.sh # variable rate, both ways round 8 bash tests/run_demux_tests.sh # what a reader loses by reading what it does not 9 bash tests/run_transrate_tests.sh # fitting a disc: the identity, the size, the cost 15 diff --git a/rust/crates/core/src/cut.rs b/rust/crates/core/src/cut.rs index cf8f6e7..44403be 100644 --- a/rust/crates/core/src/cut.rs +++ b/rust/crates/core/src/cut.rs @@ -3438,12 +3438,22 @@ fn take_graphics( } fn open_input(path: &str) -> Result<(crate::input::Demux, usize)> { - let ictx = crate::input::demux(&path)?; + let mut ictx = crate::input::demux(&path)?; let index = ictx .streams() .best(ff::media::Type::Video) .ok_or_else(|| anyhow!("no video stream"))? .index(); + // Carry container-only SAR to copied parameters and boundary encoders. + unsafe { + let stream = *(*ictx.as_mut_ptr()).streams.add(index); + let sar = ff::ffi::av_guess_sample_aspect_ratio( + ictx.as_mut_ptr(), stream, std::ptr::null_mut(), + ); + if sar.num > 0 && sar.den > 0 { + (*(*stream).codecpar).sample_aspect_ratio = sar; + } + } Ok((ictx, index)) } @@ -10055,6 +10065,9 @@ fn cut_into( let mut ost = octx.add_stream(ff::encoder::find(ff::codec::Id::None))?; ost.set_parameters(params.clone()); ost.set_time_base(grid.time_base()); + unsafe { + (*ost.as_mut_ptr()).sample_aspect_ratio = (*params.as_ptr()).sample_aspect_ratio; + } if let Some((num, den)) = stated_rate { ost.set_avg_frame_rate(ff::Rational::new(num, den)); } diff --git a/rust/crates/core/src/lib.rs b/rust/crates/core/src/lib.rs index 79a6dad..44f23d5 100644 --- a/rust/crates/core/src/lib.rs +++ b/rust/crates/core/src/lib.rs @@ -1777,7 +1777,12 @@ fn outline_of(path: &str) -> Result<(Outline, input::Demux)> { } else { std::slice::from_raw_parts((*p).extradata, (*p).extradata_size as usize).to_vec() }; - let sar = (*p).sample_aspect_ratio; + // Container-only display aspect (notably AV1) can live on AVStream + // without a corresponding SAR in codecpar. + let sar = ff::ffi::av_guess_sample_aspect_ratio( + ictx.as_ptr() as *mut _, stream.as_ptr() as *mut _, std::ptr::null_mut(), + ); + let sar = if sar.num > 0 && sar.den > 0 { sar } else { (*p).sample_aspect_ratio }; ( (*p).width as u32, (*p).height as u32, diff --git a/tests/ci.sh b/tests/ci.sh index 55166ad..efc930c 100755 --- a/tests/ci.sh +++ b/tests/ci.sh @@ -28,7 +28,7 @@ SUITES=( audio_codec audio_smart audio_format audio_head bilingual index proxy disc bdav udf dvd bd_audio - vp9_av1 vfr demux transrate join + vp9_av1 sar vfr demux transrate join ) [ $# -gt 0 ] && SUITES=("$@") diff --git a/tests/run_sar_tests.sh b/tests/run_sar_tests.sh new file mode 100644 index 0000000..a216af2 --- /dev/null +++ b/tests/run_sar_tests.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -eu +cd "$(dirname "$0")/.." +[ -x rust/target/release/examples/conformdiag ] || (cd rust && cargo build --release --locked --example conformdiag) +python3 tests/sar_tests.py "$@" diff --git a/tests/sar_tests.py b/tests/sar_tests.py new file mode 100644 index 0000000..f46752e --- /dev/null +++ b/tests/sar_tests.py @@ -0,0 +1,112 @@ +"""Container SAR regression, independent of AV1 guards and clock changes. + +Requires Python's standard library and FFmpeg with libx264, libx265 and +libaom-av1. Retains generated media, commands and hashes in a new directory. +""" +import argparse +from fractions import Fraction +import hashlib +import json +import os +from pathlib import Path +import re +import subprocess +import tempfile + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--cut', type=Path, default=Path('rust/target/release/smartcut' + ('.exe' if os.name == 'nt' else ''))) + parser.add_argument('--ffmpeg', default=os.environ.get('FFMPEG', 'ffmpeg')) + parser.add_argument('--ffprobe', default=os.environ.get('FFPROBE', 'ffprobe')) + parser.add_argument('--work-dir', type=Path) + parser.add_argument('--fixtures', type=Path, help='reuse/generate source clips here for exact paired comparisons') + parser.add_argument('--outline-probe', type=Path, help='existing conformdiag example; defaults beside the CLI under examples/') + args = parser.parse_args() + work = Path(tempfile.mkdtemp(prefix='smartcut-sar-', dir=args.work_dir)) + fixtures = args.fixtures.resolve() if args.fixtures else work + fixtures.mkdir(parents=True, exist_ok=True) + cut = args.cut.resolve() + outline_probe = (args.outline_probe or cut.parent/'examples'/('conformdiag.exe' if os.name == 'nt' else 'conformdiag')).resolve() + commands, cases, outlines = [], [], [] + + def sha(path): + with path.open('rb') as stream: + return hashlib.file_digest(stream, 'sha256').hexdigest() + + def run(command): + command = list(map(str, command)) + p = subprocess.run(command, capture_output=True, timeout=180) + commands.append({'command': command, 'exit_code': p.returncode}) + assert p.returncode == 0, f'exit {p.returncode}: {command}\n{p.stderr.decode(errors="replace")[-2000:]}' + return p + + def probe(path): + data = run([args.ffprobe, '-v', 'error', '-select_streams', 'v:0', '-count_frames', + '-show_entries', 'stream=codec_name,width,height,sample_aspect_ratio,nb_read_frames', + '-of', 'json', path]).stdout + return json.loads(data)['streams'][0] + + def sar(stream): + value = stream.get('sample_aspect_ratio', 'N/A') + return Fraction(value.replace(':', '/')) if value not in ('N/A', '0:1') else Fraction(1) + + encoders = { + 'h264': ['libx264', '-preset', 'medium', '-crf', '14'], + 'hevc': ['libx265', '-preset', 'medium', '-crf', '18', '-x265-params', 'log-level=error:pools=none'], + 'av1': ['libaom-av1', '-cpu-used', '8', '-crf', '22', '-b:v', '0'], + } + cuts = {'copy': ('0-4.8', 120), 'mixed': ('0.52-4.28', 94), 'encoded': ('0.52-0.76', 6)} + for codec, encoder in encoders.items(): + for ratio in ('4/3', '1/1'): + mp4 = fixtures/f'{codec}-sar{ratio.replace("/", "_")}.mp4' + if not mp4.exists(): + run([args.ffmpeg, '-v', 'error', '-y', '-f', 'lavfi', '-i', 'testsrc2=size=192x112:rate=25', + '-frames:v', '120', '-an', '-vf', f'setsar={ratio}', '-c:v', *encoder, + '-g', '24', '-pix_fmt', 'yuv420p', mp4]) + for container in ('.mp4', '.mkv'): + source = mp4.with_suffix(container) + if container == '.mkv' and not source.exists(): + run([args.ffmpeg, '-v', 'error', '-y', '-i', mp4, '-map', '0:v:0', '-c', 'copy', source]) + original = probe(source) + assert sar(original) == Fraction(ratio), ('fixture SAR', source, original) + shape = run([outline_probe, '--shape', source]).stdout.decode(errors='replace') + found = re.search(r'\bsar=([\d.]+)', shape) + correct = found is not None and abs(float(found[1])-float(Fraction(ratio))) < 0.0001 + outlines.append({'input': source.name, 'input_sha256': sha(source), + 'expected_sar': ratio, 'passed': correct, 'shape': shape}) + print('ok' if correct else 'FAIL', 'outline', codec, ratio, container, flush=True) + for mode, (bounds, frames) in cuts.items(): + out = work/f'{source.stem}-{mode}{container}' + row = {'codec': codec, 'container': container, 'sar': ratio, 'mode': mode, + 'input': source.name, 'input_sha256': sha(source), 'output': out.name} + try: + log = run([cut, source, '--no-chapters', '--keep', bounds, '-o', out]) + out.with_suffix(container+'.log').write_bytes(log.stdout+log.stderr) + made = probe(out) + assert sar(made) == Fraction(ratio), ('output SAR', made) + assert (made['width'], made['height']) == (192, 112), made + assert made['codec_name'] == codec, made + assert int(made['nb_read_frames']) == frames, made + run([args.ffmpeg, '-v', 'error', '-xerror', '-err_detect', 'explode', '-i', out, + '-map', '0:v:0', '-f', 'null', '-']) + row.update(passed=True, output_sha256=sha(out)) + except (AssertionError, subprocess.TimeoutExpired) as error: + row.update(passed=False, error=str(error)) + if out.exists(): + row['output_sha256'] = sha(out) + cases.append(row) + print('ok' if row['passed'] else 'FAIL', codec, ratio, container, mode, + row.get('error', ''), flush=True) + record = {'cut': str(cut), 'cut_sha256': sha(cut), 'generator_sha256': sha(Path(__file__)), + 'outline_probe_sha256': sha(outline_probe), 'fixture_directory': str(fixtures), 'commands': commands, + 'cases': cases, 'outline_checks': outlines} + (work/'results.json').write_text(json.dumps(record, indent=2)+'\n', encoding='utf-8') + checks = cases + outlines + failures = sum(not c['passed'] for c in checks) + print(f'{len(checks)-failures} passed, {failures} failed ({len(cases)} exports, {len(outlines)} outlines); evidence: {work}', flush=True) + raise SystemExit(1 if failures else 0) + + +if __name__ == '__main__': + main() From ac593fc23dd2db0dc2887d0c45b3860c600ac805 Mon Sep 17 00:00:00 2001 From: SteveYu000 <111431635+SteveYu000@users.noreply.github.com> Date: Mon, 2 Nov 2026 13:10:46 +0800 Subject: [PATCH 2/2] Keep container SAR out of boundary encoder parameters Preserve the input codecpar SAR for boundary encoders. Use the guessed display SAR only for the outline and the output stream's metadata. Set both output SAR fields to satisfy libavformat's consistency check without changing the source parameters or the copied SPSs. Reproduce the reviewer's container override: 12 SPSs now all retain 1:1 while the output container keeps 4:3 DAR. Add AVC/HEVC regressions with square and non-square coded SAR, MP4/MKV outputs and TS header inspection. Correct the building.md column and state 44 exports plus 16 outlines. Actually build with Rust 1.98/MSVC/FFmpeg 7.1.1: 60/60 media checks, 570 workspace tests passed with four known Windows baseline failures filtered; Clippy exits zero. Timing remains unchanged in all 44 outputs, and the eight new override exports retain all baseline video packets and decoded pixels. Document the pre-existing HEVC TS remux decode failure separately from the SAR assertions. Addresses the review on PR #11; no AV1 configuration, VFR or M2TS changes. --- docs/technical/building.md | 2 +- rust/crates/core/src/cut.rs | 31 ++++++----- tests/sar_tests.py | 104 ++++++++++++++++++++++++++++++++++-- 3 files changed, 119 insertions(+), 18 deletions(-) diff --git a/docs/technical/building.md b/docs/technical/building.md index 8826a90..009675d 100644 --- a/docs/technical/building.md +++ b/docs/technical/building.md @@ -85,7 +85,7 @@ bash tests/run_dvd_tests.sh # a DVD-Video disc, as a folder and as an bash tests/run_bd_audio_tests.sh # the sound a disc carries, written out 39 bash tests/run_vc1_tests.sh # the VC-1 encoder, put through a decoder 4 bash tests/run_vp9_av1_tests.sh # VP9 and AV1: cut, and still the source's bytes 5 -bash tests/run_sar_tests.sh # container SAR: input outline and three cuts 48 +bash tests/run_sar_tests.sh # SAR: 44 exports + 16 outlines 60 bash tests/run_vfr_tests.sh # variable rate, both ways round 8 bash tests/run_demux_tests.sh # what a reader loses by reading what it does not 9 bash tests/run_transrate_tests.sh # fitting a disc: the identity, the size, the cost 15 diff --git a/rust/crates/core/src/cut.rs b/rust/crates/core/src/cut.rs index 44403be..99e9f4d 100644 --- a/rust/crates/core/src/cut.rs +++ b/rust/crates/core/src/cut.rs @@ -3438,22 +3438,12 @@ fn take_graphics( } fn open_input(path: &str) -> Result<(crate::input::Demux, usize)> { - let mut ictx = crate::input::demux(&path)?; + let ictx = crate::input::demux(&path)?; let index = ictx .streams() .best(ff::media::Type::Video) .ok_or_else(|| anyhow!("no video stream"))? .index(); - // Carry container-only SAR to copied parameters and boundary encoders. - unsafe { - let stream = *(*ictx.as_mut_ptr()).streams.add(index); - let sar = ff::ffi::av_guess_sample_aspect_ratio( - ictx.as_mut_ptr(), stream, std::ptr::null_mut(), - ); - if sar.num > 0 && sar.den > 0 { - (*(*stream).codecpar).sample_aspect_ratio = sar; - } - } Ok((ictx, index)) } @@ -9407,7 +9397,19 @@ fn cut_into( .collect(); let (ictx, ist_index) = open_input(&src.input.url)?; - let params = ictx.stream(ist_index).unwrap().parameters(); + let ist = ictx.stream(ist_index).unwrap(); + let params = ist.parameters(); + // A container may override the display aspect without rewriting the + // pictures (for example, mkvmerge --aspect-ratio). Keep that display SAR + // on the output stream, but leave codecpar alone: boundary encoders must + // say the same SAR as the copied pictures, not alternate at every seam. + // AV1's container-only SAR needs no corresponding encoder setting. + let display_sar = unsafe { + let sar = ff::ffi::av_guess_sample_aspect_ratio( + ictx.as_ptr() as *mut _, ist.as_ptr() as *mut _, std::ptr::null_mut(), + ); + if sar.num > 0 && sar.den > 0 { sar } else { (*params.as_ptr()).sample_aspect_ratio } + }; let extradata = unsafe { let p = params.as_ptr(); if (*p).extradata.is_null() || (*p).extradata_size <= 0 { @@ -10066,7 +10068,10 @@ fn cut_into( ost.set_parameters(params.clone()); ost.set_time_base(grid.time_base()); unsafe { - (*ost.as_mut_ptr()).sample_aspect_ratio = (*params.as_ptr()).sample_aspect_ratio; + (*ost.as_mut_ptr()).sample_aspect_ratio = display_sar; + // libavformat requires these two output fields to agree. This is + // the muxer's copy, not the input parameters used by encoders. + (*ost.parameters().as_mut_ptr()).sample_aspect_ratio = display_sar; } if let Some((num, den)) = stated_rate { ost.set_avg_frame_rate(ff::Rational::new(num, den)); diff --git a/tests/sar_tests.py b/tests/sar_tests.py index f46752e..8ae1e84 100644 --- a/tests/sar_tests.py +++ b/tests/sar_tests.py @@ -28,7 +28,7 @@ def main(): fixtures.mkdir(parents=True, exist_ok=True) cut = args.cut.resolve() outline_probe = (args.outline_probe or cut.parent/'examples'/('conformdiag.exe' if os.name == 'nt' else 'conformdiag')).resolve() - commands, cases, outlines = [], [], [] + commands, cases, outlines, overrides = [], [], [], [] def sha(path): with path.open('rb') as stream: @@ -51,6 +51,32 @@ def sar(stream): value = stream.get('sample_aspect_ratio', 'N/A') return Fraction(value.replace(':', '/')) if value not in ('N/A', '0:1') else Fraction(1) + def header_sars(path): + # Inspect every SPS, including the ones written by boundary encoders; + # ffprobe's stream SAR alone can hide a change at a seam. + log = run([args.ffmpeg, '-v', 'info', '-i', path, '-map', '0:v:0', + '-c', 'copy', '-bsf:v', 'trace_headers', '-f', 'null', '-']).stderr.decode(errors='replace') + (work/(path.name+'.headers.log')).write_text(log, encoding='utf-8') + fields = re.findall(r'\b(aspect_ratio_idc|sar_width|sar_height)\s+[01]+\s*=\s*(\d+)', log) + ratios = [] + index = 0 + while index < len(fields): + field, value = fields[index] + assert field == 'aspect_ratio_idc', (path, fields[index:]) + value = int(value) + if value == 255: + assert [v[0] for v in fields[index+1:index+3]] == ['sar_width', 'sar_height'], path + ratios.append(Fraction(int(fields[index+1][1]), int(fields[index+2][1]))) + index += 3 + else: + # These fixtures deliberately cover square and non-square SPS SAR. + assert value in (1, 14), (path, value) + ratios.append({1: Fraction(1), 14: Fraction(4, 3)}[value]) + index += 1 + assert ratios, ('no SPS SAR found', path) + assert len(ratios) == len(re.findall(r'\] Sequence Parameter Set\s*$', log, re.M)), ('SPS without SAR', path) + return ratios + encoders = { 'h264': ['libx264', '-preset', 'medium', '-crf', '14'], 'hevc': ['libx265', '-preset', 'medium', '-crf', '18', '-x265-params', 'log-level=error:pools=none'], @@ -98,13 +124,83 @@ def sar(stream): cases.append(row) print('ok' if row['passed'] else 'FAIL', codec, ratio, container, mode, row.get('error', ''), flush=True) + + # A display-aspect override must not leak into the boundary SPS. The first + # fixture uses the reviewer's exact 30 s / 640x360 H.264 generation command. + # Also cover HEVC, a non-square coded SAR, both output containers, and a + # later TS remux where the container's display override no longer applies. + for codec in ('h264', 'hevc'): + for coded_sar in ('1/1', '4/3'): + stem = f'override-{codec}-{coded_sar.replace("/", "_")}' + original = fixtures/(stem+'.mp4') + source = fixtures/(stem+'.mkv') + if not original.exists(): + options = [] if coded_sar == '1/1' else ['-vf', f'setsar={coded_sar}'] + encoder = ['libx264'] if codec == 'h264' else ['libx265', '-x265-params', 'log-level=error:pools=none'] + run([args.ffmpeg, '-v', 'error', '-y', '-f', 'lavfi', '-i', + 'testsrc2=rate=25:size=640x360', '-t', '30', *options, + '-c:v', *encoder, '-g', '50', original]) + if not source.exists(): + run([args.ffmpeg, '-v', 'error', '-y', '-i', original, + '-c', 'copy', '-aspect', '4:3', source]) + expected = Fraction(coded_sar) + source_sars = header_sars(source) + assert all(s == expected for s in source_sars), ('fixture SPS SAR', source, source_sars) + assert sar(probe(source)) == Fraction(3, 4), ('fixture container SAR', source) + shape = run([outline_probe, '--shape', source]).stdout.decode(errors='replace') + found = re.search(r'\bsar=([\d.]+)', shape) + correct = found is not None and abs(float(found[1])-0.75) < 0.0001 + outlines.append({'input': source.name, 'input_sha256': sha(source), + 'expected_sar': '3/4', 'passed': correct, 'shape': shape}) + print('ok' if correct else 'FAIL', 'outline override', codec, coded_sar, flush=True) + for container in ('.mp4', '.mkv'): + out = work/(stem+'-cut'+container) + remux = work/(out.name+'.ts') + row = {'codec': codec, 'container': container, 'coded_sar': coded_sar, + 'display_sar': '3/4', 'input': source.name, 'input_sha256': sha(source), + 'source_sps_sars': [str(s) for s in source_sars], 'output': out.name} + try: + log = run([cut, source, '--keep', '3.3-12.1', '--keep', '15-22.7', '-o', out]) + text = (log.stdout+log.stderr).decode(errors='replace') + (work/(out.name+'.log')).write_text(text, encoding='utf-8') + assert re.search(r'^\s*copy\s+', text, re.M), 'fixture must exercise copied pictures' + assert re.search(r'^\s*reencode\s+', text, re.M), 'fixture must exercise boundary encoders' + made = probe(out) + assert sar(made) == Fraction(3, 4), ('output container SAR', made) + assert (made['width'], made['height'], made['codec_name']) == (640, 360, codec), made + run([args.ffmpeg, '-v', 'error', '-xerror', '-err_detect', 'explode', '-i', out, + '-map', '0:v:0', '-f', 'null', '-']) + values = header_sars(out) + row['output_sps_sars'] = [str(s) for s in values] + # Inspect the TS even when the original output has a bad SPS, + # so the retained evidence shows the downstream effect too. + run([args.ffmpeg, '-v', 'error', '-y', '-i', out, '-map', '0:v:0', '-c', 'copy', remux]) + remux_sars = header_sars(remux) + row['ts_sps_sars'] = [str(s) for s in remux_sars] + row['ts_sha256'] = sha(remux) + # The FFmpeg 7.1 HEVC TS remux also has a strict-decode + # failure with unmodified v0.8.39. Check its SAR headers + # here without making this suite a TS decoding fix. + if codec == 'h264': + run([args.ffmpeg, '-v', 'error', '-xerror', '-err_detect', 'explode', '-i', remux, + '-map', '0:v:0', '-f', 'null', '-']) + assert all(s == expected for s in values), ('output SPS SAR changes', row['output_sps_sars']) + assert all(s == expected for s in remux_sars), ('remux SPS SAR changes', row['ts_sps_sars']) + row.update(passed=True, frames=int(made['nb_read_frames'])) + except (AssertionError, subprocess.TimeoutExpired) as error: + row.update(passed=False, error=str(error)) + if out.exists(): + row['output_sha256'] = sha(out) + overrides.append(row) + print('ok' if row['passed'] else 'FAIL', 'override', codec, coded_sar, container, + row.get('error', ''), flush=True) record = {'cut': str(cut), 'cut_sha256': sha(cut), 'generator_sha256': sha(Path(__file__)), 'outline_probe_sha256': sha(outline_probe), 'fixture_directory': str(fixtures), 'commands': commands, - 'cases': cases, 'outline_checks': outlines} + 'cases': cases, 'outline_checks': outlines, 'override_cases': overrides} (work/'results.json').write_text(json.dumps(record, indent=2)+'\n', encoding='utf-8') - checks = cases + outlines + checks = cases + outlines + overrides failures = sum(not c['passed'] for c in checks) - print(f'{len(checks)-failures} passed, {failures} failed ({len(cases)} exports, {len(outlines)} outlines); evidence: {work}', flush=True) + print(f'{len(checks)-failures} passed, {failures} failed ({len(cases)+len(overrides)} exports, {len(outlines)} outlines); evidence: {work}', flush=True) raise SystemExit(1 if failures else 0)