From 238b34be00d6d03a5a4dd0faadf9f89398ead942 Mon Sep 17 00:00:00 2001 From: SusieeTheFemboy <96988330+SusieeTheLinuxUser@users.noreply.github.com> Date: Sat, 24 Oct 2026 06:14:01 +0200 Subject: [PATCH] Media: a video with an unsupported codec says only its audio plays (#1237) insert.video answered playable=true with no warning for a clip whose video codec has no decoder (a VP8 + Vorbis WebM, say): its audio track plays, but the slide shows an unexplained black box, which reads as "black with no audio". The insert now warns that the video can't be played yet and only the audio plays. The missing audio itself is not reproducible: through the real insert + play path the Vorbis track of a VP8 WebM decodes and sounds, on main and on 0.10.0 (the reporter's build). A regression test drives that whole path, on a new synthesised clip-vp8.webm fixture. Co-Authored-By: GLM-5.3 --- ATTRIBUTION.md | 2 +- crates/engine/src/cmd/insert.rs | 25 ++++++++++++----- crates/engine/src/cmd/media.rs | 12 +++++++++ crates/media/tests/fixtures/clip-vp8.webm | Bin 0 -> 7547 bytes crates/media/tests/fixtures/make.sh | 1 + crates/ui-egui/src/media.rs | 31 ++++++++++++++++++++++ 6 files changed, 63 insertions(+), 8 deletions(-) create mode 100644 crates/media/tests/fixtures/clip-vp8.webm diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 3299a639..8c874476 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 8bc45380..6b6b6d0e 100644 --- a/crates/engine/src/cmd/insert.rs +++ b/crates/engine/src/cmd/insert.rs @@ -1278,14 +1278,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 85ed6c3f..79c192b9 100644 --- a/crates/engine/src/cmd/media.rs +++ b/crates/engine/src/cmd/media.rs @@ -286,4 +286,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 0000000000000000000000000000000000000000..5d727361484ee5995b05939ab78b3fd5b05c016e GIT binary patch literal 7547 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 Mbx5GA@{-^74}WLjdH?_b literal 0 HcmV?d00001 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 fc23902a..eb4964a6 100644 --- a/crates/ui-egui/src/media.rs +++ b/crates/ui-egui/src/media.rs @@ -727,6 +727,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();