diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 0e51ed27..ff680bda 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -27,7 +27,7 @@ artwork. The ribbon and menu icons stay original drawings. | `assets/app-icon/icon-source.deckcraft` | DeckCraft contributors | made with `deckcraft-cli run` (see git history) | MIT OR Apache-2.0 | Source of the placeholder icon | | `docs/images/editor.png`, `docs/images/shape-format.png`, `docs/images/format-pane.png`, `docs/images/slide-sorter.png`, `docs/images/dark-mode.png`, `docs/images/sample-deck.png` | DeckCraft contributors | Screenshots of DeckCraft and slides rendered by `deckcraft-cli` from the built-in sample deck (original content; Inter font, OFL-1.1) | MIT OR Apache-2.0 | README screenshots | | `crates/media/tests/fixtures/tone*` (`tone.mp3`, `.m4a`, `.aac`, `.ogg`, `.opus`, `.flac`, `.wma`, `tone-alac.m4a`) | DeckCraft contributors | synthesised 0.5 s 440 Hz sine, encoded by `crates/media/tests/fixtures/make.sh` (ffmpeg as the encoder only) | CC0-1.0 | Audio decoding tests | -| `crates/media/tests/fixtures/clip-*` (`clip-h264.mp4`, `clip-h264.mov`, `clip-hevc.mp4`, `clip-vp9.webm`, `clip-av1.mkv`) | DeckCraft contributors | 6 frames of a colour gradient generated by `make.sh` (python3), encoded by ffmpeg (x264 / x265 / libvpx / SVT-AV1 as encoders only) | CC0-1.0 | Video decoding tests | +| `crates/media/tests/fixtures/clip-*` (`clip-h264.mp4`, `clip-h264.mov`, `clip-hevc.mp4`, `clip-vp8.webm`, `clip-vp9.webm`, `clip-av1.mkv`) | DeckCraft contributors | 6 frames of a colour gradient generated by `make.sh` (python3), encoded by ffmpeg (x264 / x265 / libvpx / libvpx-vp9 / SVT-AV1 as encoders only) | CC0-1.0 | Video decoding tests | | `crates/media/tests/fixtures/damaged/*` (`ebml-header.mkv`, `ebml-position.webm`, `demux-loop.mp4`, `demux-loop.m4a`) | DeckCraft contributors | the `clip-*` and `tone*` fixtures above with bytes changed by a mutation fuzzer | CC0-1.0 | Damaged media must not panic or hang | | `crates/media/tests/fixtures/damaged/*` (`stsc-count.m4a`, `stsz-size.m4a`) | DeckCraft contributors | `tone.m4a` above with bytes changed by a mutation fuzzer | CC0-1.0 | Damaged MP4 sample tables must not abort | | `crates/media/tests/fixtures/make.sh` | DeckCraft contributors | this repository | MIT OR Apache-2.0 | Regenerates the media fixtures | diff --git a/crates/engine/src/cmd/insert.rs b/crates/engine/src/cmd/insert.rs index f36b3506..df15187c 100644 --- a/crates/engine/src/cmd/insert.rs +++ b/crates/engine/src/cmd/insert.rs @@ -1281,14 +1281,25 @@ fn media(s: &mut Session, p: &Value, video: bool) -> Result { } Err(_) => (0, 0, 0, false), }; - let warning = (!playable).then(|| match &info { - Ok(i) => { - let codecs: Vec<&str> = i.audio.iter().map(|a| a.codec.as_str()).chain(i.video.iter().map(|v| v.codec.as_str())).collect(); - format!("{name} was inserted, but it can't be played ({} isn't supported yet)", codecs.join(" / ")) - } - Err(e) => format!("{name} was inserted, but it can't be played ({e})"), - }); let decodable_video = info.as_ref().is_ok_and(|i| i.video.as_ref().is_some_and(|v| v.decodable)); + let warning = if !playable { + Some(match &info { + Ok(i) => { + let codecs: Vec<&str> = i.audio.iter().map(|a| a.codec.as_str()).chain(i.video.iter().map(|v| v.codec.as_str())).collect(); + format!("{name} was inserted, but it can't be played ({} isn't supported yet)", codecs.join(" / ")) + } + Err(e) => format!("{name} was inserted, but it can't be played ({e})"), + }) + } else if video && !decodable_video { + // A video codec without a decoder (VP8, say): the clip's audio still plays, but no frame + // can ever show — say so instead of leaving an unexplained black box (#1237). + info.as_ref() + .ok() + .and_then(|i| i.video.as_ref()) + .map(|v| format!("{name} was inserted, but its video can't be played ({} isn't supported yet); only the audio plays", v.codec)) + } else { + None + }; let poster_png = if video && decodable_video { super::media::poster_png(&bytes, 0) } else { None }; let size = s.doc()?.doc.slide_size; let rect = rect_param(p, "rect").unwrap_or_else(|| { diff --git a/crates/engine/src/cmd/media.rs b/crates/engine/src/cmd/media.rs index e4ceb9f6..04179321 100644 --- a/crates/engine/src/cmd/media.rs +++ b/crates/engine/src/cmd/media.rs @@ -290,4 +290,16 @@ mod tests { assert_eq!(r["playable"], false); assert!(r["warning"].as_str().is_some_and(|w| w.contains("can't be played")), "{r}"); } + + #[test] + fn a_vp8_webm_is_playable_and_warns_that_only_the_audio_plays() { + let mut s = Session::with_new(); + let _ = s.execute("slide.new", &json!({"layout": "blank"})); + let clip = include_bytes!("../../../media/tests/fixtures/clip-vp8.webm"); + let r = s.execute("insert.video", &json!({"name": "clip.webm", "data": base64_encode(clip)})).expect("insert"); + assert_eq!(r["playable"], true, "the Vorbis track plays: {r}"); + let w = r["warning"].as_str().expect("the VP8 track can't be shown"); + assert!(w.contains("VP8") && w.contains("only the audio plays"), "{w}"); + assert!(matches!(s.ui_requests.last(), Some(UiRequest::Message { .. })), "the warning reaches the status bar"); + } } diff --git a/crates/media/tests/fixtures/clip-vp8.webm b/crates/media/tests/fixtures/clip-vp8.webm new file mode 100644 index 00000000..5d727361 Binary files /dev/null and b/crates/media/tests/fixtures/clip-vp8.webm differ diff --git a/crates/media/tests/fixtures/make.sh b/crates/media/tests/fixtures/make.sh index d85419b9..abb064d3 100755 --- a/crates/media/tests/fixtures/make.sh +++ b/crates/media/tests/fixtures/make.sh @@ -37,4 +37,5 @@ $FF $VID $TONE44 -c:v libx264 -pix_fmt yuv420p -g 5 -bf 2 -c:a aac -b:a 48k -sho $FF $VID -c:v libx264 -pix_fmt yuv420p -g 5 -bf 0 $META clip-h264.mov $FF $VID -c:v libx265 -pix_fmt yuv420p -x265-params log-level=error:keyint=5 -tag:v hvc1 $META clip-hevc.mp4 $FF $VID $TONE -c:v libvpx-vp9 -pix_fmt yuv420p -g 5 -b:v 100k -c:a libopus -b:a 32k -shortest $META clip-vp9.webm +$FF $VID $TONE44 -c:v libvpx -pix_fmt yuv420p -g 5 -b:v 100k -deadline realtime -c:a vorbis -strict -2 -ac 2 -b:a 48k -shortest $META clip-vp8.webm $FF -f rawvideo -pix_fmt rgb24 -s 64x64 -r 10 -i "$TMP_DIR/deckcraft-fixture-64x64.rgb" -c:v libsvtav1 -pix_fmt yuv420p -g 5 -svtav1-params loglevel=0 $META clip-av1.mkv diff --git a/crates/ui-egui/src/media.rs b/crates/ui-egui/src/media.rs index a65358eb..657e92a7 100644 --- a/crates/ui-egui/src/media.rs +++ b/crates/ui-egui/src/media.rs @@ -736,6 +736,37 @@ mod tests { b } + /// A video whose codec has no decoder (a VP8 WebM, #1237) still plays through the real + /// insert + play path: its audio track sounds, and the insert said the video won't show. + #[test] + fn a_webm_with_an_undecodable_video_codec_still_plays_its_audio() { + use deckcraft_engine::Session; + let bytes = include_bytes!("../../media/tests/fixtures/clip-vp8.webm"); + let mut s = Session::with_new(); + let _ = s.execute("slide.new", &serde_json::json!({"layout": "blank"})); + let r = s + .execute("insert.video", &serde_json::json!({"name": "clip.webm", "data": deckcraft_engine::cmd::base64_encode(bytes)})) + .expect("insert"); + assert_eq!(r["playable"], true, "the Vorbis track plays: {r}"); + let w = r["warning"].as_str().expect("the insert says the video won't show"); + assert!(w.contains("VP8") && w.contains("only the audio plays"), "{w}"); + let (uid, doc) = s.active().map(|d| (d.uid, d.doc.clone())).expect("document"); + let id = ShapeId(r["id"].as_u64().expect("id") as u32); + let clip = find_clip(&doc, id, Some(0)).expect("clip"); + let mut host = MediaHost::new(None); + host.play(uid, &doc, id, &clip, None, Owner::Show); + assert!(host.player.wait_loaded(key(id), std::time::Duration::from_secs(5)), "decode"); + assert!(host.status(id).is_some_and(|s| s.error.is_none()), "Vorbis decodes"); + let mut buf = vec![0.0f32; 22_050]; + host.player.render(&mut buf, 1, 44_100); + // The fixture's sine is −21 dB, so a peak near 0.09 means it is sounding. + let peak = buf.iter().fold(0.0f32, |m, s| m.max(s.abs())); + assert!(peak > 0.04, "the audio track sounds: peak {peak}"); + assert!(host.videos.get(&id).is_some_and(|v| v.feed.is_none()), "VP8 opens no video feed"); + host.tick(&egui::Context::default(), &mut s); + assert!(host.texture(id).is_none(), "no VP8 frame exists to show"); + } + #[test] fn grouped_gifs_play_in_slide_space() { let mut doc = Presentation::default();