use std::fs::File;
use std::io::{Read as _, Seek as _, SeekFrom};
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::OnceLock;
use std::time::{Duration, Instant};
use image::DynamicImage;
use symphonia_core::codecs::video::{well_known as video_codecs, VideoCodecParameters};
use symphonia_core::codecs::video::{well_known::extra_data as extra_data_ids, VideoCodecId};
use symphonia_core::codecs::CodecParameters;
use symphonia_core::formats::{FormatOptions, FormatReader, MediaInfo, SeekMode, SeekTo, Track};
use symphonia_core::io::{MediaSource, MediaSourceStream, MediaSourceStreamOptions};
use symphonia_core::units::{Time, Timestamp};
use symphonia_format_mkv::MkvReader;
const THUMB_MAX_PX: u32 = 1024;
const TOOL_TIMEOUT: Duration = Duration::from_secs(30);
const POLL_INTERVAL: Duration = Duration::from_millis(20);
const NATIVE_CONTAINER_EXTS: [(&str, NativeContainer); 5] = [
("mp4", NativeContainer::Mp4),
("m4v", NativeContainer::Mp4),
("mov", NativeContainer::Mp4),
("mkv", NativeContainer::Matroska),
("webm", NativeContainer::Matroska),
];
const DECODABLE_PROFILE_IDCS: [u8; 3] = [66, 77, 100];
const PROFILES_WITH_CHROMA_BLOCK: [u8; 13] =
[100, 110, 122, 244, 44, 83, 86, 118, 128, 138, 139, 134, 135];
const CHROMA_FORMAT_420: u32 = 1;
const DECODABLE_HEVC_PROFILE_IDCS: [u8; 2] = [1, 2];
const DECODABLE_HEVC_BIT_DEPTHS: [u32; 2] = [8, 10];
const MAX_HEVC_SUB_LAYERS_MINUS1: u32 = 6;
const NATIVE_MAX_DIMENSION: u32 = 8192;
const NATIVE_MAX_SAMPLE_BYTES: u64 = 64 * 1024 * 1024;
const NATIVE_FLAT_FRAME_RETRIES: usize = 3;
const NATIVE_FLAT_LUMA_SPREAD: u8 = 8;
const NATIVE_MKV_MAX_SCAN_BYTES: u64 = 32 * 1024 * 1024;
const NATIVE_MKV_MAX_PACKETS: usize = 2000;
const H264_NAL_TYPE_IDR: u8 = 5;
const HEVC_IRAP_NAL_TYPES: [u8; 6] = [16, 17, 18, 19, 20, 21];
pub fn thumbnail(path: &Path, allow_external: bool) -> Option<DynamicImage> {
if let Some(img) = thumbnail_native(path) {
return Some(img);
}
if !allow_external {
return None;
}
thumbnail_external(path)
}
fn thumbnail_native(path: &Path) -> Option<DynamicImage> {
crate::preview::markdown::catch_silent(|| thumbnail_native_inner(path)).flatten()
}
fn thumbnail_native_inner(path: &Path) -> Option<DynamicImage> {
match native_container_kind(path)? {
NativeContainer::Mp4 => thumbnail_native_mp4(path),
NativeContainer::Matroska => thumbnail_native_mkv(path),
}
}
fn thumbnail_native_mp4(path: &Path) -> Option<DynamicImage> {
let mut file = File::open(path).ok()?;
let size = file.metadata().ok()?.len();
let mp4 = re_mp4::Mp4::read(&mut file, size).ok()?;
let track = mp4
.tracks()
.values()
.find(|t| t.kind == Some(re_mp4::TrackKind::Video))?;
let kind = native_codec_kind(track.codec_string(&mp4).as_deref())?;
let cfg = track.raw_codec_config(&mp4)?;
let stream = native_stream_from_config(kind, &cfg)?;
let mut candidates = pick_keyframes(&track.samples, NATIVE_FLAT_FRAME_RETRIES).into_iter();
thumbnail_from_keyframes(&stream, || loop {
let Some(sample) = track.samples.get(candidates.next()?) else {
continue;
};
if sample.size == 0 || sample.size > NATIVE_MAX_SAMPLE_BYTES {
continue;
}
if let Some(buf) = read_sample_bytes(&mut file, sample.offset, sample.size) {
return Some(buf);
}
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum NativeContainer {
Mp4,
Matroska,
}
fn native_container_kind(path: &Path) -> Option<NativeContainer> {
let ext = path.extension().and_then(|e| e.to_str())?;
NATIVE_CONTAINER_EXTS
.iter()
.find(|(known, _)| ext.eq_ignore_ascii_case(known))
.map(|(_, container)| *container)
}
fn native_stream_from_config(kind: NativeCodecKind, cfg: &[u8]) -> Option<NativeStream<'_>> {
match kind {
NativeCodecKind::Avc => {
let avcc = rust_h264::nal::parse_avcc_config(cfg).ok()?;
let sps = parse_sps_facts(&avcc.sps_nals.first()?.rbsp)?;
if !sps_is_decodable(&sps) {
return None;
}
Some(NativeStream::Avc(avcc))
}
NativeCodecKind::Hevc => {
let hvcc = parse_hvcc(cfg)?;
let sps = parse_hevc_sps_facts(&hvcc.sps_rbsp)?;
if !hevc_sps_is_decodable(&sps) {
return None;
}
Some(NativeStream::Hevc(hvcc))
}
}
}
fn thumbnail_from_keyframes(
stream: &NativeStream<'_>,
mut next_keyframe: impl FnMut() -> Option<Vec<u8>>,
) -> Option<DynamicImage> {
let mut last: Option<DynamicImage> = None;
for _ in 0..NATIVE_FLAT_FRAME_RETRIES {
let Some(buf) = next_keyframe() else {
break;
};
let Some(frame) = decode_keyframe(stream, &buf) else {
continue;
};
let flat = luma_spread(&frame.y) < NATIVE_FLAT_LUMA_SPREAD;
let Some(img) = frame_to_image(&frame) else {
continue;
};
if !flat {
return Some(shrink_to_thumb(img));
}
if last.is_none() {
last = Some(img);
}
}
last.map(shrink_to_thumb)
}
fn thumbnail_native_mkv(path: &Path) -> Option<DynamicImage> {
match mkv_attempt(path, true, MkvBudget::DEFAULT) {
MkvAttempt::Frame(img) => Some(img),
MkvAttempt::NoFrame { sought: true } => {
match mkv_attempt(path, false, MkvBudget::DEFAULT) {
MkvAttempt::Frame(img) => Some(img),
_ => None,
}
}
_ => None,
}
}
#[derive(Debug, Clone, Copy)]
struct MkvBudget {
bytes: u64,
packets: usize,
}
impl MkvBudget {
const DEFAULT: Self = Self {
bytes: NATIVE_MKV_MAX_SCAN_BYTES,
packets: NATIVE_MKV_MAX_PACKETS,
};
}
enum MkvAttempt {
Frame(DynamicImage),
Refused,
NoFrame { sought: bool },
}
fn mkv_attempt(path: &Path, seek_to_ten_percent: bool, budget: MkvBudget) -> MkvAttempt {
let Some(source) = BudgetedSource::open(path, budget.bytes) else {
return MkvAttempt::Refused;
};
let mss = MediaSourceStream::new(Box::new(source), MediaSourceStreamOptions::default());
let Ok(mut reader) = MkvReader::try_new(mss, FormatOptions::default()) else {
return MkvAttempt::Refused;
};
let Some(track) = find_mkv_track(&reader) else {
return MkvAttempt::Refused;
};
let Some(stream) = native_stream_from_config(track.kind, &track.cfg) else {
return MkvAttempt::Refused;
};
let length_size = stream_length_size(&stream);
let mut sought = false;
if seek_to_ten_percent {
if let Some(time) = track.ten_percent {
sought = reader
.seek(
SeekMode::Coarse,
SeekTo::Time {
time,
track_id: Some(track.id),
},
)
.is_ok();
}
}
let mut packets_left = budget.packets;
let found = thumbnail_from_keyframes(&stream, || {
while packets_left > 0 {
packets_left -= 1;
let packet = reader.next_packet().ok()??;
if packet.track_id != track.id
|| packet.data.is_empty()
|| packet.data.len() as u64 > NATIVE_MAX_SAMPLE_BYTES
|| !sample_has_keyframe(track.kind, &packet.data, length_size)
{
continue;
}
return Some(packet.data.into_vec());
}
None
});
match found {
Some(img) => MkvAttempt::Frame(img),
None => MkvAttempt::NoFrame { sought },
}
}
struct MkvTrack {
id: u32,
kind: NativeCodecKind,
cfg: Vec<u8>,
ten_percent: Option<Time>,
}
fn find_mkv_track(reader: &dyn FormatReader) -> Option<MkvTrack> {
reader.tracks().iter().find_map(|t| {
let CodecParameters::Video(v) = t.codec_params.as_ref()? else {
return None;
};
let kind = mkv_codec_kind(v.codec)?;
Some(MkvTrack {
id: t.id,
kind,
cfg: extra_data_for(v, kind)?.to_vec(),
ten_percent: ten_percent_time(reader.media_info(), t),
})
})
}
fn mkv_codec_kind(codec: VideoCodecId) -> Option<NativeCodecKind> {
match codec {
video_codecs::CODEC_ID_H264 => Some(NativeCodecKind::Avc),
video_codecs::CODEC_ID_HEVC => Some(NativeCodecKind::Hevc),
_ => None,
}
}
fn extra_data_for(params: &VideoCodecParameters, kind: NativeCodecKind) -> Option<&[u8]> {
let wanted = match kind {
NativeCodecKind::Avc => extra_data_ids::VIDEO_EXTRA_DATA_ID_AVC_DECODER_CONFIG,
NativeCodecKind::Hevc => extra_data_ids::VIDEO_EXTRA_DATA_ID_HEVC_DECODER_CONFIG,
};
params
.extra_data
.iter()
.find(|e| e.id == wanted)
.map(|e| &*e.data)
}
fn ten_percent_time(media: &MediaInfo, track: &Track) -> Option<Time> {
track.time_base?;
let total = media
.time_base?
.calc_time(Timestamp::new(i64::try_from(media.duration?.get()).ok()?))?;
Some(Time::from_nanos(i64::try_from(total.as_nanos() / 10).ok()?))
}
fn stream_length_size(stream: &NativeStream<'_>) -> usize {
match stream {
NativeStream::Avc(avcc) => avcc.length_size,
NativeStream::Hevc(hvcc) => hvcc.length_size,
}
}
fn sample_has_keyframe(kind: NativeCodecKind, sample: &[u8], length_size: usize) -> bool {
let mut found = false;
for_each_nal(sample, length_size, |nal| {
let Some(&first) = nal.first() else {
return;
};
found |= match kind {
NativeCodecKind::Avc => first & 0x1f == H264_NAL_TYPE_IDR,
NativeCodecKind::Hevc => HEVC_IRAP_NAL_TYPES.contains(&((first >> 1) & 0x3f)),
};
});
found
}
struct BudgetedSource {
file: File,
len: Option<u64>,
remaining: u64,
}
impl BudgetedSource {
fn open(path: &Path, budget: u64) -> Option<Self> {
let file = File::open(path).ok()?;
let len = file.metadata().ok().map(|m| m.len());
Some(Self {
file,
len,
remaining: budget,
})
}
}
impl std::io::Read for BudgetedSource {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
if self.remaining == 0 {
return Err(std::io::Error::other("video scan read budget exhausted"));
}
let cap = usize::try_from(self.remaining).unwrap_or(usize::MAX);
let take = buf.len().min(cap);
let read = self.file.read(&mut buf[..take])?;
self.remaining -= read as u64;
Ok(read)
}
}
impl std::io::Seek for BudgetedSource {
fn seek(&mut self, pos: SeekFrom) -> std::io::Result<u64> {
self.file.seek(pos)
}
}
impl MediaSource for BudgetedSource {
fn is_seekable(&self) -> bool {
self.len.is_some()
}
fn byte_len(&self) -> Option<u64> {
self.len
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum NativeCodecKind {
Avc,
Hevc,
}
enum NativeStream<'a> {
Avc(rust_h264::nal::AvccConfig<'a>),
Hevc(HvccConfig),
}
fn native_codec_kind(codec: Option<&str>) -> Option<NativeCodecKind> {
if codec_string_is_avc(codec) {
Some(NativeCodecKind::Avc)
} else if codec_string_is_hevc(codec) {
Some(NativeCodecKind::Hevc)
} else {
None
}
}
fn codec_string_is_avc(codec: Option<&str>) -> bool {
codec
.map(|c| c.starts_with("avc1") || c.starts_with("avc3"))
.unwrap_or(false)
}
fn codec_string_is_hevc(codec: Option<&str>) -> bool {
codec
.map(|c| c.starts_with("hvc1") || c.starts_with("hev1"))
.unwrap_or(false)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct SpsFacts {
profile_idc: u8,
chroma_format_idc: u32,
bit_depth_luma: u32,
bit_depth_chroma: u32,
width: u32,
height: u32,
}
fn sps_is_decodable(sps: &SpsFacts) -> bool {
DECODABLE_PROFILE_IDCS.contains(&sps.profile_idc)
&& sps.chroma_format_idc == CHROMA_FORMAT_420
&& sps.bit_depth_luma == 8
&& sps.bit_depth_chroma == 8
&& sps.width > 0
&& sps.height > 0
&& sps.width <= NATIVE_MAX_DIMENSION
&& sps.height <= NATIVE_MAX_DIMENSION
}
fn parse_sps_facts(rbsp: &[u8]) -> Option<SpsFacts> {
let profile_idc = *rbsp.first()?;
let mut r = BitReader::new(rbsp.get(3..)?);
r.ue()?;
let (mut chroma_format_idc, mut bit_depth_luma, mut bit_depth_chroma) =
(CHROMA_FORMAT_420, 8, 8);
if PROFILES_WITH_CHROMA_BLOCK.contains(&profile_idc) {
chroma_format_idc = r.ue()?;
if chroma_format_idc == 3 {
r.bit()?; }
bit_depth_luma = 8 + r.ue()?;
bit_depth_chroma = 8 + r.ue()?;
r.bit()?; if r.bit()? == 1 {
let lists = if chroma_format_idc == 3 { 12 } else { 8 };
for i in 0..lists {
if r.bit()? == 1 {
r.skip_scaling_list(if i < 6 { 16 } else { 64 })?;
}
}
}
}
r.ue()?; match r.ue()? {
0 => {
r.ue()?; }
1 => {
r.bit()?; r.se()?; r.se()?; let cycle = r.ue()?;
if cycle > 255 {
return None;
}
for _ in 0..cycle {
r.se()?; }
}
2 => {}
_ => return None, }
r.ue()?; r.bit()?;
let width_mbs = r.ue()?.checked_add(1)?;
let height_map_units = r.ue()?.checked_add(1)?;
let frame_mbs_only_flag = r.bit()?;
Some(SpsFacts {
profile_idc,
chroma_format_idc,
bit_depth_luma,
bit_depth_chroma,
width: width_mbs.checked_mul(16)?,
height: (2 - frame_mbs_only_flag)
.checked_mul(height_map_units)?
.checked_mul(16)?,
})
}
struct HvccConfig {
parameter_sets: Vec<u8>,
sps_rbsp: Vec<u8>,
length_size: usize,
}
const HEVC_NAL_TYPE_SPS: u8 = 33;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct HevcSpsFacts {
general_profile_idc: u8,
chroma_format_idc: u32,
bit_depth_luma: u32,
bit_depth_chroma: u32,
width: u32,
height: u32,
}
fn hevc_sps_is_decodable(sps: &HevcSpsFacts) -> bool {
DECODABLE_HEVC_PROFILE_IDCS.contains(&sps.general_profile_idc)
&& sps.chroma_format_idc == CHROMA_FORMAT_420
&& sps.bit_depth_luma == sps.bit_depth_chroma
&& DECODABLE_HEVC_BIT_DEPTHS.contains(&sps.bit_depth_luma)
&& sps.width > 0
&& sps.height > 0
&& sps.width <= NATIVE_MAX_DIMENSION
&& sps.height <= NATIVE_MAX_DIMENSION
}
fn parse_hevc_sps_facts(rbsp: &[u8]) -> Option<HevcSpsFacts> {
let mut r = BitReader::new(rbsp);
r.bits(4)?; let max_sub_layers_minus1 = r.bits(3)?;
if max_sub_layers_minus1 > MAX_HEVC_SUB_LAYERS_MINUS1 {
return None;
}
r.bit()?; let general_profile_idc = skip_profile_tier_level(&mut r, max_sub_layers_minus1)?;
r.ue()?; let chroma_format_idc = r.ue()?;
if chroma_format_idc == 3 {
r.bit()?; }
let width = r.ue()?;
let height = r.ue()?;
if r.bit()? == 1 {
r.ue()?;
r.ue()?;
r.ue()?;
r.ue()?;
}
let bit_depth_luma = 8 + r.ue()?;
let bit_depth_chroma = 8 + r.ue()?;
Some(HevcSpsFacts {
general_profile_idc,
chroma_format_idc,
bit_depth_luma,
bit_depth_chroma,
width,
height,
})
}
fn skip_profile_tier_level(r: &mut BitReader<'_>, max_sub_layers_minus1: u32) -> Option<u8> {
r.bits(2)?; r.bit()?; let general_profile_idc = r.bits(5)? as u8;
r.bits(32)?; r.bits(24)?; r.bits(24)?;
r.bits(8)?;
if max_sub_layers_minus1 > 0 {
let mut profile_present = [false; MAX_HEVC_SUB_LAYERS_MINUS1 as usize];
let mut level_present = [false; MAX_HEVC_SUB_LAYERS_MINUS1 as usize];
for i in 0..max_sub_layers_minus1 as usize {
profile_present[i] = r.bit()? == 1;
level_present[i] = r.bit()? == 1;
}
for _ in max_sub_layers_minus1..8 {
r.bits(2)?;
}
for i in 0..max_sub_layers_minus1 as usize {
if profile_present[i] {
r.bits(2)?;
r.bit()?;
r.bits(5)?;
r.bits(32)?;
r.bits(24)?;
r.bits(24)?;
}
if level_present[i] {
r.bits(8)?; }
}
}
Some(general_profile_idc)
}
fn parse_hvcc(cfg: &[u8]) -> Option<HvccConfig> {
let num_arrays = *cfg.get(22)?;
let length_size = usize::from((cfg.get(21)? & 0x03) + 1);
let mut parameter_sets = Vec::new();
let mut sps_rbsp: Option<Vec<u8>> = None;
let mut i = 23usize;
for _ in 0..num_arrays {
let nal_type = cfg.get(i)? & 0x3f;
let count = u16::from_be_bytes([*cfg.get(i + 1)?, *cfg.get(i + 2)?]);
i += 3;
for _ in 0..count {
let len = usize::from(u16::from_be_bytes([*cfg.get(i)?, *cfg.get(i + 1)?]));
i += 2;
let nal = cfg.get(i..i.checked_add(len)?)?;
i += len;
parameter_sets.extend_from_slice(&[0, 0, 0, 1]);
parameter_sets.extend_from_slice(nal);
if nal_type == HEVC_NAL_TYPE_SPS && sps_rbsp.is_none() {
sps_rbsp = Some(strip_emulation_prevention(nal.get(2..)?));
}
}
}
Some(HvccConfig {
parameter_sets,
sps_rbsp: sps_rbsp?,
length_size,
})
}
fn strip_emulation_prevention(payload: &[u8]) -> Vec<u8> {
let mut out = Vec::with_capacity(payload.len());
let mut zeros = 0usize;
for &b in payload {
if zeros >= 2 && b == 0x03 {
zeros = 0; continue;
}
zeros = if b == 0 { zeros + 1 } else { 0 };
out.push(b);
}
out
}
struct BitReader<'a> {
data: &'a [u8],
pos: usize,
}
impl<'a> BitReader<'a> {
fn new(data: &'a [u8]) -> Self {
Self { data, pos: 0 }
}
fn bit(&mut self) -> Option<u32> {
let byte = *self.data.get(self.pos / 8)?;
let shift = 7 - (self.pos % 8);
self.pos += 1;
Some(u32::from((byte >> shift) & 1))
}
fn bits(&mut self, n: u32) -> Option<u32> {
if n > 32 {
return None;
}
let start = self.pos;
let mut value = 0u32;
for _ in 0..n {
match self.bit() {
Some(b) => value = (value << 1) | b,
None => {
self.pos = start;
return None;
}
}
}
Some(value)
}
fn ue(&mut self) -> Option<u32> {
let mut leading = 0;
while self.bit()? == 0 {
leading += 1;
if leading > 31 {
return None;
}
}
let mut value = 0u32;
for _ in 0..leading {
value = (value << 1) | self.bit()?;
}
(1u32 << leading).checked_sub(1)?.checked_add(value)
}
fn se(&mut self) -> Option<i32> {
let k = self.ue()?;
let magnitude = i64::from(k).div_euclid(2) + i64::from(k % 2);
Some(if k % 2 == 1 {
magnitude as i32
} else {
-(magnitude as i32)
})
}
fn skip_scaling_list(&mut self, size: usize) -> Option<()> {
let mut last_scale = 8i32;
let mut next_scale = 8i32;
for _ in 0..size {
if next_scale != 0 {
let delta = self.se()?;
next_scale = (last_scale + delta + 256).rem_euclid(256);
}
if next_scale != 0 {
last_scale = next_scale;
}
}
Some(())
}
}
fn pick_keyframes(samples: &[re_mp4::Sample], limit: usize) -> Vec<usize> {
let syncs: Vec<usize> = samples
.iter()
.enumerate()
.filter(|(_, s)| s.is_sync)
.map(|(i, _)| i)
.collect();
if syncs.is_empty() {
return Vec::new();
}
let target = samples.len() / 10;
let start = syncs
.iter()
.enumerate()
.min_by_key(|(_, &idx)| idx.abs_diff(target))
.map(|(pos, _)| pos)
.unwrap_or(0);
syncs[start..].iter().copied().take(limit).collect()
}
fn read_sample_bytes(file: &mut File, offset: u64, size: u64) -> Option<Vec<u8>> {
file.seek(SeekFrom::Start(offset)).ok()?;
let mut buf = vec![0u8; usize::try_from(size).ok()?];
file.read_exact(&mut buf).ok()?;
Some(buf)
}
struct NativeFrame {
width: u32,
height: u32,
y: Vec<u8>,
u: Vec<u8>,
v: Vec<u8>,
}
fn decode_keyframe(stream: &NativeStream<'_>, sample: &[u8]) -> Option<NativeFrame> {
match stream {
NativeStream::Avc(avcc) => decode_keyframe_avc(avcc, sample),
NativeStream::Hevc(hvcc) => decode_keyframe_hevc(hvcc, sample),
}
}
fn decode_keyframe_avc(
avcc: &rust_h264::nal::AvccConfig<'_>,
sample: &[u8],
) -> Option<NativeFrame> {
let mut decoder = rust_h264::decoder::Decoder::new();
for nal in avcc.sps_nals.iter().chain(avcc.pps_nals.iter()) {
decoder.decode_nal(nal).ok()?;
}
let mut decoded = None;
for nal in rust_h264::nal::parse_avcc(sample, avcc.length_size) {
if let Some(frame) = decoder.decode_nal(&nal).ok()? {
decoded = Some(frame);
break;
}
}
let frame = decoded.or_else(|| decoder.flush())?;
Some(NativeFrame {
width: frame.width,
height: frame.height,
y: frame.y,
u: frame.u,
v: frame.v,
})
}
fn decode_keyframe_hevc(hvcc: &HvccConfig, sample: &[u8]) -> Option<NativeFrame> {
let mut stream = hvcc.parameter_sets.clone();
append_as_annex_b(&mut stream, sample, hvcc.length_size);
let mut decoder = rust_h265::Decoder::new();
let mut decoded = None;
for nal in rust_h265::parse_annex_b(&stream) {
if let Some(frame) = decoder.decode_nal(&nal).ok()? {
decoded = Some(frame);
break;
}
}
let frame = decoded.or_else(|| decoder.flush())?;
hevc_frame_to_native(&frame)
}
fn for_each_nal(sample: &[u8], length_size: usize, mut f: impl FnMut(&[u8])) {
if length_size == 0 {
return;
}
let mut i = 0usize;
while i + length_size <= sample.len() {
let mut len = 0usize;
for k in 0..length_size {
len = (len << 8) | usize::from(sample[i + k]);
}
i += length_size;
let Some(nal) = sample.get(i..i.saturating_add(len)) else {
return;
};
f(nal);
i += len;
}
}
fn append_as_annex_b(out: &mut Vec<u8>, sample: &[u8], length_size: usize) {
for_each_nal(sample, length_size, |nal| {
out.extend_from_slice(&[0, 0, 0, 1]);
out.extend_from_slice(nal);
});
}
fn hevc_frame_to_native(frame: &rust_h265::Frame) -> Option<NativeFrame> {
if !(8..=16).contains(&frame.bit_depth) {
return None;
}
let max = (1u32 << frame.bit_depth) - 1;
Some(NativeFrame {
width: frame.width,
height: frame.height,
y: plane_to_8bit(&frame.y, max),
u: plane_to_8bit(&frame.u, max),
v: plane_to_8bit(&frame.v, max),
})
}
fn plane_to_8bit(plane: &rust_h265::PixelData, max: u32) -> Vec<u8> {
match plane {
rust_h265::PixelData::U8(v) => v.clone(),
rust_h265::PixelData::U16(v) => v
.iter()
.map(|&s| ((u32::from(s).min(max) * 255 + max / 2) / max) as u8)
.collect(),
}
}
fn luma_spread(y: &[u8]) -> u8 {
let mut min = u8::MAX;
let mut max = u8::MIN;
for &v in y {
min = min.min(v);
max = max.max(v);
}
max.saturating_sub(min)
}
fn frame_to_image(frame: &NativeFrame) -> Option<DynamicImage> {
let (w, h) = (frame.width as usize, frame.height as usize);
let (cw, ch) = (w / 2, h / 2);
if w < 2
|| h < 2
|| w > NATIVE_MAX_DIMENSION as usize
|| h > NATIVE_MAX_DIMENSION as usize
|| frame.y.len() < w * h
|| frame.u.len() < cw * ch
|| frame.v.len() < cw * ch
{
return None;
}
let bt709 = h >= 720;
let (kr_v, kg_u, kg_v, kb_u) = if bt709 {
(1.792_7_f32, -0.213_2, -0.532_9, 2.112_4)
} else {
(1.596_0_f32, -0.391_8, -0.813_0, 2.017_2)
};
let mut buf = Vec::with_capacity(w * h * 3);
for y in 0..h {
let cy = (y / 2).min(ch.saturating_sub(1));
for x in 0..w {
let cx = (x / 2).min(cw.saturating_sub(1));
let yy = (f32::from(frame.y[y * w + x]) - 16.0) * 1.164_4;
let u = f32::from(frame.u[cy * cw + cx]) - 128.0;
let v = f32::from(frame.v[cy * cw + cx]) - 128.0;
buf.push(clamp_u8(yy + kr_v * v));
buf.push(clamp_u8(yy + kg_u * u + kg_v * v));
buf.push(clamp_u8(yy + kb_u * u));
}
}
let rgb = image::RgbImage::from_raw(w as u32, h as u32, buf)?;
Some(DynamicImage::ImageRgb8(rgb))
}
fn clamp_u8(v: f32) -> u8 {
v.clamp(0.0, 255.0) as u8
}
fn shrink_to_thumb(img: DynamicImage) -> DynamicImage {
let long = img.width().max(img.height());
if long <= THUMB_MAX_PX {
return img;
}
img.resize(
THUMB_MAX_PX,
THUMB_MAX_PX,
image::imageops::FilterType::Lanczos3,
)
}
fn thumbnail_external(path: &Path) -> Option<DynamicImage> {
let out = temp_png_path();
let ok = run_ffmpegthumbnailer(path, &out) || run_ffmpeg(path, &out);
let img = if ok {
image::ImageReader::open(&out)
.ok()
.and_then(|r| r.with_guessed_format().ok())
.and_then(|r| r.decode().ok())
} else {
None
};
let _ = std::fs::remove_file(&out); img
}
fn run_ffmpegthumbnailer(path: &Path, out: &Path) -> bool {
let mut cmd = Command::new("ffmpegthumbnailer");
cmd.arg("-i")
.arg(path)
.arg("-o")
.arg(out)
.arg("-s")
.arg(THUMB_MAX_PX.to_string())
.arg("-q")
.arg("8")
.arg("-c")
.arg("png")
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
matches!(spawn_and_wait_with_timeout(&mut cmd, TOOL_TIMEOUT), Some(s) if s.success())
&& out_is_nonempty(out)
}
fn run_ffmpeg(path: &Path, out: &Path) -> bool {
let vf = format!("thumbnail,scale='min({THUMB_MAX_PX},iw)':-2");
let mut cmd = Command::new("ffmpeg");
cmd.arg("-y")
.arg("-nostdin")
.arg("-loglevel")
.arg("error")
.arg("-i")
.arg(path)
.arg("-frames:v")
.arg("1")
.arg("-vf")
.arg(vf)
.arg(out)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
matches!(spawn_and_wait_with_timeout(&mut cmd, TOOL_TIMEOUT), Some(s) if s.success())
&& out_is_nonempty(out)
}
fn spawn_and_wait_with_timeout(
cmd: &mut Command,
timeout: Duration,
) -> Option<std::process::ExitStatus> {
let mut child: Child = cmd.spawn().ok()?;
let deadline = Instant::now() + timeout;
loop {
match child.try_wait() {
Ok(Some(status)) => return Some(status),
Ok(None) => {
if Instant::now() >= deadline {
let _ = child.kill();
let _ = child.wait(); return None;
}
std::thread::sleep(POLL_INTERVAL);
}
Err(_) => return None,
}
}
}
fn out_is_nonempty(out: &Path) -> bool {
std::fs::metadata(out).map(|m| m.len() > 0).unwrap_or(false)
}
fn private_temp_dir() -> PathBuf {
static DIR: OnceLock<PathBuf> = OnceLock::new();
DIR.get_or_init(|| {
let dir = std::env::temp_dir().join(format!("konoma-vthumb-{}", std::process::id()));
#[cfg(unix)]
{
use std::os::unix::fs::{DirBuilderExt, PermissionsExt};
let _ = std::fs::DirBuilder::new().mode(0o700).create(&dir);
let _ = std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700));
}
#[cfg(not(unix))]
{
let _ = std::fs::create_dir(&dir);
}
dir
})
.clone()
}
fn temp_png_path() -> PathBuf {
static N: AtomicU64 = AtomicU64::new(0);
let n = N.fetch_add(1, Ordering::Relaxed);
private_temp_dir().join(format!("thumb-{n}.png"))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_support::unique_tmp;
fn sample_path_or_skip(name: &str) -> Option<PathBuf> {
let p = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("samples")
.join(name);
if p.exists() {
Some(p)
} else {
eprintln!(
"SKIP: samples/{name} not found (excluded from the published crate) — this test verifies nothing this run"
);
None
}
}
struct BitWriter {
bytes: Vec<u8>,
bits: u32,
}
impl BitWriter {
fn new() -> Self {
Self {
bytes: Vec::new(),
bits: 0,
}
}
fn bit(&mut self, b: u32) {
if self.bits.is_multiple_of(8) {
self.bytes.push(0);
}
if b == 1 {
let last = self.bytes.len() - 1;
self.bytes[last] |= 1 << (7 - (self.bits % 8));
}
self.bits += 1;
}
fn bits(&mut self, n: u32, v: u32) {
for i in (0..n).rev() {
self.bit((v >> i) & 1);
}
}
fn ue(&mut self, v: u32) {
let n = v + 1;
let len = 32 - n.leading_zeros();
for _ in 0..len - 1 {
self.bit(0);
}
for i in (0..len).rev() {
self.bit((n >> i) & 1);
}
}
}
fn synthetic_sps(
profile_idc: u8,
chroma_format_idc: u32,
bit_depth_luma: u32,
bit_depth_chroma: u32,
width: u32,
height: u32,
) -> Vec<u8> {
let mut w = BitWriter::new();
w.ue(0); if PROFILES_WITH_CHROMA_BLOCK.contains(&profile_idc) {
w.ue(chroma_format_idc);
if chroma_format_idc == 3 {
w.bit(0); }
w.ue(bit_depth_luma - 8);
w.ue(bit_depth_chroma - 8);
w.bit(0); w.bit(0); }
w.ue(0); w.ue(2); w.ue(1); w.bit(0); w.ue(width / 16 - 1);
w.ue(height / 16 - 1);
w.bit(1);
let mut rbsp = vec![profile_idc, 0x00, 30];
rbsp.extend_from_slice(&w.bytes);
rbsp
}
#[test]
fn sps_guard_admits_only_8bit_420_baseline_main_high() {
let decodable = |sps: &[u8]| parse_sps_facts(sps).is_some_and(|f| sps_is_decodable(&f));
for ok in [66u8, 77, 100] {
assert!(
decodable(&synthetic_sps(ok, 1, 8, 8, 320, 240)),
"profile_idc {ok} の 8bit 4:2:0 は rust_h264 が正しくデコードできる(受理すべき)"
);
}
assert!(
!decodable(&synthetic_sps(100, 0, 8, 8, 320, 240)),
"モノクロ(4:0:0)は profile 100 のまま=profile では区別できない。rust_h264 は chroma_format_idc を読まないので 4:2:0 として誤パースし絵が壊れる"
);
assert!(
!decodable(&synthetic_sps(100, 2, 8, 8, 320, 240)),
"4:2:2 は拒否"
);
assert!(
!decodable(&synthetic_sps(100, 3, 8, 8, 320, 240)),
"4:4:4 は拒否"
);
assert!(
!decodable(&synthetic_sps(100, 1, 10, 10, 320, 240)),
"10bit は拒否"
);
assert!(
!decodable(&synthetic_sps(100, 1, 8, 10, 320, 240)),
"輝度だけ 8bit でも色差が 10bit なら拒否"
);
for bad in [110u8, 122, 244] {
assert!(
!decodable(&synthetic_sps(bad, 1, 8, 8, 320, 240)),
"profile_idc {bad} は壊れた絵を出すので拒否"
);
}
for unknown in [0u8, 1, 88, 128, 255] {
assert!(
!decodable(&synthetic_sps(unknown, 1, 8, 8, 320, 240)),
"未知の profile_idc {unknown} も拒否(未知は推測の許可証ではない)"
);
}
assert!(
decodable(&synthetic_sps(100, 1, 8, 8, 8192, 8192)),
"上限ちょうどは受理"
);
assert!(
!decodable(&synthetic_sps(100, 1, 8, 8, 8208, 8192)),
"上限超の幅は拒否"
);
assert!(
!decodable(&synthetic_sps(100, 1, 8, 8, 8192, 8208)),
"上限超の高さは拒否"
);
assert!(parse_sps_facts(&[]).is_none(), "空の SPS");
assert!(parse_sps_facts(&[100]).is_none(), "profile だけの SPS");
assert!(
parse_sps_facts(&[100, 0, 30]).is_none(),
"ヘッダ 3 バイトだけの SPS"
);
let full = synthetic_sps(100, 1, 8, 8, 320, 240);
assert!(
parse_sps_facts(&full[..full.len() - 1]).is_none()
|| parse_sps_facts(&full[..4]).is_none(),
"途中で切れた SPS はパースを終える(バッファ外を読まない・無限ループしない)"
);
assert!(parse_sps_facts(&[100, 0, 30, 0, 0, 0, 0, 0, 0, 0]).is_none());
}
#[test]
fn sps_parse_matches_the_bundled_sample() {
let Some(p) = sample_path_or_skip("sample.mp4") else {
return;
};
let mut file = File::open(&p).unwrap();
let size = file.metadata().unwrap().len();
let mp4 = re_mp4::Mp4::read(&mut file, size).unwrap();
let track = mp4
.tracks()
.values()
.find(|t| t.kind == Some(re_mp4::TrackKind::Video))
.expect("video track");
let cfg = track.raw_codec_config(&mp4).unwrap();
let avcc = rust_h264::nal::parse_avcc_config(&cfg).unwrap();
let facts = parse_sps_facts(&avcc.sps_nals[0].rbsp).expect("SPS が読める");
assert_eq!(
facts,
SpsFacts {
profile_idc: 100,
chroma_format_idc: 1,
bit_depth_luma: 8,
bit_depth_chroma: 8,
width: 320,
height: 240,
},
"実ファイルの SPS を取り違えている"
);
}
#[test]
fn guard_reads_the_bitstream_not_the_container() {
let Some(src) = sample_path_or_skip("sample.mp4") else {
return;
};
let original = std::fs::read(&src).unwrap();
let avcc_at = original
.windows(4)
.position(|w| w == b"avcC")
.expect("samples/sample.mp4 に avcC 記録がある")
+ 4;
let container_profile = avcc_at + 1;
let sps_profile = avcc_at + 9;
assert_eq!(
original[avcc_at], 1,
"avcC の先頭は configurationVersion=1(検算)"
);
assert_eq!(original[container_profile], 100, "コンテナ申告は High(100)");
assert_eq!(
original[avcc_at + 8] & 0x1f,
7,
"SPS NAL(type 7)を指している(検算)"
);
assert_eq!(original[sps_profile], 100, "ビットストリームも High(100)");
let dir = unique_tmp("konoma_video_bitstream_guard_test");
std::fs::create_dir_all(&dir).unwrap();
let control = dir.join("control.mp4");
std::fs::write(&control, &original).unwrap();
assert!(
thumbnail_native(&control).is_some(),
"対照: 無改変のコピーはデコードできる"
);
for bad in [110u8, 122, 244] {
let mut bytes = original.clone();
bytes[container_profile] = bad;
let p = dir.join(format!("container{bad}.mp4"));
std::fs::write(&p, &bytes).unwrap();
assert!(
thumbnail_native(&p).is_some(),
"コンテナの申告({bad})は判断材料でない=ビットストリームが 8bit 4:2:0 High なら描ける"
);
let mut bytes = original.clone();
bytes[sps_profile] = bad;
let p = dir.join(format!("sps{bad}.mp4"));
std::fs::write(&p, &bytes).unwrap();
assert!(
thumbnail_native(&p).is_none(),
"SPS が profile {bad} と言うならデコード前に拒否する(ビットストリームが実際にはデコードできても)"
);
}
std::fs::remove_dir_all(&dir).ok();
}
#[derive(Debug, Clone, Copy)]
struct HevcSpsSpec {
profile_idc: u8,
chroma_format_idc: u32,
bit_depth_luma: u32,
bit_depth_chroma: u32,
width: u32,
height: u32,
sub_layers_minus1: u32,
}
impl Default for HevcSpsSpec {
fn default() -> Self {
Self {
profile_idc: 1,
chroma_format_idc: 1,
bit_depth_luma: 8,
bit_depth_chroma: 8,
width: 320,
height: 240,
sub_layers_minus1: 0,
}
}
}
fn synthetic_hevc_sps(spec: HevcSpsSpec) -> Vec<u8> {
let mut w = BitWriter::new();
w.bits(4, 0); w.bits(3, spec.sub_layers_minus1);
w.bit(1);
w.bits(2, 0); w.bit(0); w.bits(5, u32::from(spec.profile_idc));
w.bits(32, 1u32 << (31 - spec.profile_idc.min(31)));
w.bits(24, 0); w.bits(24, 0);
w.bits(8, 90); if spec.sub_layers_minus1 > 0 {
for _ in 0..spec.sub_layers_minus1 {
w.bit(1); w.bit(1); }
for _ in spec.sub_layers_minus1..8 {
w.bits(2, 0); }
for _ in 0..spec.sub_layers_minus1 {
w.bits(2, 0); w.bit(0); w.bits(5, 1); w.bits(32, 0); w.bits(24, 0); w.bits(24, 0);
w.bits(8, 90); }
}
w.ue(0); w.ue(spec.chroma_format_idc);
if spec.chroma_format_idc == 3 {
w.bit(0); }
w.ue(spec.width);
w.ue(spec.height);
w.bit(0); w.ue(spec.bit_depth_luma - 8);
w.ue(spec.bit_depth_chroma - 8);
w.bytes
}
#[test]
fn hevc_sps_guard_admits_only_main_and_main10_420() {
let facts = |spec: HevcSpsSpec| parse_hevc_sps_facts(&synthetic_hevc_sps(spec));
let decodable = |spec: HevcSpsSpec| facts(spec).is_some_and(|f| hevc_sps_is_decodable(&f));
assert!(decodable(HevcSpsSpec::default()), "Main 8bit 4:2:0 は受理");
assert!(
decodable(HevcSpsSpec {
profile_idc: 2,
bit_depth_luma: 10,
bit_depth_chroma: 10,
..Default::default()
}),
"Main 10 の 10bit 4:2:0 は受理"
);
assert_eq!(
facts(HevcSpsSpec {
profile_idc: 2,
bit_depth_luma: 10,
bit_depth_chroma: 10,
width: 1920,
height: 1080,
..Default::default()
}),
Some(HevcSpsFacts {
general_profile_idc: 2,
chroma_format_idc: 1,
bit_depth_luma: 10,
bit_depth_chroma: 10,
width: 1920,
height: 1080,
})
);
for chroma in [0u32, 2, 3] {
assert!(
!decodable(HevcSpsSpec {
chroma_format_idc: chroma,
..Default::default()
}),
"chroma_format_idc {chroma} は 4:2:0 でないので拒否"
);
}
for depth in [9u32, 11, 12, 14, 16] {
assert!(
!decodable(HevcSpsSpec {
bit_depth_luma: depth,
bit_depth_chroma: depth,
..Default::default()
}),
"{depth}bit は rust_h265 が検証していないので拒否(12bit はエラーも出さずに絵を返す)"
);
}
assert!(
!decodable(HevcSpsSpec {
profile_idc: 2,
bit_depth_luma: 8,
bit_depth_chroma: 10,
..Default::default()
}),
"輝度と色差で bit depth が食い違うものは拒否"
);
for bad in [0u8, 3, 4, 5, 6, 7, 9, 31] {
assert!(
!decodable(HevcSpsSpec {
profile_idc: bad,
..Default::default()
}),
"general_profile_idc {bad} は Main/Main 10 でないので拒否(未知は推測の許可証ではない)"
);
}
assert!(
decodable(HevcSpsSpec {
width: 8192,
height: 8192,
..Default::default()
}),
"上限ちょうどは受理"
);
for (w, h) in [(8193, 8192), (8192, 8193), (0, 240), (320, 0)] {
assert!(
!decodable(HevcSpsSpec {
width: w,
height: h,
..Default::default()
}),
"{w}x{h} は拒否"
);
}
for n in 0..=MAX_HEVC_SUB_LAYERS_MINUS1 {
assert_eq!(
facts(HevcSpsSpec {
sub_layers_minus1: n,
width: 1280,
height: 720,
..Default::default()
}),
Some(HevcSpsFacts {
general_profile_idc: 1,
chroma_format_idc: 1,
bit_depth_luma: 8,
bit_depth_chroma: 8,
width: 1280,
height: 720,
}),
"sub_layers_minus1={n} で profile_tier_level の読み飛ばしがずれている"
);
}
let malformed = HevcSpsSpec {
sub_layers_minus1: 7,
..Default::default()
};
assert!(
parse_hevc_sps_facts(&synthetic_hevc_sps(malformed)).is_none(),
"sps_max_sub_layers_minus1=7 は規格外=拒否"
);
assert!(parse_hevc_sps_facts(&[]).is_none(), "空の SPS");
let full = synthetic_hevc_sps(HevcSpsSpec::default());
for cut in [1usize, 4, 8, 12] {
assert!(
parse_hevc_sps_facts(&full[..cut.min(full.len())]).is_none(),
"{cut} バイトで切れた SPS はパースを終える(バッファ外を読まない)"
);
}
assert!(!parse_hevc_sps_facts(&[0u8; 24]).is_some_and(|f| hevc_sps_is_decodable(&f)));
}
struct HvccLayout {
record: usize,
sps_nal: usize,
sps_len: usize,
}
fn hvcc_layout(bytes: &[u8]) -> HvccLayout {
let record = bytes
.windows(4)
.position(|w| w == b"hvcC")
.expect("hvcC 記録がある")
+ 4;
let num_arrays = bytes[record + 22];
let mut i = record + 23;
for _ in 0..num_arrays {
let nal_type = bytes[i] & 0x3f;
let count = u16::from_be_bytes([bytes[i + 1], bytes[i + 2]]);
i += 3;
for _ in 0..count {
let len = usize::from(u16::from_be_bytes([bytes[i], bytes[i + 1]]));
i += 2;
if nal_type == HEVC_NAL_TYPE_SPS {
return HvccLayout {
record,
sps_nal: i,
sps_len: len,
};
}
i += len;
}
}
panic!("hvcC に SPS が無い");
}
#[test]
fn hevc_sps_parse_matches_the_bundled_sample() {
let Some(p) = sample_path_or_skip("sample-hevc.mp4") else {
return;
};
let mut file = File::open(&p).unwrap();
let size = file.metadata().unwrap().len();
let mp4 = re_mp4::Mp4::read(&mut file, size).unwrap();
let track = mp4
.tracks()
.values()
.find(|t| t.kind == Some(re_mp4::TrackKind::Video))
.expect("video track");
assert_eq!(
native_codec_kind(track.codec_string(&mp4).as_deref()),
Some(NativeCodecKind::Hevc),
"サンプルが HEVC として認識されていない"
);
let cfg = track.raw_codec_config(&mp4).unwrap();
let hvcc = parse_hvcc(&cfg).expect("hvcC が読める");
assert_eq!(hvcc.length_size, 4, "length prefix の幅");
assert!(
hvcc.parameter_sets.starts_with(&[0, 0, 0, 1]),
"パラメータセットが Annex B(スタートコード)になっていない=rust_h265 が受け付けない形"
);
let bytes = std::fs::read(&p).unwrap();
let layout = hvcc_layout(&bytes);
let raw_sps = &bytes[layout.sps_nal..layout.sps_nal + layout.sps_len];
let escapes = raw_sps
.windows(3)
.filter(|w| w == &[0x00, 0x00, 0x03])
.count();
assert!(
escapes > 0,
"このサンプルの SPS に emulation prevention が無い=剥がす処理の検証が空振りしている"
);
assert_eq!(
hvcc.sps_rbsp.len(),
raw_sps.len() - 2 - escapes,
"SPS の RBSP 化がずれている(2 バイトの NAL ヘッダと {escapes} 個の 0x03 だけが落ちるはず)"
);
let facts = parse_hevc_sps_facts(&hvcc.sps_rbsp).expect("SPS が読める");
assert_eq!(
facts,
HevcSpsFacts {
general_profile_idc: 1,
chroma_format_idc: 1,
bit_depth_luma: 8,
bit_depth_chroma: 8,
width: 320,
height: 240,
},
"実ファイルの SPS を取り違えている"
);
}
#[test]
fn hevc_guard_reads_the_bitstream_not_the_container() {
let Some(src) = sample_path_or_skip("sample-hevc.mp4") else {
return;
};
let original = std::fs::read(&src).unwrap();
let layout = hvcc_layout(&original);
assert_eq!(
original[layout.record], 1,
"hvcC の先頭は configurationVersion=1(検算)"
);
assert_eq!(
original[layout.record + 16] & 0x03,
1,
"コンテナ申告は 4:2:0(検算)"
);
let sps_profile = layout.sps_nal + 3;
assert_eq!(
original[sps_profile] & 0x1f,
1,
"ビットストリームも Main(profile_idc=1)"
);
let dir = unique_tmp("konoma_video_hevc_bitstream_guard_test");
std::fs::create_dir_all(&dir).unwrap();
let control = dir.join("control.mp4");
std::fs::write(&control, &original).unwrap();
assert!(
thumbnail_native(&control).is_some(),
"対照: 無改変のコピーはデコードできる"
);
let mut bytes = original.clone();
bytes[layout.record + 16] = (bytes[layout.record + 16] & !0x03) | 3; bytes[layout.record + 17] = (bytes[layout.record + 17] & !0x07) | 4; bytes[layout.record + 18] = (bytes[layout.record + 18] & !0x07) | 4; let p = dir.join("container_lies.mp4");
std::fs::write(&p, &bytes).unwrap();
assert!(
thumbnail_native(&p).is_some(),
"hvcC の chromaFormat/bitDepth 申告は判断材料でない=ビットストリームが Main 8bit 4:2:0 なら描ける"
);
for bad in [4u8, 5, 9] {
let mut bytes = original.clone();
bytes[sps_profile] = (bytes[sps_profile] & !0x1f) | bad;
let p = dir.join(format!("sps{bad}.mp4"));
std::fs::write(&p, &bytes).unwrap();
assert!(
thumbnail_native(&p).is_none(),
"SPS が profile {bad} と言うならデコード前に拒否する(ビットストリームが実際にはデコードできても)"
);
}
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn native_path_extracts_a_real_frame_from_hevc_without_any_external_tool() {
let Some(p) = sample_path_or_skip("sample-hevc.mp4") else {
return;
};
let started = Instant::now();
let img = thumbnail_native(&p).expect("HEVC mp4 は純 Rust 経路でサムネイルになるはず");
let elapsed = started.elapsed();
eprintln!("native thumbnail of samples/sample-hevc.mp4: {elapsed:?}");
assert_eq!(
(img.width(), img.height()),
(320, 240),
"元動画そのままの寸法(既定値やスケール後の寸法ではない)"
);
let luma: Vec<u8> = img.to_luma8().into_raw();
let spread = luma_spread(&luma);
assert!(
spread > NATIVE_FLAT_LUMA_SPREAD,
"一様な絵しか出ていない(デコード結果が絵になっていない): 輝度幅={spread}"
);
assert!(
thumbnail(&p, false).is_some(),
"[external] video = false でも純 Rust 経路が先に走る"
);
}
#[test]
fn hevc_native_path_degrades_safely_on_broken_input() {
let Some(src) = sample_path_or_skip("sample-hevc.mp4") else {
return;
};
let original = std::fs::read(&src).unwrap();
let layout = hvcc_layout(&original);
let dir = unique_tmp("konoma_video_hevc_broken_test");
std::fs::create_dir_all(&dir).unwrap();
let mut bytes = original.clone();
bytes[layout.sps_nal - 2] = 0xff;
bytes[layout.sps_nal - 1] = 0xff;
let p = dir.join("hvcc_overlong.mp4");
std::fs::write(&p, &bytes).unwrap();
assert!(
thumbnail_native(&p).is_none(),
"hvcC の NAL 長が記録をはみ出していたら None(バッファ外を読まない)"
);
let mut bytes = original.clone();
bytes[layout.record + 22] = 0xff;
let p = dir.join("hvcc_many_arrays.mp4");
std::fs::write(&p, &bytes).unwrap();
assert!(
thumbnail_native(&p).is_none(),
"numOfArrays が過大でも None(panic しない)"
);
let mut bytes = original.clone();
let mdat = bytes
.windows(4)
.position(|w| w == b"mdat")
.expect("samples/sample-hevc.mp4 に mdat がある")
+ 4;
for b in bytes.iter_mut().skip(mdat) {
*b = b.wrapping_mul(31).wrapping_add(7);
}
let p = dir.join("shredded.mp4");
std::fs::write(&p, &bytes).unwrap();
assert!(
thumbnail_native(&p).is_none(),
"サンプルデータが壊れた HEVC mp4 は None(panic しない)"
);
let p = dir.join("truncated.mp4");
std::fs::write(&p, &original[..original.len() / 3]).unwrap();
assert!(
thumbnail_native(&p).is_none(),
"途中で切れた HEVC mp4 は None(panic しない)"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn emulation_prevention_is_undone_before_reading_a_parameter_set() {
assert_eq!(
strip_emulation_prevention(&[0x00, 0x00, 0x03, 0x01]),
vec![0x00, 0x00, 0x01]
);
assert_eq!(
strip_emulation_prevention(&[0x00, 0x03, 0x00, 0x03]),
vec![0x00, 0x03, 0x00, 0x03]
);
assert_eq!(strip_emulation_prevention(&[0x03; 4]), vec![0x03; 4]);
assert_eq!(
strip_emulation_prevention(&[0x00, 0x00, 0x03, 0x00, 0x00, 0x03, 0x02]),
vec![0x00, 0x00, 0x00, 0x00, 0x02]
);
assert_eq!(
strip_emulation_prevention(&[0x00, 0x00, 0x00, 0x03, 0x04]),
vec![0x00, 0x00, 0x00, 0x04]
);
assert!(strip_emulation_prevention(&[]).is_empty());
}
#[test]
fn bit_reader_reads_fixed_width_fields_all_or_nothing() {
let mut r = BitReader::new(&[0b1010_1100, 0b0011_0101]);
assert_eq!(r.bits(4), Some(0b1010));
assert_eq!(r.bits(0), Some(0), "0 ビットは常に 0 を返し何も消費しない");
assert_eq!(r.bits(8), Some(0b1100_0011), "バイト境界をまたぐ");
assert_eq!(r.bits(4), Some(0b0101));
assert_eq!(r.bits(1), None, "尽きたら None");
let mut r = BitReader::new(&[0xff]);
assert_eq!(r.bits(9), None);
assert_eq!(r.bits(8), Some(0xff), "失敗した読みは位置を進めない");
let mut r = BitReader::new(&[0xff; 8]);
assert_eq!(r.bits(33), None);
assert_eq!(r.bits(32), Some(u32::MAX));
}
#[test]
fn ten_bit_samples_are_rescaled_not_truncated() {
const MAX10: u32 = 1023;
let all: Vec<u16> = (0..=1023).collect();
let out = plane_to_8bit(&rust_h265::PixelData::U16(all.clone()), MAX10);
let expected: Vec<u8> = all
.iter()
.map(|&v| ((u32::from(v) * 255 + MAX10 / 2) / MAX10) as u8)
.collect();
assert_eq!(out, expected, "10bit→8bit の対応が線形写像になっていない");
assert_eq!(out[0], 0, "黒は黒のまま");
assert_eq!(out[1023], 255, "白は白のまま");
assert_eq!(out[512], 128, "中間は中間");
let shifted: Vec<u8> = all.iter().map(|&v| (v >> 2) as u8).collect();
let differing = out.iter().zip(&shifted).filter(|(a, b)| a != b).count();
let worst = out
.iter()
.zip(&shifted)
.map(|(a, b)| i32::from(*a) - i32::from(*b))
.map(i32::abs)
.max()
.unwrap();
assert_eq!(differing, 170, "切り捨て(>> 2)と同じ結果になっている");
assert_eq!(worst, 1, "差は常に 1 段(実測)");
assert_eq!(
plane_to_8bit(&rust_h265::PixelData::U8(vec![0, 17, 128, 255]), 255),
vec![0, 17, 128, 255]
);
assert_eq!(
plane_to_8bit(&rust_h265::PixelData::U16(vec![4000]), MAX10),
vec![255]
);
}
#[test]
fn parse_hvcc_refuses_records_it_cannot_read() {
assert!(parse_hvcc(&[]).is_none(), "空の記録");
assert!(
parse_hvcc(&[0u8; 22]).is_none(),
"numOfArrays に届かない長さ"
);
let mut cfg = vec![0u8; 23];
cfg[0] = 1;
assert!(parse_hvcc(&cfg).is_none(), "パラメータセットが無い記録");
let mut cfg = vec![0u8; 23];
cfg[0] = 1;
cfg[22] = 1; cfg.extend_from_slice(&[HEVC_NAL_TYPE_SPS, 0x00, 0x01]); cfg.extend_from_slice(&[0xff, 0xff]); assert!(parse_hvcc(&cfg).is_none(), "記録をはみ出す NAL 長");
}
#[test]
fn codec_string_guard_admits_only_avc() {
assert!(codec_string_is_avc(Some("avc1.640028")));
assert!(codec_string_is_avc(Some("avc1.42E01E")));
assert!(codec_string_is_avc(Some("avc3.640028")));
assert!(!codec_string_is_avc(Some("hvc1.1.6.L93.B0")));
assert!(!codec_string_is_avc(Some("hev1.1.6.L93.B0")));
assert!(!codec_string_is_avc(Some("vp09.00.10.08")));
assert!(!codec_string_is_avc(Some("vp8")));
assert!(!codec_string_is_avc(Some("av01.0.04M.08")));
assert!(!codec_string_is_avc(Some("mp4a.40.2")));
assert!(!codec_string_is_avc(None), "コーデック不明は拒否");
}
#[test]
fn codec_string_dispatch_picks_the_right_decoder() {
use NativeCodecKind::{Avc, Hevc};
assert_eq!(native_codec_kind(Some("avc1.640028")), Some(Avc));
assert_eq!(native_codec_kind(Some("avc3.640028")), Some(Avc));
assert_eq!(native_codec_kind(Some("hvc1.1.6.L93.B0")), Some(Hevc));
assert_eq!(native_codec_kind(Some("hev1.1.6.L93.B0")), Some(Hevc));
assert_eq!(
native_codec_kind(Some("hvc1.2.4.L90.90")),
Some(Hevc),
"Main 10"
);
for other in [
"vp09.00.10.08",
"vp8",
"av01.0.04M.08",
"mp4a.40.2",
"mp4v.20.9",
"apch",
] {
assert_eq!(
native_codec_kind(Some(other)),
None,
"{other} は内蔵デコーダの担当でない=外部ツールへ降格する"
);
}
assert_eq!(native_codec_kind(None), None, "コーデック不明は拒否");
}
#[test]
fn native_path_only_opens_containers_it_indexes() {
use NativeContainer::{Matroska, Mp4};
assert_eq!(native_container_kind(Path::new("/x/clip.mp4")), Some(Mp4));
assert_eq!(
native_container_kind(Path::new("/x/clip.MP4")),
Some(Mp4),
"大小無視"
);
assert_eq!(native_container_kind(Path::new("/x/clip.m4v")), Some(Mp4));
assert_eq!(native_container_kind(Path::new("/x/clip.mov")), Some(Mp4));
assert_eq!(
native_container_kind(Path::new("/x/clip.mkv")),
Some(Matroska)
);
assert_eq!(
native_container_kind(Path::new("/x/clip.webm")),
Some(Matroska),
"WebM は Matroska の部分集合=同じ demuxer が読む"
);
assert_eq!(
native_container_kind(Path::new("/x/clip.WEBM")),
Some(Matroska),
"大小無視"
);
assert_eq!(
native_container_kind(Path::new("/x/clip.avi")),
None,
"avi はどちらの demuxer の担当でもない=外部ツールへ降格"
);
assert_eq!(native_container_kind(Path::new("/x/clip")), None);
let Some(src) = sample_path_or_skip("sample.mp4") else {
return;
};
let bytes = std::fs::read(&src).unwrap();
let dir = unique_tmp("konoma_video_ext_gate_test");
std::fs::create_dir_all(&dir).unwrap();
for name in ["clip.mkv", "clip.webm", "clip", "clip.avi"] {
let disguised = dir.join(name);
std::fs::write(&disguised, &bytes).unwrap();
assert!(
thumbnail_native(&disguised).is_none(),
"{name}: 拡張子と中身が食い違うファイルは安全に None(別コンテナの reader が絵を作ってしまわない)"
);
}
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn native_path_extracts_a_real_frame_from_mkv_without_any_external_tool() {
let Some(p) = sample_path_or_skip("sample.mkv") else {
return;
};
let started = Instant::now();
let img = thumbnail_native(&p).expect("H.264 mkv は純 Rust 経路でサムネイルになるはず");
eprintln!(
"native thumbnail of samples/sample.mkv: {:?}",
started.elapsed()
);
assert_eq!(
(img.width(), img.height()),
(320, 240),
"元動画そのままの寸法(既定値やスケール後の寸法ではない)"
);
let luma: Vec<u8> = img.to_luma8().into_raw();
let spread = luma_spread(&luma);
assert!(
spread > NATIVE_FLAT_LUMA_SPREAD,
"一様な絵しか出ていない(デコード結果が絵になっていない): 輝度幅={spread}"
);
assert!(
thumbnail(&p, false).is_some(),
"[external] video = false でも純 Rust 経路が先に走る"
);
}
#[test]
fn mkv_seek_target_comes_from_the_media_duration() {
let Some(p) = sample_path_or_skip("sample.mkv") else {
return;
};
let source = BudgetedSource::open(&p, NATIVE_MKV_MAX_SCAN_BYTES).unwrap();
let mss = MediaSourceStream::new(Box::new(source), MediaSourceStreamOptions::default());
let reader = MkvReader::try_new(mss, FormatOptions::default()).unwrap();
assert!(
reader.tracks().iter().all(|t| t.duration.is_none()),
"この前提が変わったら(トラックにも duration が入るようになったら)この検査の意味も変わる"
);
let track = find_mkv_track(&reader).expect("H.264 トラックが見つかる");
let target = track
.ten_percent
.expect("セグメントの duration から 10% 位置が求まる");
let ns = target.as_nanos();
assert!(
(290_000_000..=310_000_000).contains(&ns),
"10% 位置が 0.3 秒付近でない: {ns}ns"
);
}
#[test]
fn keyframe_detection_reads_the_nal_type_of_each_codec() {
use NativeCodecKind::{Avc, Hevc};
fn sample(nals: &[&[u8]]) -> Vec<u8> {
let mut out = Vec::new();
for nal in nals {
out.extend_from_slice(&((nal.len() as u32) + 1).to_be_bytes());
out.extend_from_slice(nal);
out.push(0xab); }
out
}
assert!(sample_has_keyframe(Avc, &sample(&[&[0x65]]), 4), "IDR(5)");
assert!(
sample_has_keyframe(Avc, &sample(&[&[0x67], &[0x68], &[0x65]]), 4),
"SPS/PPS の後ろに IDR が続くのが普通の形"
);
assert!(
!sample_has_keyframe(Avc, &sample(&[&[0x41]]), 4),
"非 IDR スライス(1)はキーフレームでない=単体でデコードできない"
);
assert!(!sample_has_keyframe(Avc, &sample(&[&[0x67], &[0x68]]), 4));
assert!(!sample_has_keyframe(Avc, &sample(&[&[0x33]]), 4));
for nal_type in HEVC_IRAP_NAL_TYPES {
let header = [nal_type << 1, 0x01];
assert!(
sample_has_keyframe(Hevc, &sample(&[&header]), 4),
"IRAP({nal_type}) はキーフレーム"
);
}
for nal_type in [0u8, 1, 15, 22, 23, 32, 33, 34] {
let header = [nal_type << 1, 0x01];
assert!(
!sample_has_keyframe(Hevc, &sample(&[&header]), 4),
"NAL type {nal_type} は IRAP でない"
);
}
let params_then_idr = sample(&[&[64, 1], &[66, 1], &[68, 1], &[38, 1]]);
assert!(sample_has_keyframe(Hevc, ¶ms_then_idr, 4));
assert!(!sample_has_keyframe(Avc, ¶ms_then_idr, 4));
assert!(!sample_has_keyframe(Avc, &[], 4));
assert!(
!sample_has_keyframe(Avc, &[0, 0], 4),
"長さ前置が途中で切れている"
);
assert!(
!sample_has_keyframe(Avc, &[0x00, 0x00, 0x00, 0xff, 0x65], 4),
"宣言された長さがバッファをはみ出している"
);
assert!(
!sample_has_keyframe(Avc, &[0x00, 0x00, 0x00, 0x00], 4),
"長さ 0 の NAL(中身が無い)"
);
assert!(!sample_has_keyframe(Avc, &sample(&[&[0x65]]), 0));
}
#[test]
fn mkv_scan_gives_up_at_its_read_budget() {
let dir = unique_tmp("konoma_video_mkv_budget_test");
std::fs::create_dir_all(&dir).unwrap();
let file = dir.join("data.bin");
std::fs::write(&file, vec![7u8; 4096]).unwrap();
let mut src = BudgetedSource::open(&file, 100).expect("open");
assert_eq!(src.byte_len(), Some(4096), "元ファイルの長さは正直に返す");
assert!(src.is_seekable());
let mut buf = [0u8; 64];
assert_eq!(src.read(&mut buf).unwrap(), 64, "予算内は普通に読める");
assert_eq!(
src.read(&mut buf).unwrap(),
36,
"予算の残りぶんだけ読んで止まる"
);
assert!(
src.read(&mut buf).is_err(),
"予算を使い切ったら以降は失敗する(黙って 0 バイト=EOF を返すと、呼び出し側が正常終了と取り違える)"
);
assert!(BudgetedSource::open(&file, 0)
.expect("open")
.read(&mut buf)
.is_err());
std::fs::remove_dir_all(&dir).ok();
let Some(p) = sample_path_or_skip("sample.mkv") else {
return;
};
assert!(
matches!(
mkv_attempt(&p, true, MkvBudget::DEFAULT),
MkvAttempt::Frame(_)
),
"対照: 既定の予算ならこのファイルは絵になる"
);
for bytes in [1u64, 512, 4096] {
assert!(
!matches!(
mkv_attempt(&p, true, MkvBudget { bytes, packets: 8 }),
MkvAttempt::Frame(_)
),
"{bytes} バイトしか読めない予算では諦める(読み続けない)"
);
}
assert!(
!matches!(
mkv_attempt(
&p,
false,
MkvBudget {
bytes: NATIVE_MKV_MAX_SCAN_BYTES,
packets: 0
}
),
MkvAttempt::Frame(_)
),
"パケット数の上限も効く"
);
}
#[test]
fn mkv_codec_id_dispatch_picks_the_right_decoder() {
use NativeCodecKind::{Avc, Hevc};
assert_eq!(mkv_codec_kind(video_codecs::CODEC_ID_H264), Some(Avc));
assert_eq!(mkv_codec_kind(video_codecs::CODEC_ID_HEVC), Some(Hevc));
for other in [
video_codecs::CODEC_ID_VP9,
video_codecs::CODEC_ID_AV1,
video_codecs::CODEC_ID_VP8,
video_codecs::CODEC_ID_MPEG4,
video_codecs::CODEC_ID_THEORA,
VideoCodecId::default(),
] {
assert_eq!(
mkv_codec_kind(other),
None,
"{other} は内蔵デコーダの担当でない=外部ツールへ降格する"
);
}
}
#[test]
fn extra_data_is_chosen_by_id_not_by_position() {
use symphonia_core::codecs::video::VideoExtraData;
let block = |id, byte| VideoExtraData {
id,
data: vec![byte; 4].into_boxed_slice(),
};
let mut params = VideoCodecParameters::default();
params.extra_data.push(block(
extra_data_ids::VIDEO_EXTRA_DATA_ID_DOLBY_VISION_CONFIG,
0xDD,
));
params.extra_data.push(block(
extra_data_ids::VIDEO_EXTRA_DATA_ID_HEVC_DECODER_CONFIG,
0xEE,
));
params.extra_data.push(block(
extra_data_ids::VIDEO_EXTRA_DATA_ID_AVC_DECODER_CONFIG,
0xAA,
));
assert_eq!(
extra_data_for(¶ms, NativeCodecKind::Hevc),
Some(&[0xEE; 4][..]),
"先頭の Dolby Vision 記録を hvcC と取り違えている"
);
assert_eq!(
extra_data_for(¶ms, NativeCodecKind::Avc),
Some(&[0xAA; 4][..])
);
let mut only_avc = VideoCodecParameters::default();
only_avc.extra_data.push(block(
extra_data_ids::VIDEO_EXTRA_DATA_ID_AVC_DECODER_CONFIG,
0xAA,
));
assert_eq!(extra_data_for(&only_avc, NativeCodecKind::Hevc), None);
assert_eq!(
extra_data_for(&VideoCodecParameters::default(), NativeCodecKind::Avc),
None,
"CodecPrivate がまったく無いトラック(VP9 の webm がこの形)"
);
}
#[test]
fn mkv_native_path_degrades_safely_on_broken_input() {
assert!(thumbnail_native(Path::new("/no/such/video.mkv")).is_none());
let dir = unique_tmp("konoma_video_mkv_broken_test");
std::fs::create_dir_all(&dir).unwrap();
let garbage = dir.join("garbage.mkv");
std::fs::write(&garbage, b"not matroska at all, just some bytes\n").unwrap();
assert!(thumbnail_native(&garbage).is_none(), "非 mkv は None");
let Some(src) = sample_path_or_skip("sample.mkv") else {
std::fs::remove_dir_all(&dir).ok();
return;
};
let original = std::fs::read(&src).unwrap();
let truncated = dir.join("truncated.mkv");
std::fs::write(&truncated, &original[..original.len() / 3]).unwrap();
assert!(
thumbnail_native(&truncated).is_none(),
"途中で切れた mkv は None(panic しない)"
);
let cluster = original
.windows(4)
.position(|w| w == [0x1F, 0x43, 0xB6, 0x75])
.expect("samples/sample.mkv に Cluster 要素がある");
let mut bytes = original.clone();
for b in bytes.iter_mut().skip(cluster + 4) {
*b = b.wrapping_mul(31).wrapping_add(7);
}
let shredded = dir.join("shredded.mkv");
std::fs::write(&shredded, &bytes).unwrap();
assert!(
thumbnail_native(&shredded).is_none(),
"サンプルデータが壊れた mkv は None(panic しない)"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn mkv_guard_reads_the_bitstream_not_the_container() {
let Some(src) = sample_path_or_skip("sample.mkv") else {
return;
};
let original = std::fs::read(&src).unwrap();
let cfg = {
let source = BudgetedSource::open(&src, NATIVE_MKV_MAX_SCAN_BYTES).unwrap();
let mss = MediaSourceStream::new(Box::new(source), MediaSourceStreamOptions::default());
let reader = MkvReader::try_new(mss, FormatOptions::default()).unwrap();
let track = find_mkv_track(&reader).expect("H.264 トラックが見つかる");
assert_eq!(track.kind, NativeCodecKind::Avc);
track.cfg
};
let record = original
.windows(cfg.len())
.position(|w| w == cfg)
.expect("CodecPrivate(avcC 記録)がファイル中にそのまま入っている");
let container_profile = record + 1;
let sps_profile = record + 9;
assert_eq!(
original[record], 1,
"avcC の先頭は configurationVersion=1(検算)"
);
assert_eq!(original[container_profile], 100, "コンテナ申告は High(100)");
assert_eq!(
original[record + 8] & 0x1f,
7,
"SPS NAL(type 7)を指している(検算)"
);
assert_eq!(original[sps_profile], 100, "ビットストリームも High(100)");
let dir = unique_tmp("konoma_video_mkv_bitstream_guard_test");
std::fs::create_dir_all(&dir).unwrap();
let control = dir.join("control.mkv");
std::fs::write(&control, &original).unwrap();
assert!(
thumbnail_native(&control).is_some(),
"対照: 無改変のコピーはデコードできる"
);
for bad in [110u8, 122, 244] {
let mut bytes = original.clone();
bytes[container_profile] = bad;
let p = dir.join(format!("container{bad}.mkv"));
std::fs::write(&p, &bytes).unwrap();
assert!(
thumbnail_native(&p).is_some(),
"CodecPrivate の申告({bad})は判断材料でない=ビットストリームが 8bit 4:2:0 High なら描ける"
);
let mut bytes = original.clone();
bytes[sps_profile] = bad;
let p = dir.join(format!("sps{bad}.mkv"));
std::fs::write(&p, &bytes).unwrap();
assert!(
thumbnail_native(&p).is_none(),
"SPS が profile {bad} と言うならデコード前に拒否する(mp4 と同じガードが mkv でも効く)"
);
}
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn native_path_extracts_a_real_frame_without_any_external_tool() {
let Some(p) = sample_path_or_skip("sample.mp4") else {
return;
};
let started = Instant::now();
let img = thumbnail_native(&p).expect("H.264 mp4 は純 Rust 経路でサムネイルになるはず");
let elapsed = started.elapsed();
eprintln!("native thumbnail of samples/sample.mp4: {elapsed:?}");
assert_eq!(
(img.width(), img.height()),
(320, 240),
"元動画そのままの寸法(既定値やスケール後の寸法ではない)"
);
let luma: Vec<u8> = img.to_luma8().into_raw();
let spread = luma_spread(&luma);
assert!(
spread > NATIVE_FLAT_LUMA_SPREAD,
"一様な絵しか出ていない(デコード結果が絵になっていない): 輝度幅={spread}"
);
assert!(
thumbnail(&p, false).is_some(),
"[external] video = false でも純 Rust 経路が先に走る"
);
}
#[test]
fn native_path_degrades_safely_on_broken_input() {
assert!(thumbnail_native(Path::new("/no/such/video.mp4")).is_none());
let dir = unique_tmp("konoma_video_broken_test");
std::fs::create_dir_all(&dir).unwrap();
let garbage = dir.join("garbage.mp4");
std::fs::write(&garbage, b"not an mp4 at all, just some bytes\n").unwrap();
assert!(thumbnail_native(&garbage).is_none(), "非 mp4 は None");
if let Some(src) = sample_path_or_skip("sample.mp4") {
let mut bytes = std::fs::read(&src).unwrap();
let truncated = dir.join("truncated.mp4");
std::fs::write(&truncated, &bytes[..bytes.len() / 3]).unwrap();
assert!(
thumbnail_native(&truncated).is_none(),
"途中で切れた mp4 は None(panic しない)"
);
let mdat = bytes
.windows(4)
.position(|w| w == b"mdat")
.expect("samples/sample.mp4 に mdat がある")
+ 4;
for b in bytes.iter_mut().skip(mdat) {
*b = b.wrapping_mul(31).wrapping_add(7);
}
let shredded = dir.join("shredded.mp4");
std::fs::write(&shredded, &bytes).unwrap();
assert!(
thumbnail_native(&shredded).is_none(),
"サンプルデータが壊れた mp4 は None(panic しない)"
);
}
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn keyframe_selection_targets_ten_percent_and_walks_forward() {
fn track(sync_at: &[usize], len: usize) -> Vec<re_mp4::Sample> {
(0..len)
.map(|i| re_mp4::Sample {
id: i as u32,
is_sync: sync_at.contains(&i),
..Default::default()
})
.collect()
}
let s = track(&[0, 9, 30, 60], 100);
assert_eq!(pick_keyframes(&s, 3), vec![9, 30, 60]);
assert_eq!(pick_keyframes(&s, 1), vec![9]);
assert_eq!(pick_keyframes(&track(&[0, 9], 100), 3), vec![9]);
assert_eq!(pick_keyframes(&track(&[0], 40), 3), vec![0]);
assert!(pick_keyframes(&track(&[], 40), 3).is_empty());
assert!(pick_keyframes(&[], 3).is_empty());
}
static PATH_MUTATING_TESTS: std::sync::Mutex<()> = std::sync::Mutex::new(());
#[test]
fn nonexistent_or_nonvideo_returns_none() {
assert!(
thumbnail(Path::new("/no/such/video.mp4"), true).is_none(),
"存在しないパスは None"
);
let dir = unique_tmp("konoma_video_nonvideo_test");
std::fs::create_dir_all(&dir).unwrap();
let not_a_video = dir.join("notes.mp4");
std::fs::write(¬_a_video, b"this is plain text, not an mp4 container\n").unwrap();
assert!(
thumbnail(¬_a_video, true).is_none(),
".mp4 という名前だけの非動画ファイルは None(ffmpeg があれば実際に起動して拒否したことを検査する)"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn extracts_correct_frame_when_ffmpeg_available() {
let _guard = PATH_MUTATING_TESTS
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let has_ffmpeg = Command::new("ffmpeg")
.arg("-version")
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false);
if !has_ffmpeg {
eprintln!("skip: ffmpeg 不在");
return;
}
let vid = unique_tmp("konoma-vthumb-test-green").with_extension("mp4");
let _ = std::fs::remove_file(&vid);
let made = Command::new("ffmpeg")
.args(["-y", "-loglevel", "error", "-f", "lavfi", "-i"])
.arg("color=c=green:s=64x64:d=1")
.arg(&vid)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false);
assert!(made, "テスト用動画の生成に失敗");
let img = thumbnail(&vid, true).expect("ffmpeg があればサムネイルが取れるはず");
assert!(img.width() > 0 && img.height() > 0, "サムネイル寸法が 0");
let rgba = img.to_rgba8();
let px = rgba.get_pixel(rgba.width() / 2, rgba.height() / 2);
let (r, g, b) = (px[0], px[1], px[2]);
assert!(
g > r && g > b && g > 60,
"中央が緑でない(抽出フレームが元動画と不一致?): rgb=({r},{g},{b})"
);
std::fs::remove_file(&vid).ok();
}
#[cfg(unix)]
#[test]
fn ffmpeg_tools_never_inherit_this_process_stdin() {
use std::os::unix::fs::PermissionsExt;
use std::os::unix::io::AsRawFd;
extern "C" {
fn dup(fd: i32) -> i32;
fn dup2(oldfd: i32, newfd: i32) -> i32;
fn close(fd: i32) -> i32;
}
let _guard = PATH_MUTATING_TESTS
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let dir = unique_tmp("konoma_vthumb_stdin_leak_test");
std::fs::create_dir_all(&dir).expect("create test dir");
let sentinel_path = dir.join("sentinel.txt");
std::fs::write(&sentinel_path, b"STDIN_LEAK_SENTINEL\n").unwrap();
for name in ["ffmpeg", "ffmpegthumbnailer"] {
let script = dir.join(name);
std::fs::write(
&script,
format!("#!/bin/sh\ncat > \"$(dirname \"$0\")/captured_{name}.txt\"\nexit 1\n"),
)
.unwrap();
let mut perms = std::fs::metadata(&script).unwrap().permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&script, perms).unwrap();
}
let orig_path = std::env::var("PATH").unwrap_or_default();
let new_path = format!("{}:{}", dir.display(), orig_path);
unsafe { std::env::set_var("PATH", &new_path) };
let sentinel_file = std::fs::File::open(&sentinel_path).expect("open sentinel");
let saved_stdin = unsafe { dup(0) };
assert!(saved_stdin >= 0, "failed to save this process's fd 0");
let rc = unsafe { dup2(sentinel_file.as_raw_fd(), 0) };
assert_eq!(rc, 0, "failed to redirect fd 0 to the sentinel file");
struct Restore {
orig_path: String,
saved_stdin: i32,
}
impl Drop for Restore {
fn drop(&mut self) {
unsafe {
dup2(self.saved_stdin, 0);
close(self.saved_stdin);
std::env::set_var("PATH", &self.orig_path);
}
}
}
let _restore = Restore {
orig_path: orig_path.clone(),
saved_stdin,
};
let out = dir.join("out.png");
let _ = run_ffmpegthumbnailer(Path::new("/dev/null"), &out);
let _ = run_ffmpeg(Path::new("/dev/null"), &out);
let captured_thumbnailer =
std::fs::read(dir.join("captured_ffmpegthumbnailer.txt")).unwrap_or_default();
let captured_ffmpeg = std::fs::read(dir.join("captured_ffmpeg.txt")).unwrap_or_default();
assert!(
captured_thumbnailer.is_empty(),
"run_ffmpegthumbnailer が親プロセスの stdin を子に継承している(sentinel を読めてしまった): {:?}",
String::from_utf8_lossy(&captured_thumbnailer)
);
assert!(
captured_ffmpeg.is_empty(),
"run_ffmpeg が親プロセスの stdin を子に継承している(sentinel を読めてしまった): {:?}",
String::from_utf8_lossy(&captured_ffmpeg)
);
std::fs::remove_dir_all(&dir).ok();
}
#[cfg(unix)]
#[test]
fn temp_png_path_lives_in_an_owner_only_directory() {
use std::os::unix::fs::PermissionsExt;
let path = temp_png_path();
let dir = path.parent().expect("temp_png_path has a parent dir");
assert_ne!(
dir,
std::env::temp_dir(),
"システム共有の一時ディレクトリ直下に出力している(専用サブディレクトリを持っていない)"
);
let meta = std::fs::metadata(dir).expect("private temp dir should exist by now");
assert!(meta.is_dir());
let mode = meta.permissions().mode() & 0o777;
assert_eq!(
mode, 0o700,
"抽出フレームの置き場が owner-only(0700) になっていない: {dir:?} mode={mode:#o}"
);
}
#[cfg(unix)]
#[test]
fn spawn_and_wait_with_timeout_kills_a_command_that_never_exits() {
let mut cmd = Command::new("tail");
cmd.arg("-f")
.arg("/dev/null")
.stdout(Stdio::null())
.stderr(Stdio::null());
let started = Instant::now();
let status = spawn_and_wait_with_timeout(&mut cmd, Duration::from_millis(200));
let elapsed = started.elapsed();
assert!(
status.is_none(),
"ハングするコマンドが None(タイムアウト)を返さなかった: {status:?}"
);
assert!(
elapsed < Duration::from_secs(10),
"タイムアウトが機能せずブロックし続けた: elapsed={elapsed:?}"
);
}
}