#1417: WAV markers show on the audio layer

A WAV's markers were never read, so its audio layer showed none. The importer
now reads the file's cue points (`cue ` chunk named by `LIST/adtl` `labl` and
`note`, sized by `ltxt`) and Adobe's XMP markers (`_PMX` chunk, `xmpDM:markers`
entries as elements or attributes, in frames of the track's `xmpDM:frameRate`).
A marker present in both encodings is listed once. The reader is bounds-checked
and caps the marker count and XMP size.

- `Footage.markers` (optional in the file format) holds them at footage time; a
  layer made from the footage starts with them.
- Layer ▸ Markers ▸ Update Markers From Source also works on footage layers.
- Markers from a file have no label colour.

Tests: reader unit tests (cue, note, ltxt, both XMP forms, both encodings,
truncated and hostile files), a probe test, and an end-to-end test through the
fully wired session. Not checked against a WAV written by Adobe's tools.

Claude-Session: https://claude.ai/code/session_013fTnVxnXTv2MBW6uj2PPyU
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Claude
2026-10-18 17:38:44 +02:00
committed by Kristian Myram
parent f3f24ef189
commit bccbc666a2
9 changed files with 455 additions and 6 deletions
Generated
+1
View File
@@ -1443,6 +1443,7 @@ name = "effectcraft-media"
version = "0.12.0"
dependencies = [
"craft-docpath",
"effectcraft-color",
"effectcraft-model",
"effectcraft-pdf",
"effectcraft-project",
+10 -5
View File
@@ -296,8 +296,9 @@ fn convert(s: &mut Session, p: &Value) -> Result<Value> {
Ok(Value::Null)
}
/// Layer ▸ Markers ▸ Update Markers From Source: copy a precomp's composition markers onto the
/// layer (layer time is the nested comp's time), skipping ones already there.
/// Layer ▸ Markers ▸ Update Markers From Source: copy a precomp's composition markers, or the
/// markers a footage file carries (a WAV's cue points), onto the layer (layer time is the source's
/// time), skipping ones already there.
fn update_from_source(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, ids) = layers_p(s, p)?;
let src: Vec<(LayerId, Vec<Marker>)> = {
@@ -307,13 +308,17 @@ fn update_from_source(s: &mut Session, p: &Value) -> Result<Value> {
let l = comp.layer(*id)?;
match &l.source {
LayerSource::Comp { item } => Some((*id, s.project.comp(*item)?.markers.clone())),
LayerSource::Footage { item } => match &s.project.item(*item)?.kind {
effectcraft_project::ItemKind::Footage(f) => Some((*id, f.markers.clone())),
_ => None,
},
_ => None,
}
})
.collect()
};
if src.is_empty() {
return Err(bad("layer.updateMarkersFromSource", "select a precomposition layer"));
return Err(bad("layer.updateMarkersFromSource", "select a precomposition or footage layer"));
}
let n = s.edit("Update Markers From Source", None, |proj, _| {
let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
@@ -339,10 +344,10 @@ fn update_from_source(s: &mut Session, p: &Value) -> Result<Value> {
fn has_precomp_layer(s: &Session) -> std::result::Result<(), String> {
has_layers(s)?;
let c = s.active_comp().ok_or("no composition")?;
if s.state.selected_layers.iter().filter_map(|l| c.layer(*l)).any(|l| matches!(l.source, LayerSource::Comp { .. })) {
if s.state.selected_layers.iter().filter_map(|l| c.layer(*l)).any(|l| matches!(l.source, LayerSource::Comp { .. } | LayerSource::Footage { .. })) {
Ok(())
} else {
Err("select a precomposition layer".into())
Err("select a precomposition or footage layer".into())
}
}
+75
View File
@@ -0,0 +1,75 @@
//! Markers a WAV file carries, through the fully wired session (media import included): the
//! layer made from the file starts with them, and Update Markers From Source copies them again.
use effectcraft_project::LayerSource;
use serde_json::json;
/// A one-second mono 8 kHz WAV with two named cue points (a `cue ` chunk and a `LIST` `adtl`).
fn marker_wav() -> Vec<u8> {
fn chunk(id: &[u8; 4], body: &[u8]) -> Vec<u8> {
let mut v = id.to_vec();
v.extend((body.len() as u32).to_le_bytes());
v.extend(body);
if body.len() % 2 == 1 {
v.push(0);
}
v
}
let mut fmt = Vec::new();
fmt.extend(1u16.to_le_bytes());
fmt.extend(1u16.to_le_bytes());
fmt.extend(8000u32.to_le_bytes());
fmt.extend(16000u32.to_le_bytes());
fmt.extend(2u16.to_le_bytes());
fmt.extend(16u16.to_le_bytes());
let points = [(1u32, 0u32, "Intro"), (2, 4000, "Middle")];
let mut cue = (points.len() as u32).to_le_bytes().to_vec();
let mut adtl = b"adtl".to_vec();
for (id, at, name) in points {
for v in [id, 0] {
cue.extend(v.to_le_bytes());
}
cue.extend(b"data");
for v in [0, 0, at] {
cue.extend(u32::to_le_bytes(v));
}
let mut labl = id.to_le_bytes().to_vec();
labl.extend(name.as_bytes());
labl.push(0);
adtl.extend(chunk(b"labl", &labl));
}
let mut body = b"WAVE".to_vec();
for c in [chunk(b"fmt ", &fmt), chunk(b"data", &[0; 16000]), chunk(b"cue ", &cue), chunk(b"LIST", &adtl)] {
body.extend(c);
}
let mut file = b"RIFF".to_vec();
file.extend((body.len() as u32).to_le_bytes());
file.extend(body);
file
}
/// A WAV's cue points become markers on the layer made from it, named as in the file, and
/// Update Markers From Source brings back one that was deleted (through the fully wired session,
/// media import included).
#[test]
fn a_wav_layer_starts_with_the_markers_in_its_file() {
let mut s = effectcraft_host::session();
s.execute_checked("comp.new", json!({"name": "C", "width": 64, "height": 48, "frameRate": 30, "duration": 2})).unwrap();
let dir = std::env::temp_dir().join(format!("effectcraft-host-wav-markers-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("cues.wav");
std::fs::write(&path, marker_wav()).unwrap();
let r = s.execute_checked("file.import", json!({"paths": [path.to_string_lossy()], "addToComp": true})).unwrap();
assert_eq!(r["errors"], json!([]), "{r}");
let layer = s.active_comp().unwrap().layers.iter().find(|l| matches!(l.source, LayerSource::Footage { .. })).unwrap().id.0;
let list = s.execute_checked("markers.list", json!({"layer": layer})).unwrap();
let marks: Vec<(String, f64)> =
list.as_array().unwrap().iter().map(|m| (m["comment"].as_str().unwrap().to_string(), m["time"].as_f64().unwrap())).collect();
assert_eq!(marks, [("Intro".to_string(), 0.0), ("Middle".to_string(), 0.5)]);
s.execute_checked("markers.delete", json!({"layer": layer, "index": 0})).unwrap();
s.execute_checked("layer.select", json!({"layers": [layer]})).unwrap();
assert_eq!(s.execute_checked("layer.updateMarkersFromSource", json!({})).unwrap()["added"], 1);
let list = s.execute_checked("markers.list", json!({"layer": layer})).unwrap();
assert_eq!(list.as_array().unwrap().len(), 2);
let _ = std::fs::remove_dir_all(&dir);
}
+1
View File
@@ -12,6 +12,7 @@ workspace = true
[dependencies]
craft-docpath = { workspace = true }
effectcraft-time = { workspace = true }
effectcraft-color = { workspace = true }
effectcraft-project = { workspace = true }
effectcraft-raster = { workspace = true }
effectcraft-render = { workspace = true }
+1
View File
@@ -25,6 +25,7 @@ pub use layered::vector_doc;
mod pool;
mod probe;
mod stream;
mod wav_markers;
mod webp_anim;
pub use pool::{DEFAULT_BUDGET, MediaPool, PoolStats};
+3
View File
@@ -98,8 +98,11 @@ pub fn probe_bytes(path: &str, bytes: Arc<[u8]>) -> Result<Footage> {
let name = Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string());
let turn = video_turn(&bytes);
let rate = video_rate(&bytes);
// A WAV's cue points and XMP markers become the footage's markers.
let markers = crate::wav_markers::read(&bytes);
let src = filmcraft_codecs::open_bytes(&name, bytes)?;
let mut f = footage_from_info(path, src.info());
f.markers = markers;
turn_footage(&mut f, turn);
correct_rate(&mut f, rate);
Ok(f)
+350
View File
@@ -0,0 +1,350 @@
//! Markers a WAV file carries, read from its RIFF chunks.
//!
//! Two encodings are read and merged:
//! - **Cue points** (the standard): a `cue ` chunk with one point per marker, named by `labl` (or
//! `note`) sub-chunks of a `LIST` `adtl` chunk, and given a length by `ltxt`.
//! - **XMP** (Adobe's tools): an `_PMX` chunk holding an XMP packet whose `xmpDM:markers` list has
//! one entry per marker (`xmpDM:startTime`, `xmpDM:duration`, `xmpDM:name`), counted in frames
//! of `xmpDM:frameRate` (`f44100` for samples at 44.1 kHz).
//!
//! The file is untrusted input: every read is bounds-checked, sizes use checked arithmetic, and
//! the number of markers and the size of the XMP packet are capped.
use effectcraft_color::Label;
use effectcraft_project::Marker;
use effectcraft_time::Tick;
/// Most markers read from one file.
pub const MAX_MARKERS: usize = 10_000;
/// Largest XMP packet read (bytes).
const MAX_XMP: usize = 4 << 20;
fn u32_at(b: &[u8], at: usize) -> Option<u32> {
let s = b.get(at..at.checked_add(4)?)?;
Some(u32::from_le_bytes(s.try_into().ok()?))
}
/// The text of a NUL-terminated label (the rest of the chunk), lossily decoded.
fn label(b: &[u8]) -> String {
let end = b.iter().position(|&c| c == 0).unwrap_or(b.len());
String::from_utf8_lossy(b.get(..end).unwrap_or_default()).trim().to_string()
}
fn secs(frames: f64, rate: f64) -> Tick {
if rate > 0.0 && frames.is_finite() { Tick::from_seconds_f64((frames / rate).max(0.0)) } else { Tick::ZERO }
}
/// Whether `bytes` start a RIFF/WAVE file.
pub fn is_wav(bytes: &[u8]) -> bool {
matches!(bytes.get(0..4), Some(b"RIFF" | b"RF64")) && bytes.get(8..12) == Some(b"WAVE")
}
/// The chunks of a WAVE file as `(id, body)`, stopping at the first one that doesn't fit.
fn chunks(bytes: &[u8]) -> Vec<([u8; 4], &[u8])> {
let mut out = Vec::new();
let mut at = 12usize;
while let Some(head) = bytes.get(at..at.saturating_add(8)) {
let (Ok(id), Some(size)) = (<[u8; 4]>::try_from(&head[..4]), u32_at(head, 4)) else { break };
let start = at.saturating_add(8);
let Some(end) = start.checked_add(size as usize) else { break };
let Some(body) = bytes.get(start..end) else { break };
out.push((id, body));
// Chunks are padded to an even size.
at = end.saturating_add(end & 1);
}
out
}
/// The markers `bytes` carry, in time order (empty for anything that isn't a WAV with markers).
pub fn read(bytes: &[u8]) -> Vec<Marker> {
if !is_wav(bytes) {
return Vec::new();
}
let chunks = chunks(bytes);
let rate = chunks.iter().find(|(id, _)| id == b"fmt ").and_then(|(_, b)| u32_at(b, 4)).map_or(0.0, f64::from);
let mut out = cue_markers(&chunks, rate);
if let Some((_, xmp)) = chunks.iter().find(|(id, _)| id == b"_PMX") {
for m in xmp_markers(xmp, rate) {
// Tools that write both encodings repeat each marker.
if !out.iter().any(|o| o.comment == m.comment && o.time == m.time) {
out.push(m);
}
}
}
out.truncate(MAX_MARKERS);
out.sort_by_key(|m| m.time);
out
}
/// Cue points named and sized by the `adtl` list.
fn cue_markers(chunks: &[([u8; 4], &[u8])], rate: f64) -> Vec<Marker> {
let Some((_, cue)) = chunks.iter().find(|(id, _)| id == b"cue ") else { return Vec::new() };
let count = (u32_at(cue, 0).unwrap_or(0) as usize).min(MAX_MARKERS);
// Cue id -> (name, length in samples).
let mut names: Vec<(u32, String)> = Vec::new();
let mut lengths: Vec<(u32, u32)> = Vec::new();
for (_, list) in chunks.iter().filter(|(id, b)| id == b"LIST" && b.get(0..4) == Some(b"adtl")) {
let mut at = 4usize;
while let Some(head) = list.get(at..at.saturating_add(8)) {
let (Ok(id), Some(size)) = (<[u8; 4]>::try_from(&head[..4]), u32_at(head, 4)) else { break };
let start = at.saturating_add(8);
let Some(end) = start.checked_add(size as usize) else { break };
let Some(body) = list.get(start..end) else { break };
if let Some(cue_id) = u32_at(body, 0) {
match &id {
b"labl" | b"note" => {
let text = label(body.get(4..).unwrap_or_default());
// A `labl` is the name; a `note` only when there is none.
let known = names.iter().any(|(c, _)| *c == cue_id);
if &id == b"labl" || !known {
names.retain(|(c, _)| *c != cue_id);
names.push((cue_id, text));
}
}
b"ltxt" => lengths.push((cue_id, u32_at(body, 4).unwrap_or(0))),
_ => {}
}
}
at = end.saturating_add(end & 1);
}
}
let mut out = Vec::new();
for i in 0..count {
let Some(point) = cue.get(4usize.saturating_add(i.saturating_mul(24))..) else { break };
let (Some(id), Some(offset)) = (u32_at(point, 0), u32_at(point, 20)) else { break };
let comment = names.iter().find(|(c, _)| *c == id).map(|(_, t)| t.clone()).unwrap_or_default();
let length = lengths.iter().find(|(c, _)| *c == id).map_or(0, |(_, l)| *l);
out.push(Marker { time: secs(f64::from(offset), rate), duration: secs(f64::from(length), rate), comment, label: Label::None, ..Default::default() });
}
out
}
/// The text of `name` in an XMP entry, as an attribute (`name="v"`) or an element
/// (`<name>v</name>`).
fn xmp_value(entry: &str, name: &str) -> Option<String> {
let attr = format!("{name}=\"");
if let Some(i) = entry.find(&attr) {
let rest = entry.get(i + attr.len()..)?;
return Some(unescape(rest.get(..rest.find('"')?)?));
}
let open = format!("<{name}>");
let i = entry.find(&open)?;
let rest = entry.get(i + open.len()..)?;
Some(unescape(rest.get(..rest.find('<')?)?))
}
fn unescape(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut rest = s;
while let Some(i) = rest.find('&') {
out.push_str(rest.get(..i).unwrap_or_default());
let tail = rest.get(i..).unwrap_or_default();
let Some(semi) = tail.find(';').filter(|&n| n <= 10) else {
out.push('&');
rest = tail.get(1..).unwrap_or_default();
continue;
};
let ent = tail.get(1..semi).unwrap_or_default();
match ent {
"amp" => out.push('&'),
"lt" => out.push('<'),
"gt" => out.push('>'),
"quot" => out.push('"'),
"apos" => out.push('\''),
_ => {
let code = ent.strip_prefix("#x").and_then(|h| u32::from_str_radix(h, 16).ok()).or_else(|| ent.strip_prefix('#').and_then(|d| d.parse().ok()));
match code.and_then(char::from_u32) {
Some(c) => out.push(c),
None => out.push_str(tail.get(..=semi).unwrap_or_default()),
}
}
}
rest = tail.get(semi + 1..).unwrap_or_default();
}
out.push_str(rest);
out.trim().to_string()
}
/// A frame rate as XMP writes it: `f44100`, or `f30000s1001` (rate over scale).
fn xmp_rate(text: &str) -> Option<f64> {
let t = text.trim().strip_prefix('f')?;
let (num, den) = t.split_once('s').unwrap_or((t, "1"));
let (n, d): (f64, f64) = (num.parse().ok()?, den.parse().ok()?);
(n > 0.0 && d > 0.0).then_some(n / d)
}
/// The markers of an XMP packet's `xmpDM:markers` entries.
fn xmp_markers(packet: &[u8], file_rate: f64) -> Vec<Marker> {
let text = String::from_utf8_lossy(packet.get(..packet.len().min(MAX_XMP)).unwrap_or_default());
let mut out = Vec::new();
// The frame rate belongs to the track an entry follows.
let mut rate = file_rate;
for entry in text.split("<rdf:li").skip(1) {
if let Some(r) = xmp_value(entry, "xmpDM:frameRate").as_deref().and_then(xmp_rate) {
rate = r;
}
let Some(start) = xmp_value(entry, "xmpDM:startTime").and_then(|v| v.trim().parse::<f64>().ok()) else { continue };
let length = xmp_value(entry, "xmpDM:duration").and_then(|v| v.trim().parse::<f64>().ok()).unwrap_or(0.0);
let comment = xmp_value(entry, "xmpDM:name").unwrap_or_default();
out.push(Marker { time: secs(start, rate), duration: secs(length, rate), comment, label: Label::None, ..Default::default() });
if out.len() >= MAX_MARKERS {
break;
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn chunk(id: &[u8; 4], body: &[u8]) -> Vec<u8> {
let mut v = id.to_vec();
v.extend((body.len() as u32).to_le_bytes());
v.extend(body);
if body.len() % 2 == 1 {
v.push(0);
}
v
}
fn fmt(rate: u32) -> Vec<u8> {
let mut b = Vec::new();
b.extend(1u16.to_le_bytes()); // PCM
b.extend(1u16.to_le_bytes()); // mono
b.extend(rate.to_le_bytes());
b.extend((rate * 2).to_le_bytes());
b.extend(2u16.to_le_bytes());
b.extend(16u16.to_le_bytes());
chunk(b"fmt ", &b)
}
fn wav(chunks: &[Vec<u8>]) -> Vec<u8> {
let body: Vec<u8> = chunks.iter().flatten().copied().collect();
let mut v = b"RIFF".to_vec();
v.extend((body.len() as u32 + 4).to_le_bytes());
v.extend(b"WAVE");
v.extend(body);
v
}
fn cue(points: &[(u32, u32)]) -> Vec<u8> {
let mut b = (points.len() as u32).to_le_bytes().to_vec();
for (id, offset) in points {
b.extend(id.to_le_bytes());
b.extend(0u32.to_le_bytes());
b.extend(b"data");
b.extend(0u32.to_le_bytes());
b.extend(0u32.to_le_bytes());
b.extend(offset.to_le_bytes());
}
chunk(b"cue ", &b)
}
fn labl(id: &[u8; 4], cue_id: u32, text: &str) -> Vec<u8> {
let mut b = cue_id.to_le_bytes().to_vec();
b.extend(text.as_bytes());
b.push(0);
chunk(id, &b)
}
fn adtl(subs: &[Vec<u8>]) -> Vec<u8> {
let mut b = b"adtl".to_vec();
b.extend(subs.iter().flatten());
chunk(b"LIST", &b)
}
#[test]
fn cue_points_are_named_by_the_adtl_list_and_timed_in_samples() {
let file = wav(&[
fmt(44100),
chunk(b"data", &[0; 8]),
cue(&[(2, 88200), (1, 22050)]),
adtl(&[labl(b"labl", 1, "SEQ01_Prologue"), labl(b"labl", 2, "SEQ02_Vallee_P1_Agriculture")]),
]);
let m = read(&file);
assert_eq!(m.len(), 2);
assert_eq!((m[0].comment.as_str(), m[0].time), ("SEQ01_Prologue", Tick::from_seconds_f64(0.5)));
assert_eq!((m[1].comment.as_str(), m[1].time), ("SEQ02_Vallee_P1_Agriculture", Tick::from_seconds_f64(2.0)));
}
#[test]
fn a_note_names_a_cue_only_when_it_has_no_label_and_ltxt_gives_a_length() {
let mut ltxt = 1u32.to_le_bytes().to_vec();
ltxt.extend(44100u32.to_le_bytes());
ltxt.extend([0; 16]);
let file = wav(&[
fmt(44100),
cue(&[(1, 0), (2, 44100)]),
adtl(&[labl(b"note", 1, "from note"), chunk(b"ltxt", &ltxt), labl(b"labl", 2, "named"), labl(b"note", 2, "ignored")]),
]);
let m = read(&file);
assert_eq!((m[0].comment.as_str(), m[0].duration), ("from note", Tick::from_seconds_f64(1.0)));
assert_eq!(m[1].comment, "named");
}
const XMP_ELEMENTS: &str = r#"<x:xmpmeta><rdf:RDF><rdf:Description><xmpDM:Tracks><rdf:Bag><rdf:li rdf:parseType="Resource"><xmpDM:trackName>Markers</xmpDM:trackName><xmpDM:trackType>Cue</xmpDM:trackType><xmpDM:frameRate>f44100</xmpDM:frameRate><xmpDM:markers><rdf:Seq><rdf:li rdf:parseType="Resource"><xmpDM:startTime>44100</xmpDM:startTime><xmpDM:duration>0</xmpDM:duration><xmpDM:name>One &amp; Two</xmpDM:name></rdf:li><rdf:li rdf:parseType="Resource"><xmpDM:startTime>132300</xmpDM:startTime><xmpDM:duration>22050</xmpDM:duration><xmpDM:name>Three</xmpDM:name></rdf:li></rdf:Seq></xmpDM:markers></rdf:li></rdf:Bag></xmpDM:Tracks></rdf:Description></rdf:RDF></x:xmpmeta>"#;
#[test]
fn xmp_markers_are_read_as_elements_in_frames_of_the_track_rate() {
let file = wav(&[fmt(48000), chunk(b"_PMX", XMP_ELEMENTS.as_bytes())]);
let m = read(&file);
assert_eq!(m.len(), 2);
// 44100 frames at f44100 is one second, whatever the file's own rate.
assert_eq!((m[0].comment.as_str(), m[0].time), ("One & Two", Tick::from_seconds_f64(1.0)));
assert_eq!((m[1].comment.as_str(), m[1].time, m[1].duration), ("Three", Tick::from_seconds_f64(3.0), Tick::from_seconds_f64(0.5)));
}
#[test]
fn xmp_markers_are_read_as_attributes_and_use_the_file_rate_without_a_track_rate() {
let xmp = r#"<rdf:Seq><rdf:li xmpDM:startTime="24000" xmpDM:duration="0" xmpDM:name="Attr &#233;"/></rdf:Seq>"#;
let m = read(&wav(&[fmt(48000), chunk(b"_PMX", xmp.as_bytes())]));
assert_eq!((m[0].comment.as_str(), m[0].time), ("Attr é", Tick::from_seconds_f64(0.5)));
}
#[test]
fn a_marker_in_both_encodings_is_listed_once() {
let xmp = r#"<rdf:li xmpDM:startTime="44100" xmpDM:name="Same"/><rdf:li xmpDM:startTime="88200" xmpDM:name="Only XMP"/>"#;
let file = wav(&[fmt(44100), cue(&[(1, 44100)]), adtl(&[labl(b"labl", 1, "Same")]), chunk(b"_PMX", xmp.as_bytes())]);
let names: Vec<String> = read(&file).into_iter().map(|m| m.comment).collect();
assert_eq!(names, ["Same", "Only XMP"]);
}
#[test]
fn importing_a_wav_puts_its_markers_on_the_footage() {
let file = wav(&[
fmt(44100),
chunk(b"data", &[0; 4 * 44100]),
cue(&[(1, 0), (2, 44100)]),
adtl(&[labl(b"labl", 1, "SEQ01_Prologue"), labl(b"labl", 2, "SEQ02_Vallee_P1_Agriculture")]),
]);
let f = crate::probe::probe_bytes("markers.wav", file.into()).expect("probe");
assert!(f.has_audio);
let names: Vec<&str> = f.markers.iter().map(|m| m.comment.as_str()).collect();
assert_eq!(names, ["SEQ01_Prologue", "SEQ02_Vallee_P1_Agriculture"]);
assert_eq!(f.markers[1].time, Tick::from_seconds_f64(1.0));
}
#[test]
fn damaged_or_hostile_files_read_as_far_as_they_go_without_panicking() {
assert!(read(b"").is_empty());
assert!(read(b"RIFF").is_empty());
assert!(read(b"not a wav file at all").is_empty());
let good = wav(&[fmt(44100), cue(&[(1, 44100)]), adtl(&[labl(b"labl", 1, "Kept")])]);
// Every truncation is safe.
for n in 0..good.len() {
let _ = read(&good[..n]);
}
// A cue count far beyond the chunk, and chunk sizes past the end of the file.
let mut huge = 0xFFFF_FFFFu32.to_le_bytes().to_vec();
huge.extend([0; 24]);
let m = read(&wav(&[fmt(44100), chunk(b"cue ", &huge)]));
assert!(m.len() <= 1);
let mut lying = good.clone();
lying.extend(b"LIST\xff\xff\xff\xffadtl");
assert_eq!(read(&lying).len(), 1);
// No sample rate: times are zero, not a division by zero.
let m = read(&wav(&[cue(&[(1, 44100)])]));
assert_eq!(m[0].time, Tick::ZERO);
}
}
+9 -1
View File
@@ -854,6 +854,14 @@ pub fn layer(project: &mut Project, comp: &Comp, name: &str, source: LayerSource
_ => {}
}
}
// A footage layer starts with the markers its file carries (layer time is footage time).
let footage_markers = match &source {
LayerSource::Footage { item } => match project.item(*item).map(|i| &i.kind) {
Some(crate::ItemKind::Footage(f)) => f.markers.clone(),
_ => vec![],
},
_ => vec![],
};
let has_audio = matches!(&source, LayerSource::Footage { item } if matches!(project.item(*item).map(|i| &i.kind), Some(crate::ItemKind::Footage(f)) if f.has_audio))
|| matches!(source, LayerSource::Comp { .. });
if has_audio {
@@ -883,7 +891,7 @@ pub fn layer(project: &mut Project, comp: &Comp, name: &str, source: LayerSource
preserve_transparency: false,
track_matte: None,
parent: None,
markers: vec![],
markers: footage_markers,
markers_locked: false,
environment: false,
environment_background: false,
+5
View File
@@ -1172,6 +1172,10 @@ pub struct Footage {
/// timecode isn't read). It only relabels source time, as in After Effects.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub start_timecode: Option<i64>,
/// Markers the file carries (a WAV's cue points and XMP markers), at footage time. A layer
/// made from the footage starts with them, and Update Markers From Source copies new ones.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub markers: Vec<Marker>,
}
fn is_default<T: Default + PartialEq>(v: &T) -> bool {
@@ -1207,6 +1211,7 @@ impl Default for Footage {
page: 0,
alphabetical: false,
start_timecode: None,
markers: vec![],
}
}
}