pigeon-cli 0.4.24

Pigeon: authenticate, sink, and transform personal data from external services.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
//! `ffmpeg`/`ffprobe`-backed png/heic -> jpg transcoding (ADR-0112 Decision
//! ยง4), mov/m4v/mp4 -> H.265 mp4 (ADR-0122), audio-only mp4 -> Opus
//! (ADR-0142), and the jpeg copy-through path that skips both entirely. Where the
//! since-removed `pull_transform::media` defaulted to `-q:v 3` (tuned for
//! screenshots, not "no quality loss"), this always uses ffmpeg's highest
//! JPEG quality
//! setting and forces 4:4:4 chroma, with no resize filter -- dimensions are
//! therefore preserved by construction, and `transcode_to_jpg` additionally
//! confirms that with a cheap post-encode `ffprobe` check as a defensive
//! guard against an unexpected ffmpeg default silently resizing the image.
//! That guard compares pixel *area*, not exact `(width, height)` ordering,
//! and `probe_dimensions` understands HEIF "Tile Grid" stream groups
//! (ADR-0121) -- both needed for a grid-tiled, HEIF-rotated real-world HEIC
//! photo to probe and compare correctly; see `probe_dimensions`'s and
//! `transcode_to_jpg`'s own doc comments for why.

use std::path::Path;

use serde::Deserialize;

use super::format::VideoQuality;

/// Confirms `ffmpeg`/`ffprobe` are on `PATH`, checked once up front before
/// any prompts (the same shape as `rclone::worker::check_rclone_available`),
/// so a missing binary fails the whole job immediately instead of partway
/// through a long run.
pub(crate) async fn check_ffmpeg_available() -> Result<(), String> {
    for binary in ["ffmpeg", "ffprobe"] {
        tokio::process::Command::new(binary)
            .arg("-version")
            .output()
            .await
            .map_err(|_| {
                format!(
                    "'{binary}' was not found on PATH -- required for \
                     'pigeon job run transform' to transcode images"
                )
            })?;
    }
    Ok(())
}

#[derive(Deserialize, Default)]
struct FfprobeStream {
    width: Option<u32>,
    height: Option<u32>,
}

#[derive(Deserialize, Default)]
struct FfprobeStreamGroupComponent {
    width: Option<u32>,
    height: Option<u32>,
}

#[derive(Deserialize, Default)]
struct FfprobeStreamGroup {
    #[serde(default)]
    components: Vec<FfprobeStreamGroupComponent>,
}

#[derive(Deserialize, Default)]
struct FfprobeDimensionsOutput {
    #[serde(default)]
    streams: Vec<FfprobeStream>,
    #[serde(default)]
    stream_groups: Vec<FfprobeStreamGroup>,
}

/// Pixel dimensions of `path`'s true image content, or `None` if `ffprobe`
/// can't determine them (e.g. a format it can't open at all) -- treated as
/// "skip the check," not an error, since this is a defensive guard, not the
/// primary correctness mechanism (no resize filter is ever applied, so
/// dimensions are already preserved by construction).
///
/// Prefers a HEIF "Tile Grid" stream group's reconstructed canvas size
/// (ADR-0121) over the first stream's raw dimensions: a grid-tiled HEIC
/// (the common shape for a modern phone camera's high-resolution photo)
/// exposes its dozens of tiles as individual small streams (e.g. 512x512)
/// with none marked `default`/primary, so `-select_streams v:0` alone only
/// ever sees one arbitrary tile, never the full reconstructed photo (e.g.
/// 3024x4032) -- ffmpeg instead surfaces the reconstructed size through a
/// separate `stream_groups` entry. Falls back to the plain stream's
/// dimensions when no stream group exists: every non-grid input (a plain
/// PNG, a simple single-image HEIC) and the single-frame `.jpg` output,
/// which never has one.
async fn probe_dimensions(path: &Path) -> Option<(u32, u32)> {
    let output = tokio::process::Command::new("ffprobe")
        .args([
            "-v",
            "error",
            "-print_format",
            "json",
            "-select_streams",
            "v:0",
            "-show_entries",
            "stream=width,height",
            "-show_stream_groups",
        ])
        .arg(path)
        .output()
        .await
        .ok()?;
    if !output.status.success() {
        return None;
    }
    let parsed: FfprobeDimensionsOutput = serde_json::from_slice(&output.stdout).ok()?;
    if let Some(component) = parsed
        .stream_groups
        .first()
        .and_then(|group| group.components.first())
        && let (Some(width), Some(height)) = (component.width, component.height)
    {
        return Some((width, height));
    }
    let stream = parsed.streams.first()?;
    Some((stream.width?, stream.height?))
}

/// Transcodes `input` (png/heic) to `output` (always `.jpg`):
/// ```text
/// ffmpeg -y -loglevel error -i <input> -frames:v 1 -q:v 1 -pix_fmt yuvj444p -f mjpeg <output>
/// ```
/// `-q:v 1` is ffmpeg's mjpeg encoder's highest quality setting (1=best ..
/// 31=worst); `-pix_fmt yuvj444p` forces 4:4:4 chroma instead of ffmpeg's
/// default 4:2:0 subsampling for JPEG output -- the single largest avoidable
/// quality loss in a naive encode beyond quantization itself. `-f mjpeg`
/// forces the muxer explicitly: `worker.rs` writes to a scratch path ending
/// in `.scratch`, not `.jpg` (the real destination name plus a `.scratch`
/// suffix), and without `-f` ffmpeg tries to infer the output format from
/// that final extension alone and fails outright ("Unable to find a
/// suitable output format"). No `-vf scale=...` or other filter is ever
/// applied, preserving the original pixel dimensions exactly. A non-zero
/// `ffmpeg` exit -- including a HEIC input on a `libheif`-less build, which
/// fails immediately with ffmpeg's own decoder-missing message -- returns
/// `Err` with that stderr folded in verbatim -- `worker.rs`'s call site
/// retries this a bounded number of times before giving up and recording
/// the failure for just this one file (ADR-0116; no longer a whole-run
/// abort, amending ADR-0112 Decision ยง7); there is no separate
/// HEIC-capability preflight probe.
pub(crate) async fn transcode_to_jpg(input: &Path, output: &Path) -> Result<(), String> {
    let result = tokio::process::Command::new("ffmpeg")
        .args(["-y", "-loglevel", "error"])
        .arg("-i")
        .arg(input)
        .args([
            "-frames:v",
            "1",
            "-q:v",
            "1",
            "-pix_fmt",
            "yuvj444p",
            "-f",
            "mjpeg",
        ])
        .arg(output)
        .output()
        .await
        .map_err(|err| format!("failed to run ffmpeg: {err}"))?;

    if !result.status.success() {
        return Err(format!(
            "ffmpeg failed to transcode {}: {}",
            input.display(),
            String::from_utf8_lossy(&result.stderr).trim()
        ));
    }

    if let (Some(before), Some(after)) = (
        probe_dimensions(input).await,
        probe_dimensions(output).await,
    ) {
        let before_area = before.0 as u64 * before.1 as u64;
        let after_area = after.0 as u64 * after.1 as u64;
        if before_area != after_area {
            return Err(format!(
                "transcoding {} changed pixel area from {before:?} ({before_area} px) to \
                 {after:?} ({after_area} px); refusing an output that isn't full-size",
                input.display()
            ));
        }
    }

    Ok(())
}

/// Tolerance for `transcode_video`'s duration guard, in seconds -- absorbs
/// ordinary container/muxing overhead between the source and the re-encoded
/// `.mp4`, while still catching a truncated or partial encode that
/// nonetheless exits 0.
const DURATION_TOLERANCE_SECONDS: f64 = 0.5;

/// `path`'s container-level duration in seconds (`ffprobe`'s
/// `format=duration`), or `None` if `ffprobe` can't determine it -- treated
/// as "skip the check," same posture as `probe_dimensions`.
async fn probe_duration_seconds(path: &Path) -> Option<f64> {
    let output = tokio::process::Command::new("ffprobe")
        .args([
            "-v",
            "error",
            "-show_entries",
            "format=duration",
            "-of",
            "default=noprint_wrappers=1:nokey=1",
        ])
        .arg(path)
        .output()
        .await
        .ok()?;
    if !output.status.success() {
        return None;
    }
    String::from_utf8_lossy(&output.stdout)
        .trim()
        .parse::<f64>()
        .ok()
}

/// Best-effort diagnostic string appended to `transcode_video`'s pixel-area
/// mismatch error only (ADR-0125, crop fields added by ADR-0126) -- one extra
/// `ffprobe` call made once the guard is already about to fail, so the
/// common (matching) path pays no extra cost. Surfaces exactly the fields
/// `probe_dimensions` deliberately omits (sample/display aspect ratio, any
/// rotation tag or display-matrix side data, the four numeric Frame Cropping
/// extents, stream count/tags) -- the first things a future investigation
/// of this class of failure will want and this incident's own log lacked.
/// `stream_side_data`/`stream_tags` must be requested as their own
/// top-level `-show_entries` sections, not nested under `stream=...` --
/// ffprobe silently returns empty placeholders for the nested form.
async fn probe_video_diagnostics(path: &Path) -> String {
    let output = tokio::process::Command::new("ffprobe")
        .args([
            "-v",
            "error",
            "-print_format",
            "json",
            "-show_entries",
            "stream=index,codec_type,width,height,sample_aspect_ratio,display_aspect_ratio:\
             stream_side_data=side_data_type,rotation,crop_top,crop_bottom,crop_left,crop_right:\
             stream_tags=rotate",
        ])
        .arg(path)
        .output()
        .await;
    match output {
        Ok(o) if o.status.success() => {
            format!(
                " (diagnostics: {})",
                String::from_utf8_lossy(&o.stdout).trim()
            )
        }
        _ => String::new(),
    }
}

/// `transcode_video`'s audio-only rejection (ADR-0141), and
/// `transcode_audio`'s mirror-image video rejection (ADR-0142).
///
/// Named consts rather than inline literals (ADR-0143) because three
/// separate places have to agree on each substring: the `format!` that
/// produces it, `NON_RETRYABLE_SIGNATURES`, and
/// `is_wrong_input_kind_error`. Unlike every other entry in that signature
/// list, these two match **pigeon's own** message text rather than
/// `ffmpeg`'s stderr, so they are not version-fragile -- but only as long
/// as producer and matcher cannot drift apart, which is what these consts
/// guarantee.
const NO_VIDEO_STREAM_SIGNATURE: &str = "has no video stream";
const UNEXPECTED_VIDEO_STREAM_SIGNATURE: &str = "is not an audio-only recording";

/// Whether `err` means the operator pointed this run at the wrong
/// `--input-file-type` for this file -- the file itself is intact
/// (ADR-0143).
///
/// Deliberately a separate predicate from
/// `is_non_retryable_transcode_error`, which answers a different question
/// (does retrying help?). Both are true for these two signatures, but they
/// are not interchangeable: this one licenses deleting the local source
/// copy, which is only safe because such a file is undamaged and still
/// present at `--source`.
pub(crate) fn is_wrong_input_kind_error(err: &str) -> bool {
    [NO_VIDEO_STREAM_SIGNATURE, UNEXPECTED_VIDEO_STREAM_SIGNATURE]
        .iter()
        .any(|signature| err.contains(signature))
}

/// Whether `path` contains at least one video stream, used by
/// `transcode_video` to reject an audio-only input before `ffmpeg` runs
/// (ADR-0141). Unlike `probe_dimensions`/`probe_duration_seconds`, an
/// `ffprobe` failure here returns `false` rather than "skip the check": a
/// file whose streams can't be enumerated at all has no video stream this
/// job can transcode, and `transcode_video`'s mandatory `-map 0:v:0` would
/// fail on it regardless.
async fn has_video_stream(path: &Path) -> bool {
    let Ok(output) = tokio::process::Command::new("ffprobe")
        .args([
            "-v",
            "error",
            "-select_streams",
            "v",
            "-show_entries",
            "stream=index",
            "-of",
            "csv=p=0",
        ])
        .arg(path)
        .output()
        .await
    else {
        return false;
    };
    output.status.success() && !output.stdout.is_empty()
}

/// Transcodes `input` (mov/m4v/mp4) to `output` (always `.mp4`, H.265/HEVC,
/// ADR-0122):
/// ```text
/// ffmpeg -y -loglevel error -noautorotate -apply_cropping:v codec -i <input> \
///   -map 0:v:0 -map 0:a:0? -c:v libx265 -tag:v hvc1 -preset medium \
///   [-crf <N> | -x265-params lossless=1] -c:a aac -b:a 256k -f mp4 <output>
/// ```
/// `-tag:v hvc1` is required for QuickTime/Apple-device playback of
/// HEVC-in-MP4 -- ffmpeg's default `hev1` tag is not recognized by Apple's
/// own players. `-f mp4` forces the muxer explicitly, exactly
/// `transcode_to_jpg`'s own ADR-0115 lesson: `worker.rs` writes to a scratch
/// path ending in `.scratch`, not `.mp4`, so ffmpeg can never be allowed to
/// infer the output format from the on-disk extension alone. No
/// `-vf scale=...` or other resize filter is ever applied in pigeon's own
/// command line, but that alone doesn't guarantee the output's raw pixel
/// grid matches the input's: `-noautorotate` (ADR-0125) is required because
/// ffmpeg otherwise *implicitly* reshapes the decoded frame to match a
/// container-embedded display transform (QuickTime/MP4's `tkhd` matrix) --
/// for most phone video this is a harmless, area-preserving ยฑ90ยฐ/180ยฐ
/// rotation, but some real camera-captured files encode a rotation *plus* a
/// non-unity scale, which silently shrinks/grows the actual output grid
/// despite pigeon applying no filter of its own. `-noautorotate` suppresses
/// that implicit reshape unconditionally; empirically confirmed (ADR-0125)
/// that the original display matrix still survives onto the re-encoded
/// output as frame-level side data even though this is a full re-encode,
/// not a stream copy, so playback orientation is unaffected. `-apply_cropping:v
/// codec` (ADR-0126) guards an independent, later-added ffmpeg mechanism: FFmpeg
/// 7.1 added support for QuickTime's Clean Aperture (`clap` atom) *container*-level
/// crop metadata, which -- left at ffmpeg's own default of `all` -- gets silently
/// auto-applied during decode exactly like autorotate's implicit reshape, shrinking
/// the decoded frame below what `probe_dimensions`'s bare `codecpar.width/height`
/// reports. `codec` is the deliberate, narrower choice, not `none`: `none` also
/// suppresses the much older, unrelated decoder-level crop `probe_dimensions` has
/// *always* already reflected (the H.264/HEVC SPS "conformance window"), so `none`
/// would trade today's container-crop false-positive for a far more common
/// codec-crop false-positive on virtually any non-16-pixel-aligned video -- do
/// not "simplify" this to `none`. `-map 0:v:0
/// -map 0:a:0?` (optional audio map, tolerating a video-only input) pins the
/// exact stream `probe_dimensions` already reads via `-select_streams v:0`,
/// removing any ambiguity from ffmpeg's own default stream selection on a
/// multi-video-track `.mov`. Dimensions are verified by the same pixel-area
/// dimension guard `transcode_to_jpg` uses, plus a duration guard: video has
/// a truncated/partial-encode failure mode images don't (a process that
/// exits 0 having only written part of the stream), which only a duration
/// comparison can catch. A non-zero `ffmpeg` exit -- including a
/// `libx265`-less ffmpeg build, which fails immediately with an "Unknown
/// encoder" message -- returns `Err` with that stderr folded in verbatim;
/// `worker.rs`'s call site retries this a bounded number of times before
/// giving up and recording the failure for just this one file, exactly like
/// `transcode_to_jpg`.
///
/// `threads` caps libx265's own internal thread count (`-threads <n>`,
/// ADR-0127): with no cap, every concurrent `transcode_video` call
/// independently defaults to using every core on the host, so `N` files
/// transcoding at once oversubscribe CPU (and the memory that scales with
/// it) by a factor of `N` on top of `--concurrency` itself already bounding
/// the process count. Callers pass `(available_parallelism /
/// concurrency).max(1)` so the whole fleet of concurrent `ffmpeg`
/// invocations stays within the host's real core count.
///
/// An audio-only input is rejected up front via `has_video_stream`
/// (ADR-0141), before `ffmpeg` runs. `-map 0:v:0` is mandatory, so such a
/// file always failed anyway -- but with ffmpeg's raw `"Stream map ''
/// matches no streams"` stderr, which named neither the real problem nor
/// the file, and which (not being a known non-retryable signature) was
/// retried twice for a guaranteed-identical outcome. Voice-note recordings
/// saved with an `.mp4` extension are the real-world source of these.
pub(crate) async fn transcode_video(
    input: &Path,
    output: &Path,
    quality: VideoQuality,
    threads: usize,
) -> Result<(), String> {
    if !has_video_stream(input).await {
        return Err(format!(
            "{} {NO_VIDEO_STREAM_SIGNATURE} (likely an audio-only recording saved with a video \
             extension); see ADR-0142 for an audio-preserving input type",
            input.display()
        ));
    }

    let mut command = tokio::process::Command::new("ffmpeg");
    command.args([
        "-y",
        "-loglevel",
        "error",
        "-noautorotate",
        "-apply_cropping:v",
        "codec",
    ]);
    command.arg("-i").arg(input);
    command.args(["-map", "0:v:0", "-map", "0:a:0?"]);
    command.args(["-c:v", "libx265", "-tag:v", "hvc1", "-preset", "medium"]);
    command.args(["-threads", &threads.max(1).to_string()]);
    match quality.crf() {
        Some(crf) => {
            command.args(["-crf", &crf.to_string()]);
        }
        None => {
            command.args(["-x265-params", "lossless=1"]);
        }
    }
    command.args(["-c:a", "aac", "-b:a", "256k", "-f", "mp4"]);
    command.arg(output);

    let result = command
        .output()
        .await
        .map_err(|err| format!("failed to run ffmpeg: {err}"))?;

    if !result.status.success() {
        return Err(format!(
            "ffmpeg failed to transcode {}: {}",
            input.display(),
            String::from_utf8_lossy(&result.stderr).trim()
        ));
    }

    if let (Some(before), Some(after)) = (
        probe_dimensions(input).await,
        probe_dimensions(output).await,
    ) {
        let before_area = before.0 as u64 * before.1 as u64;
        let after_area = after.0 as u64 * after.1 as u64;
        if before_area != after_area {
            let diagnostics = probe_video_diagnostics(input).await;
            return Err(format!(
                "transcoding {} changed pixel area from {before:?} ({before_area} px) to \
                 {after:?} ({after_area} px); refusing an output that isn't full-size{diagnostics}",
                input.display()
            ));
        }
    }

    if let (Some(before), Some(after)) = (
        probe_duration_seconds(input).await,
        probe_duration_seconds(output).await,
    ) && (before - after).abs() > DURATION_TOLERANCE_SECONDS
    {
        return Err(format!(
            "transcoding {} changed duration from {before:.3}s to {after:.3}s (tolerance \
             {DURATION_TOLERANCE_SECONDS}s); refusing a truncated or partial encode",
            input.display()
        ));
    }

    Ok(())
}

/// Opus bitrate for `transcode_audio` (ADR-0142). 48k, not the
/// speech-tuned 32k: this content is personal archival audio of *unknown*
/// type -- some clips may be music rather than speech -- and 48k is the
/// lowest rate that stays comfortably transparent for speech while
/// remaining acceptable for general audio, still roughly 2.2x smaller than
/// a typical 128k AAC source. Same archival-over-streaming reasoning as
/// `VideoQuality::Medium`'s deliberately-better-than-conventional CRF.
const OPUS_BITRATE: &str = "48k";

/// Transcodes `input` (an audio-only `.mp4`, ADR-0142) to `output`
/// (always `.opus`, Ogg-encapsulated Opus):
/// ```text
/// ffmpeg -y -loglevel error -i <input> -map 0:a:0 \
///   -c:a libopus -b:a 48k -application audio -f opus <output>
/// ```
/// Opus is the best quality-per-bit codec available for this content class
/// at these bitrates, and costs nothing for the intended future
/// transcription work -- every transcription pipeline decodes to PCM
/// first, so the stored codec is irrelevant to it beyond whether the audio
/// survived intact. `-application audio` is libopus's general-purpose,
/// higher-fidelity mode and is already its default; do **not** "optimize"
/// it to `voip`, whose more aggressive telephony-tuned speech bandpass
/// would discard content a transcription model might still use.
/// `-f opus` forces the muxer explicitly, the same ADR-0115 lesson
/// `transcode_to_jpg`/`transcode_video` already encode: `worker.rs` writes
/// to a scratch path ending in `.scratch`, so ffmpeg can never be allowed
/// to infer the container from the on-disk extension. `-map 0:a:0` pins
/// the first audio stream rather than relying on ffmpeg's default stream
/// selection, exactly as `transcode_video` pins `0:v:0`.
///
/// An input that still has a video stream is **refused**, not silently
/// stripped -- the load-bearing safety guard, not a formality. Nothing
/// filters a `mp4-audio` run's source down to audio-only files (no
/// extension distinguishes them), and ADR-0138/0139's prefix inference
/// means `--source source` with no explicit prefix resolves to
/// `source:mp4/`: the same default address a plain `--input-file-type mp4`
/// video run uses. Without this guard, one omitted prefix would quietly
/// transcode a bucket of real video into audio-only files and push them.
///
/// Verified by the same duration guard `transcode_video` uses -- audio
/// shares exactly that truncated/partial-encode failure mode. The
/// pixel-area dimension guard is skipped: there are no dimensions.
pub(crate) async fn transcode_audio(input: &Path, output: &Path) -> Result<(), String> {
    if has_video_stream(input).await {
        return Err(format!(
            "{} has a video stream, so it {UNEXPECTED_VIDEO_STREAM_SIGNATURE}; refusing to \
             discard video (use --input-file-type mp4 to transcode it as video)",
            input.display()
        ));
    }

    let result = tokio::process::Command::new("ffmpeg")
        .args(["-y", "-loglevel", "error"])
        .arg("-i")
        .arg(input)
        .args([
            "-map",
            "0:a:0",
            "-c:a",
            "libopus",
            "-b:a",
            OPUS_BITRATE,
            "-application",
            "audio",
            "-f",
            "opus",
        ])
        .arg(output)
        .output()
        .await
        .map_err(|err| format!("failed to run ffmpeg: {err}"))?;

    if !result.status.success() {
        return Err(format!(
            "ffmpeg failed to transcode {}: {}",
            input.display(),
            String::from_utf8_lossy(&result.stderr).trim()
        ));
    }

    if let (Some(before), Some(after)) = (
        probe_duration_seconds(input).await,
        probe_duration_seconds(output).await,
    ) && (before - after).abs() > DURATION_TOLERANCE_SECONDS
    {
        return Err(format!(
            "transcoding {} changed duration from {before:.3}s to {after:.3}s (tolerance \
             {DURATION_TOLERANCE_SECONDS}s); refusing a truncated or partial encode",
            input.display()
        ));
    }

    Ok(())
}

/// Matches `ffmpeg`/`ffprobe` failure signatures that are deterministic --
/// re-running `transcode_to_jpg` against the exact same input reproduces the
/// identical failure every time, so retrying buys nothing but doubles ffmpeg
/// invocations and adds a guaranteed sleep per file (ADR-0121). Matches
/// substrings, not full messages or exit codes -- same caution as
/// `destination.rs`'s rclone-stderr precedent (ADR-0120): pattern-matching
/// subprocess output is inherently version-fragile; reconfirm these
/// substrings against whatever `ffmpeg` version is actually deployed.
pub(crate) fn is_non_retryable_transcode_error(err: &str) -> bool {
    const NON_RETRYABLE_SIGNATURES: &[&str] = &[
        "moov atom not found",
        "Invalid data found when processing input",
        "Decoder (codec",
        "could not find codec parameters",
        "refusing an output that isn't full-size",
        "Unknown encoder",
        NO_VIDEO_STREAM_SIGNATURE,
        UNEXPECTED_VIDEO_STREAM_SIGNATURE,
    ];
    NON_RETRYABLE_SIGNATURES
        .iter()
        .any(|signature| err.contains(signature))
}

/// `jpeg` input is never routed through ffmpeg -- a plain byte-for-byte
/// `fs::copy`, zero generational loss (ADR-0112 Decision ยง4).
pub(crate) fn copy_through(input: &Path, output: &Path) -> Result<(), String> {
    std::fs::copy(input, output).map_err(|err| {
        format!(
            "failed to copy {} to {}: {err}",
            input.display(),
            output.display()
        )
    })?;
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn copy_through_is_byte_for_byte_identical() {
        let dir = tempfile::tempdir().unwrap();
        let input = dir.path().join("photo.jpeg");
        let output = dir.path().join("photo.jpg");
        let bytes: Vec<u8> = (0..255).collect();
        std::fs::write(&input, &bytes).unwrap();

        copy_through(&input, &output).unwrap();

        assert_eq!(std::fs::read(&output).unwrap(), bytes);
    }

    #[test]
    fn copy_through_errors_on_a_missing_input() {
        let dir = tempfile::tempdir().unwrap();
        let input = dir.path().join("does-not-exist.jpeg");
        let output = dir.path().join("out.jpg");
        assert!(copy_through(&input, &output).is_err());
    }

    /// Generates a tiny synthetic PNG via ffmpeg's `lavfi` test source --
    /// avoids needing a checked-in binary fixture.
    async fn generate_test_png(path: &Path) -> Result<(), String> {
        let result = tokio::process::Command::new("ffmpeg")
            .args([
                "-y",
                "-loglevel",
                "error",
                "-f",
                "lavfi",
                "-i",
                "testsrc=duration=1:size=64x48:rate=1",
                "-frames:v",
                "1",
            ])
            .arg(path)
            .output()
            .await
            .map_err(|err| format!("failed to run ffmpeg: {err}"))?;
        if !result.status.success() {
            return Err(String::from_utf8_lossy(&result.stderr).to_string());
        }
        Ok(())
    }

    #[tokio::test]
    async fn transcode_to_jpg_preserves_dimensions_for_a_real_png() {
        if check_ffmpeg_available().await.is_err() {
            eprintln!("skipping: ffmpeg/ffprobe not found on PATH");
            return;
        }

        let dir = tempfile::tempdir().unwrap();
        let input = dir.path().join("test.png");
        generate_test_png(&input).await.unwrap();

        let output = dir.path().join("test.jpg");
        transcode_to_jpg(&input, &output).await.unwrap();

        assert!(output.exists());
        assert_eq!(
            probe_dimensions(&input).await,
            probe_dimensions(&output).await
        );
        assert_eq!(probe_dimensions(&output).await, Some((64, 48)));
    }

    /// Mirrors `worker.rs`'s real scratch-file naming (`<destination-name>.scratch`,
    /// where `destination-name` already ends in `.jpg`) -- without `-f mjpeg`
    /// this fails with ffmpeg's "Unable to find a suitable output format"
    /// because the final extension on disk is `.scratch`, not `.jpg`.
    #[tokio::test]
    async fn transcode_to_jpg_succeeds_when_the_output_path_ends_in_scratch() {
        if check_ffmpeg_available().await.is_err() {
            eprintln!("skipping: ffmpeg/ffprobe not found on PATH");
            return;
        }

        let dir = tempfile::tempdir().unwrap();
        let input = dir.path().join("test.png");
        generate_test_png(&input).await.unwrap();

        let output = dir.path().join("test-abcdef0123456789.jpg.scratch");
        transcode_to_jpg(&input, &output).await.unwrap();

        assert!(output.exists());
    }

    #[tokio::test]
    async fn transcode_to_jpg_fails_fast_on_an_unreadable_input() {
        if check_ffmpeg_available().await.is_err() {
            eprintln!("skipping: ffmpeg/ffprobe not found on PATH");
            return;
        }

        let dir = tempfile::tempdir().unwrap();
        let input = dir.path().join("not-an-image.heic");
        std::fs::write(&input, b"this is not a real heic file").unwrap();
        let output = dir.path().join("out.jpg");

        let result = transcode_to_jpg(&input, &output).await;
        assert!(result.is_err());
    }

    /// Generates a tiny synthetic video (with a silent audio track) via
    /// ffmpeg's `lavfi` test sources -- avoids needing a checked-in binary
    /// fixture, mirroring `generate_test_png` above.
    async fn generate_test_video(path: &Path) -> Result<(), String> {
        let result = tokio::process::Command::new("ffmpeg")
            .args([
                "-y",
                "-loglevel",
                "error",
                "-f",
                "lavfi",
                "-i",
                "testsrc=duration=1:size=64x48:rate=10",
                "-f",
                "lavfi",
                "-i",
                "anullsrc=duration=1",
                "-c:v",
                "libx264",
                "-c:a",
                "aac",
            ])
            .arg(path)
            .output()
            .await
            .map_err(|err| format!("failed to run ffmpeg: {err}"))?;
        if !result.status.success() {
            return Err(String::from_utf8_lossy(&result.stderr).to_string());
        }
        Ok(())
    }

    #[tokio::test]
    async fn transcode_video_preserves_dimensions_and_duration_for_a_real_video() {
        if check_ffmpeg_available().await.is_err() {
            eprintln!("skipping: ffmpeg/ffprobe not found on PATH");
            return;
        }

        let dir = tempfile::tempdir().unwrap();
        let input = dir.path().join("test.mp4");
        if generate_test_video(&input).await.is_err() {
            eprintln!("skipping: this ffmpeg build can't generate a test fixture");
            return;
        }

        let output = dir.path().join("test-output.mp4");
        if let Err(err) = transcode_video(&input, &output, VideoQuality::Medium, 1).await {
            eprintln!("skipping: this ffmpeg build can't encode libx265: {err}");
            return;
        }

        assert!(output.exists());
        assert_eq!(
            probe_dimensions(&input).await,
            probe_dimensions(&output).await
        );
        let before_duration = probe_duration_seconds(&input).await.unwrap();
        let after_duration = probe_duration_seconds(&output).await.unwrap();
        assert!((before_duration - after_duration).abs() <= DURATION_TOLERANCE_SECONDS);
    }

    /// Generates a base test video (as `generate_test_video`) and remuxes a
    /// copy with a `degrees` display-matrix rotation attached at the
    /// container level (`-display_rotation`, an ffmpeg *input* option, on a
    /// `-c copy` remux) -- mirrors a real device-captured `.mov`'s embedded
    /// `tkhd` rotation matrix (ADR-0125) without needing a checked-in binary
    /// fixture. The rotation must be attached via a separate remux of a real
    /// container, not folded into the original `lavfi` generation command --
    /// doing the latter bakes the rotation into the raw frames instead of
    /// attaching container-level side data, and wouldn't exercise the bug
    /// this is meant to pin down.
    async fn generate_test_video_with_rotation(path: &Path, degrees: i32) -> Result<(), String> {
        let base_path = path.with_file_name("rotation-base.mp4");
        generate_test_video(&base_path).await?;
        let result = tokio::process::Command::new("ffmpeg")
            .args(["-y", "-loglevel", "error", "-display_rotation"])
            .arg(degrees.to_string())
            .arg("-i")
            .arg(&base_path)
            .args(["-c", "copy", "-f", "mp4"])
            .arg(path)
            .output()
            .await
            .map_err(|err| format!("failed to run ffmpeg: {err}"))?;
        if !result.status.success() {
            return Err(String::from_utf8_lossy(&result.stderr).to_string());
        }
        Ok(())
    }

    /// ADR-0125 regression test: a `.mov`/`.mp4` whose container carries a
    /// rotation display matrix must transcode without ffmpeg's implicit
    /// autorotate reshaping the output's raw pixel grid -- `-noautorotate`
    /// means the comparison must hold on the *exact* `(width, height)` tuple
    /// here, not merely equal area, pinning the no-reshape mechanism.
    ///
    /// Caveat: this fixture is a pure rotation (area-preserving), which
    /// would *not* have failed even before this fix -- ffmpeg's CLI exposes
    /// no flag to inject the real incident's rotation-plus-non-unity-scale
    /// matrix. This test covers the mechanism, not the exact production
    /// fingerprint (see ADR-0125's Decision ยง3/ยง4 for the manual
    /// verification that covered the latter).
    #[tokio::test]
    async fn transcode_video_preserves_raw_dimensions_for_an_input_with_a_rotation_display_matrix()
    {
        if check_ffmpeg_available().await.is_err() {
            eprintln!("skipping: ffmpeg/ffprobe not found on PATH");
            return;
        }

        let dir = tempfile::tempdir().unwrap();
        let input = dir.path().join("rotated.mp4");
        if generate_test_video_with_rotation(&input, 90).await.is_err() {
            eprintln!("skipping: this ffmpeg build can't attach a display-rotation matrix");
            return;
        }

        let output = dir.path().join("rotated-output.mp4");
        if let Err(err) = transcode_video(&input, &output, VideoQuality::Medium, 1).await {
            panic!("transcode_video incorrectly rejected a rotated-but-unreshaped input: {err}");
        }

        assert_eq!(
            probe_dimensions(&input).await,
            probe_dimensions(&output).await
        );
    }

    /// Generates a base H.264 test video (not HEVC -- `h264_metadata` is an
    /// H.264-only bitstream filter) and remuxes a copy with a codec-level
    /// (SPS "conformance window") crop attached via that filter -- mirrors
    /// the long-standing crop mechanism `probe_dimensions`'s bare
    /// `codecpar.width/height` has *always* already reflected, distinct from
    /// ADR-0126's container-level (`clap`-atom) crop. `crop_left`/
    /// `crop_right` must each be a multiple of 2 (ffmpeg rejects an odd
    /// value outright); 2+2 trims the width by exactly 4px, confirmed
    /// empirically against a real `h264_metadata` run.
    async fn generate_test_video_with_sps_crop(path: &Path) -> Result<(), String> {
        let base_path = path.with_file_name("sps-crop-base.mp4");
        let result = tokio::process::Command::new("ffmpeg")
            .args([
                "-y",
                "-loglevel",
                "error",
                "-f",
                "lavfi",
                "-i",
                "testsrc=duration=1:size=64x48:rate=5",
                "-c:v",
                "libx264",
                "-pix_fmt",
                "yuv420p",
            ])
            .arg(&base_path)
            .output()
            .await
            .map_err(|err| format!("failed to run ffmpeg: {err}"))?;
        if !result.status.success() {
            return Err(String::from_utf8_lossy(&result.stderr).to_string());
        }

        let result = tokio::process::Command::new("ffmpeg")
            .args(["-y", "-loglevel", "error", "-i"])
            .arg(&base_path)
            .args([
                "-c:v",
                "copy",
                "-bsf:v",
                "h264_metadata=crop_left=2:crop_right=2",
                "-f",
                "mp4",
            ])
            .arg(path)
            .output()
            .await
            .map_err(|err| format!("failed to run ffmpeg: {err}"))?;
        if !result.status.success() {
            return Err(String::from_utf8_lossy(&result.stderr).to_string());
        }
        Ok(())
    }

    /// ADR-0126 regression test: a codec-level (SPS) crop -- the kind
    /// `probe_dimensions` has always already reflected -- must still
    /// transcode correctly with `-apply_cropping:v codec` in place. This
    /// pins the "codec, not none" choice: if a future reader "simplifies"
    /// `-apply_cropping:v codec` to `-apply_cropping:v none`, the output
    /// reverts to the pre-crop coded size and this test starts failing
    /// (empirically confirmed via a direct ffmpeg run: `codec` and the old
    /// pre-ADR-0126 default both produce the cropped size here; `none`
    /// regresses to the uncropped one).
    #[tokio::test]
    async fn transcode_video_preserves_codec_level_cropped_dimensions() {
        if check_ffmpeg_available().await.is_err() {
            eprintln!("skipping: ffmpeg/ffprobe not found on PATH");
            return;
        }

        let dir = tempfile::tempdir().unwrap();
        let input = dir.path().join("sps-cropped.mp4");
        if generate_test_video_with_sps_crop(&input).await.is_err() {
            eprintln!("skipping: this ffmpeg build can't attach an SPS crop via h264_metadata");
            return;
        }

        let output = dir.path().join("sps-cropped-output.mp4");
        if let Err(err) = transcode_video(&input, &output, VideoQuality::Medium, 1).await {
            panic!("transcode_video incorrectly rejected a codec-level-cropped input: {err}");
        }

        assert_eq!(
            probe_dimensions(&input).await,
            probe_dimensions(&output).await
        );
    }

    /// Mirrors `transcode_to_jpg_succeeds_when_the_output_path_ends_in_scratch`
    /// -- without `-f mp4` this fails because the on-disk extension is
    /// `.scratch`, not `.mp4`.
    #[tokio::test]
    async fn transcode_video_succeeds_when_the_output_path_ends_in_scratch() {
        if check_ffmpeg_available().await.is_err() {
            eprintln!("skipping: ffmpeg/ffprobe not found on PATH");
            return;
        }

        let dir = tempfile::tempdir().unwrap();
        let input = dir.path().join("test.mp4");
        if generate_test_video(&input).await.is_err() {
            eprintln!("skipping: this ffmpeg build can't generate a test fixture");
            return;
        }

        let output = dir.path().join("test-abcdef0123456789.mp4.scratch");
        if let Err(err) = transcode_video(&input, &output, VideoQuality::Low, 1).await {
            eprintln!("skipping: this ffmpeg build can't encode libx265: {err}");
            return;
        }

        assert!(output.exists());
    }

    #[tokio::test]
    async fn transcode_video_fails_fast_on_an_unreadable_input() {
        if check_ffmpeg_available().await.is_err() {
            eprintln!("skipping: ffmpeg/ffprobe not found on PATH");
            return;
        }

        let dir = tempfile::tempdir().unwrap();
        let input = dir.path().join("not-a-video.mp4");
        std::fs::write(&input, b"this is not a real mp4 file").unwrap();
        let output = dir.path().join("out.mp4");

        let result = transcode_video(&input, &output, VideoQuality::Medium, 1).await;
        assert!(result.is_err());
    }

    /// Generates an audio-only `.mp4` (an AAC stream, no video stream at
    /// all) via ffmpeg's `anullsrc` test source -- mirrors the real-world
    /// `audioclip*.mp4` voice-note recordings that reached
    /// `transcode_video` in production (ADR-0141).
    async fn generate_audio_only_mp4(path: &Path) -> Result<(), String> {
        let result = tokio::process::Command::new("ffmpeg")
            .args([
                "-y",
                "-loglevel",
                "error",
                "-f",
                "lavfi",
                "-i",
                "anullsrc=duration=1",
                "-c:a",
                "aac",
                "-f",
                "mp4",
            ])
            .arg(path)
            .output()
            .await
            .map_err(|err| format!("failed to run ffmpeg: {err}"))?;
        if !result.status.success() {
            return Err(String::from_utf8_lossy(&result.stderr).to_string());
        }
        Ok(())
    }

    /// ADR-0141 regression test: an audio-only input must be rejected with a
    /// message naming the real problem, before `ffmpeg` runs -- not with
    /// ffmpeg's own `"Stream map '' matches no streams"` stderr, which named
    /// neither the file nor the cause, and which was retried twice because
    /// it matched no non-retryable signature.
    #[tokio::test]
    async fn transcode_video_fails_with_a_clear_error_on_an_audio_only_input() {
        if check_ffmpeg_available().await.is_err() {
            eprintln!("skipping: ffmpeg/ffprobe not found on PATH");
            return;
        }

        let dir = tempfile::tempdir().unwrap();
        let input = dir.path().join("audioclip.mp4");
        if generate_audio_only_mp4(&input).await.is_err() {
            eprintln!("skipping: this ffmpeg build can't generate an audio-only fixture");
            return;
        }
        assert!(!has_video_stream(&input).await);

        let output = dir.path().join("out.mp4");
        let err = transcode_video(&input, &output, VideoQuality::Medium, 1)
            .await
            .expect_err("an audio-only input must not transcode as video");

        assert!(
            err.contains("has no video stream"),
            "unexpected error: {err}"
        );
        assert!(
            is_non_retryable_transcode_error(&err),
            "an audio-only input is deterministic and must never be retried: {err}"
        );
        assert!(
            !output.exists(),
            "ffmpeg must not have run at all for an audio-only input"
        );
    }

    /// ADR-0142: an audio-only input transcodes to a real `.opus`, with its
    /// duration preserved inside the shared tolerance. Also pins the
    /// ADR-0115 muxer-inference lesson for this output format -- the
    /// scratch path ends in `.scratch`, not `.opus`, so without `-f opus`
    /// ffmpeg could not infer the container.
    #[tokio::test]
    async fn transcode_audio_produces_an_opus_preserving_duration_from_a_scratch_suffixed_path() {
        if check_ffmpeg_available().await.is_err() {
            eprintln!("skipping: ffmpeg/ffprobe not found on PATH");
            return;
        }

        let dir = tempfile::tempdir().unwrap();
        let input = dir.path().join("voice.mp4");
        if generate_audio_only_mp4(&input).await.is_err() {
            eprintln!("skipping: this ffmpeg build can't generate an audio-only fixture");
            return;
        }

        let output = dir.path().join("voice-abcdef0123456789.opus.scratch");
        if let Err(err) = transcode_audio(&input, &output).await {
            eprintln!("skipping: this ffmpeg build can't encode libopus: {err}");
            return;
        }

        assert!(output.exists());
        let before = probe_duration_seconds(&input).await.unwrap();
        let after = probe_duration_seconds(&output).await.unwrap();
        assert!(
            (before - after).abs() <= DURATION_TOLERANCE_SECONDS,
            "duration drifted: {before}s -> {after}s"
        );
    }

    /// The load-bearing ADR-0142 safety guard: an input that still has a
    /// video stream must be refused, never silently stripped of its video.
    /// A `mp4-audio` run's source is not filtered down to audio-only files
    /// (no extension distinguishes them), and ADR-0138/0139's prefix
    /// inference resolves a bare `--source source` to `source:mp4/` -- the
    /// same default address a video run uses.
    #[tokio::test]
    async fn transcode_audio_refuses_an_input_that_still_has_a_video_stream() {
        if check_ffmpeg_available().await.is_err() {
            eprintln!("skipping: ffmpeg/ffprobe not found on PATH");
            return;
        }

        let dir = tempfile::tempdir().unwrap();
        let input = dir.path().join("real-video.mp4");
        if generate_test_video(&input).await.is_err() {
            eprintln!("skipping: this ffmpeg build can't generate a test fixture");
            return;
        }

        let output = dir.path().join("out.opus");
        let err = transcode_audio(&input, &output)
            .await
            .expect_err("a file with a video stream must not be transcoded to audio-only");

        assert!(
            err.contains("is not an audio-only recording"),
            "unexpected error: {err}"
        );
        assert!(
            is_non_retryable_transcode_error(&err),
            "this is deterministic and must never be retried: {err}"
        );
        assert!(
            !output.exists(),
            "ffmpeg must not have run at all for a video-bearing input"
        );
    }

    #[tokio::test]
    async fn transcode_audio_fails_fast_on_an_unreadable_input() {
        if check_ffmpeg_available().await.is_err() {
            eprintln!("skipping: ffmpeg/ffprobe not found on PATH");
            return;
        }

        let dir = tempfile::tempdir().unwrap();
        let input = dir.path().join("not-audio.mp4");
        std::fs::write(&input, b"this is not a real mp4 file").unwrap();
        let output = dir.path().join("out.opus");

        assert!(transcode_audio(&input, &output).await.is_err());
    }

    #[tokio::test]
    async fn has_video_stream_is_true_for_a_real_video_and_false_for_an_unreadable_file() {
        if check_ffmpeg_available().await.is_err() {
            eprintln!("skipping: ffmpeg/ffprobe not found on PATH");
            return;
        }

        let dir = tempfile::tempdir().unwrap();
        let video = dir.path().join("test.mp4");
        if generate_test_video(&video).await.is_err() {
            eprintln!("skipping: this ffmpeg build can't generate a test fixture");
            return;
        }
        assert!(has_video_stream(&video).await);

        let garbage = dir.path().join("not-a-video.mp4");
        std::fs::write(&garbage, b"this is not a real mp4 file").unwrap();
        assert!(!has_video_stream(&garbage).await);

        assert!(!has_video_stream(&dir.path().join("does-not-exist.mp4")).await);
    }

    #[test]
    fn is_non_retryable_transcode_error_matches_every_known_deterministic_signature() {
        assert!(is_non_retryable_transcode_error(
            "ffmpeg failed to transcode /x.heic: [mov,mp4,m4a,3gp,3g2,mj2 @ 0x1] moov atom not found\n\
             /x.heic: Invalid data found when processing input"
        ));
        assert!(is_non_retryable_transcode_error(
            "ffmpeg failed to transcode /x.heic: Invalid data found when processing input"
        ));
        assert!(is_non_retryable_transcode_error(
            "ffmpeg: Decoder (codec heic) not found for input stream"
        ));
        assert!(is_non_retryable_transcode_error(
            "ffmpeg failed to transcode /x.mov: could not find codec parameters"
        ));
        assert!(is_non_retryable_transcode_error(
            "transcoding /x.png changed pixel area from (100, 100) (10000 px) to (0, 0) (0 px); \
             refusing an output that isn't full-size"
        ));
        assert!(is_non_retryable_transcode_error(
            "ffmpeg failed to transcode /x.mp4: Unknown encoder 'libx265'"
        ));
        assert!(is_non_retryable_transcode_error(
            "/x.mp4 has no video stream (likely an audio-only recording saved with a video \
             extension); see ADR-0142 for an audio-preserving input type"
        ));
    }

    #[test]
    fn is_non_retryable_transcode_error_does_not_match_a_plausible_transient_error() {
        assert!(!is_non_retryable_transcode_error(
            "failed to run ffmpeg: Resource temporarily unavailable (os error 11)"
        ));
        assert!(!is_non_retryable_transcode_error(
            "ffmpeg failed to transcode /x.png: Cannot allocate memory"
        ));
    }

    #[test]
    fn is_non_retryable_transcode_error_is_false_for_an_empty_string() {
        assert!(!is_non_retryable_transcode_error(""));
    }

    /// ADR-0143: the predicate that licenses deleting a failed file's local
    /// source copy must match exactly the two wrong-input-kind guards and
    /// nothing else -- a corrupt file is non-retryable too, but its local
    /// copy is still wanted for diagnosis (ADR-0124).
    #[test]
    fn is_wrong_input_kind_error_matches_both_guards_and_nothing_else() {
        assert!(is_wrong_input_kind_error(
            "/x.mp4 has no video stream (likely an audio-only recording saved with a video \
             extension); see ADR-0142 for an audio-preserving input type"
        ));
        assert!(is_wrong_input_kind_error(
            "/x.mp4 has a video stream, so it is not an audio-only recording; refusing to \
             discard video (use --input-file-type mp4 to transcode it as video)"
        ));

        // Every other failure class, including other non-retryable ones.
        assert!(!is_wrong_input_kind_error(
            "ffmpeg failed to transcode /x.mp4: Invalid data found when processing input"
        ));
        assert!(!is_wrong_input_kind_error(
            "ffmpeg failed to transcode /x.mp4: moov atom not found"
        ));
        assert!(!is_wrong_input_kind_error(
            "ffmpeg failed to transcode /x.mp4: Unknown encoder 'libx265'"
        ));
        assert!(!is_wrong_input_kind_error(
            "failed to run ffmpeg: Resource temporarily unavailable (os error 11)"
        ));
        assert!(!is_wrong_input_kind_error(""));
    }

    /// Both guards' real messages must be classified non-retryable as well
    /// -- built here by calling the real producers' format strings' const
    /// substrings, so this fails if a const and its `format!` drift apart.
    #[test]
    fn both_wrong_input_kind_signatures_are_also_non_retryable() {
        for signature in [NO_VIDEO_STREAM_SIGNATURE, UNEXPECTED_VIDEO_STREAM_SIGNATURE] {
            let message = format!("/x.mp4 {signature} ...");
            assert!(
                is_non_retryable_transcode_error(&message),
                "{signature} must be non-retryable"
            );
            assert!(
                is_wrong_input_kind_error(&message),
                "{signature} must be a wrong-input-kind error"
            );
        }
    }

    /// Confirms the plain (no stream group) probe path is unaffected by the
    /// `-show_stream_groups` addition -- every ordinary PNG/JPEG input and
    /// the single-frame `.jpg` output both lack a stream group entirely, so
    /// `probe_dimensions` must still fall back to the first stream's
    /// dimensions exactly as before.
    #[tokio::test]
    async fn probe_dimensions_falls_back_to_the_plain_stream_when_there_is_no_stream_group() {
        if check_ffmpeg_available().await.is_err() {
            eprintln!("skipping: ffmpeg/ffprobe not found on PATH");
            return;
        }

        let dir = tempfile::tempdir().unwrap();
        let input = dir.path().join("test.png");
        generate_test_png(&input).await.unwrap();

        assert_eq!(probe_dimensions(&input).await, Some((64, 48)));
    }

    /// The area-based comparison in `transcode_to_jpg` must accept a
    /// transposed-but-equal-area pair (a HEIF `irot` rotation between the
    /// raw input canvas reading and the rotation-corrected transcoded
    /// output, ADR-0121) and still reject a genuinely different-area pair.
    #[test]
    fn dimension_guard_area_comparison_accepts_a_transpose_and_rejects_a_real_mismatch() {
        let transposed_a: (u32, u32) = (4032, 3024);
        let transposed_b: (u32, u32) = (3024, 4032);
        let area_a = transposed_a.0 as u64 * transposed_a.1 as u64;
        let area_b = transposed_b.0 as u64 * transposed_b.1 as u64;
        assert_eq!(area_a, area_b);

        let truncated: (u32, u32) = (512, 512);
        let area_truncated = truncated.0 as u64 * truncated.1 as u64;
        assert_ne!(area_a, area_truncated);
    }
}