mirror of
https://github.com/storytold/filmcraft.git
synced 2026-11-02 18:09:21 +00:00
Merge remote-tracking branch 'upstream/main' into cs-ui
The conflict was in ROADMAP.md, where main recorded the ProRes alpha work: the header names both changes and both revision-history rows are kept. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Generated
+3
@@ -2281,6 +2281,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"tempfile",
|
||||
"thiserror 2.0.21",
|
||||
"ureq",
|
||||
]
|
||||
@@ -2291,6 +2292,7 @@ version = "0.12.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"png",
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2324,6 +2326,7 @@ dependencies = [
|
||||
"filmcraft-tts-text",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"thiserror 2.0.21",
|
||||
"zip",
|
||||
]
|
||||
|
||||
@@ -192,7 +192,7 @@ No FFmpeg inside. The video codecs, AAC, Opus and the containers are our own Rus
|
||||
| **MP4 / MOV** | ✓ | ✓ | Fragmented MP4, edit lists, timecode tracks |
|
||||
| **Matroska / WebM** | ✓ | | Lacing, Cues, header stripping, HDR colour metadata |
|
||||
| **AVI** | ✓ | | AVI 1.0 and OpenDML (files past 1 GB); Motion JPEG, H.264, HEVC and uncompressed RGB / YUV with PCM, MP3, MP2 or AC-3. DivX / Xvid and DV not yet |
|
||||
| **Stills** | ✓ | ✓ | Import PNG, JPEG, GIF, WebP, TIFF and BMP (animated GIF and APNG as clips, with transparency); export PNG sequences and animated GIF |
|
||||
| **Stills** | ✓ | ✓ | Import PNG, JPEG, GIF, WebP, TIFF and BMP (animated GIF, APNG and WebP as clips, with transparency); export PNG sequences and animated GIF |
|
||||
|
||||
Also imported: MPEG-2 / MPEG-1 video, AC-3, MP2, MXF (OP1a / OP-Atom), MPEG transport and program streams (AVCHD, broadcast, DVD), Ogg and image sequences. Not yet: AV1 export, camera RAW, E-AC-3. Hardware decoding works on macOS (VideoToolbox), Windows (Media Foundation) and, for H.264 and HEVC, Linux (VA-API); H.264 export can use NVIDIA's encoder on Windows and Linux (opt-in), and H.265 (HEVC Main, 8-bit) export works through it (NVENC on Windows, VideoToolbox on macOS) ([#30](https://github.com/storytold/filmcraft/issues/30)).
|
||||
|
||||
|
||||
+4
-1
@@ -5,7 +5,7 @@
|
||||
|
||||
**Stage: alpha** · next: beta, ~19 points of ready-for-real-work and ~400–700 h away, plus opening Premiere's `.prproj` ([why](docs/target-app-parity.md#stage); passes the [core-workflow gate](docs/roadmap.md#alpha-gate))
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-19 · **Change:** minor (language table: Czech, #1156; previously: German covers the whole interface) · **Target:** Adobe Premiere Pro 2026 (26.5.2)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (ProRes alpha final-run conformance and native macOS validation; language table: Czech, #1156) · **Target:** Adobe Premiere Pro 2026 (26.5.2)
|
||||
|
||||
Progress toward parity with Adobe Premiere Pro. This page is the summary; the detail is in
|
||||
[docs/target-app-parity.md](docs/target-app-parity.md) (assessment, methodology, calibration),
|
||||
@@ -108,6 +108,8 @@ Ranked; detail and "done when" in [docs/roadmap.md](docs/roadmap.md) and [docs/g
|
||||
|
||||
## Progress log
|
||||
|
||||
- **2026-10-22:** ProRes 4444 / XQ alpha slices include the final singleton run code required by RDD 36:2022. Six HD fixtures rejected by native macOS decoding now pass; syntax regression tests cover final runs and slice widths ([details](crates/prores/README.md#alpha-bitstream-conformance), EffectCraft #293).
|
||||
|
||||
- **2026-10-12:** Transform's motion-blur shutter on adjustment layers uses the sequence frame rate instead of 24 fps, so a 360° shutter in a 60 fps sequence spans one frame, not 2.5 (#1088)
|
||||
- **2026-10-10 (changed media):** Settings ▸ Media ▸ Automatically refresh growing files works: media files that change on disk while the project is open (a recording still being written, a render written over the file a clip uses) are read again every *Refresh growing files every* seconds and as soon as the window comes back to the front: the new length, picture and identity, without an undo step (`media.refreshChanged`, `media_watch`).
|
||||
- **2026-10-10 (re-measure):** full parity re-measure against Premiere Pro 26.5.2: menu label diff 338 / 366 (92%), installed bundle listings (10 UI languages, exporter and codec frameworks), 188 open issues. Breadth ~86% (file formats now counted), ready ~55%, stage **alpha**; progress docs reorganized per the craftrules standard ([target-app-parity](docs/target-app-parity.md), [gaps](docs/gaps.md), [roadmap](docs/roadmap.md), [codec](docs/codec-parity.md), [file-format](docs/file-format-parity.md), [hardware](docs/hardware-parity.md), [UI](docs/ui-parity.md), [localization](docs/localization-parity.md)).
|
||||
@@ -167,6 +169,7 @@ Ranked; detail and "done when" in [docs/roadmap.md](docs/roadmap.md) and [docs/g
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-22 | minor | ProRes alpha: write the final singleton run code; 4444/XQ HD clips that failed native macOS decoding now decode, with syntax regression tests (EffectCraft #293) |
|
||||
| 2026-10-19 | minor | Language table: Czech (#1156, 100%) |
|
||||
| 2026-10-13 | minor | Language table: German covers the whole interface (~100%) |
|
||||
| 2026-10-12 | trivial | Progress log: Transform shutter on adjustment layers (#1088) |
|
||||
|
||||
@@ -24,6 +24,8 @@ use subtle::ConstantTimeEq;
|
||||
pub const MAX_LINE: usize = 4 * 1024 * 1024;
|
||||
/// Connections served at once; further ones get an error line and are closed.
|
||||
pub const MAX_CONNECTIONS: usize = 16;
|
||||
/// A peer that stops sending or reading must eventually give back its connection slot.
|
||||
const CONNECTION_TIMEOUT: Duration = Duration::from_secs(120);
|
||||
|
||||
pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {
|
||||
start_with_token(port, ctx, std::env::var("FILMCRAFT_CONTROL_TOKEN").ok())
|
||||
@@ -83,13 +85,22 @@ impl Drop for Slot {
|
||||
}
|
||||
|
||||
fn serve(stream: TcpStream, tx: &Sender<ControlRequest>, ctx: &egui::Context, token: &str) {
|
||||
let Ok(read) = stream.try_clone() else { return };
|
||||
if let Err(e) = serve_with_timeout(stream, tx, ctx, token, CONNECTION_TIMEOUT) {
|
||||
eprintln!("filmcraft: could not configure control connection: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
fn serve_with_timeout(stream: TcpStream, tx: &Sender<ControlRequest>, ctx: &egui::Context, token: &str, timeout: Duration) -> std::io::Result<()> {
|
||||
stream.set_read_timeout(Some(timeout))?;
|
||||
stream.set_write_timeout(Some(timeout))?;
|
||||
let read = stream.try_clone()?;
|
||||
serve_lines(read, stream, token, |method, params| {
|
||||
let (req, rrx) = ControlRequest::new(method, params);
|
||||
tx.send(req).ok()?;
|
||||
ctx.request_repaint();
|
||||
Some(rrx.recv_timeout(Duration::from_secs(60)).unwrap_or_else(|_| json!({"ok": false, "error": "timeout"})))
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// One parsed line.
|
||||
@@ -131,13 +142,22 @@ fn parse_line(bytes: &[u8], token: &str) -> Line {
|
||||
Line::Request { id: obj.get("id").cloned().unwrap_or(Value::Null), method: method.to_string(), params: obj.get("params").cloned().unwrap_or(json!({})) }
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
enum ReadFailure {
|
||||
Timeout,
|
||||
Rejected(String),
|
||||
}
|
||||
|
||||
/// Read one `\n`-terminated line of at most `max` bytes into `buf` (newline stripped).
|
||||
/// `Ok(false)` at end of stream; `Err` when the line is too long or the read fails.
|
||||
fn read_bounded_line(reader: &mut impl BufRead, buf: &mut Vec<u8>, max: usize) -> Result<bool, String> {
|
||||
fn read_bounded_line(reader: &mut impl BufRead, buf: &mut Vec<u8>, max: usize) -> Result<bool, ReadFailure> {
|
||||
buf.clear();
|
||||
// `take` bounds the bytes buffered for one line: a peer can't make us allocate more than `max`.
|
||||
let limit = u64::try_from(max).unwrap_or(u64::MAX).saturating_add(1);
|
||||
let n = reader.take(limit).read_until(b'\n', buf).map_err(|e| e.to_string())?;
|
||||
let n = reader.take(limit).read_until(b'\n', buf).map_err(|e| match e.kind() {
|
||||
std::io::ErrorKind::TimedOut | std::io::ErrorKind::WouldBlock => ReadFailure::Timeout,
|
||||
_ => ReadFailure::Rejected(e.to_string()),
|
||||
})?;
|
||||
if n == 0 {
|
||||
return Ok(false);
|
||||
}
|
||||
@@ -149,7 +169,7 @@ fn read_bounded_line(reader: &mut impl BufRead, buf: &mut Vec<u8>, max: usize) -
|
||||
return Ok(true);
|
||||
}
|
||||
if buf.len() > max {
|
||||
return Err(format!("request line longer than {max} bytes; closing the connection"));
|
||||
return Err(ReadFailure::Rejected(format!("request line longer than {max} bytes; closing the connection")));
|
||||
}
|
||||
// Last line without a newline, then end of stream.
|
||||
Ok(true)
|
||||
@@ -164,7 +184,10 @@ fn serve_lines<R: Read, W: Write>(read: R, mut out: W, token: &str, mut handle:
|
||||
loop {
|
||||
let reject = match read_bounded_line(&mut reader, &mut buf, MAX_LINE) {
|
||||
Ok(false) => return,
|
||||
Err(e) => e,
|
||||
// Expiration is not a request: a pooled client must see EOF and reconnect, rather
|
||||
// than read a stale error reply. A partial line must not be parsed on timeout.
|
||||
Err(ReadFailure::Timeout) => return,
|
||||
Err(ReadFailure::Rejected(e)) => e,
|
||||
Ok(true) => match parse_line(&buf, token) {
|
||||
Line::Blank => continue,
|
||||
Line::Reject(e) => e,
|
||||
@@ -312,6 +335,115 @@ mod tests {
|
||||
drop(held);
|
||||
}
|
||||
|
||||
/// Idle and unterminated requests must release a counted connection without client EOF.
|
||||
fn timed_out_connection_releases_slot(partial: &[u8]) {
|
||||
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let open = Arc::new(AtomicUsize::new(1));
|
||||
let counted = Arc::clone(&open);
|
||||
let (tx, rx) = channel::<ControlRequest>();
|
||||
let (done, finished) = channel();
|
||||
let server = std::thread::spawn(move || {
|
||||
let (stream, _) = listener.accept().unwrap();
|
||||
{
|
||||
let _slot = Slot(counted);
|
||||
serve_with_timeout(stream, &tx, &egui::Context::default(), TOKEN, Duration::from_millis(100)).unwrap();
|
||||
}
|
||||
done.send(()).unwrap();
|
||||
});
|
||||
let mut client = TcpStream::connect(addr).unwrap();
|
||||
client.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
|
||||
client.write_all(partial).unwrap();
|
||||
let completion = finished.recv_timeout(Duration::from_secs(5));
|
||||
let mut unsolicited = String::new();
|
||||
if completion.is_ok() {
|
||||
client.read_to_string(&mut unsolicited).unwrap();
|
||||
}
|
||||
// Always unblock and join the test worker before asserting, even on the unfixed server.
|
||||
let unexpected_command = rx.try_recv().is_ok();
|
||||
drop(rx);
|
||||
drop(client);
|
||||
server.join().unwrap();
|
||||
assert!(completion.is_ok(), "an idle peer held its connection slot until the client closed");
|
||||
assert!(unsolicited.is_empty(), "expiration must not leave a stale reply for a pooled client: {unsolicited}");
|
||||
assert_eq!(open.load(Ordering::SeqCst), 0);
|
||||
assert!(!unexpected_command, "an incomplete request must not reach the UI queue");
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// A silent unauthenticated peer must not retain control-server capacity.
|
||||
fn idle_connection_releases_its_slot() {
|
||||
timed_out_connection_releases_slot(b"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Sending a partial JSON line must not bypass idle expiration.
|
||||
fn unterminated_request_releases_its_slot() {
|
||||
timed_out_connection_releases_slot(b"{\"method\":");
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// A timeout is not EOF: valid JSON without its newline must not be dispatched.
|
||||
fn valid_unterminated_request_is_not_dispatched() {
|
||||
let request = json!({"id": 1, "token": TOKEN, "method": "ui.inspect"}).to_string();
|
||||
timed_out_connection_releases_slot(request.as_bytes());
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Waiting for the UI is separate from socket idleness; buffered requests remain answerable.
|
||||
fn idle_timeout_does_not_cancel_a_pending_ui_reply() {
|
||||
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let (tx, rx) = channel::<ControlRequest>();
|
||||
let server = std::thread::spawn(move || {
|
||||
let (stream, _) = listener.accept().unwrap();
|
||||
serve_with_timeout(stream, &tx, &egui::Context::default(), TOKEN, Duration::from_millis(100)).unwrap();
|
||||
});
|
||||
let mut client = TcpStream::connect(addr).unwrap();
|
||||
client.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
|
||||
let mut read = BufReader::new(client.try_clone().unwrap());
|
||||
// Buffer complete requests, as the bridge does. Queue both before waiting for replies:
|
||||
// client scheduling after reply 1 must not create a genuine idle gap in this test.
|
||||
let requests: String = [1, 2].into_iter().map(|id| format!("{}\n", json!({"token": TOKEN, "id": id, "method": "ui.inspect"}))).collect();
|
||||
client.write_all(requests.as_bytes()).unwrap();
|
||||
for id in [1, 2] {
|
||||
let request = rx.recv_timeout(Duration::from_secs(5)).unwrap_or_else(|e| panic!("request {id} did not reach the UI: {e}"));
|
||||
// Deliberately exceed the read timeout while the server is awaiting a UI reply.
|
||||
std::thread::sleep(Duration::from_millis(250));
|
||||
request.reply.send(json!({"ok": true})).unwrap();
|
||||
let mut line = String::new();
|
||||
read.read_line(&mut line).unwrap();
|
||||
assert_eq!(serde_json::from_str::<Value>(&line).unwrap(), json!({"id": id, "ok": true}));
|
||||
}
|
||||
client.shutdown(std::net::Shutdown::Write).unwrap();
|
||||
server.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// An active client can send another request after consuming its previous reply.
|
||||
fn active_connection_can_be_reused() {
|
||||
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let (tx, rx) = channel::<ControlRequest>();
|
||||
let server = std::thread::spawn(move || {
|
||||
let (stream, _) = listener.accept().unwrap();
|
||||
serve_with_timeout(stream, &tx, &egui::Context::default(), TOKEN, Duration::from_secs(5)).unwrap();
|
||||
});
|
||||
let mut client = TcpStream::connect(addr).unwrap();
|
||||
client.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
|
||||
let mut read = BufReader::new(client.try_clone().unwrap());
|
||||
for id in [1, 2] {
|
||||
let line = format!("{}\n", json!({"token": TOKEN, "id": id, "method": "ui.inspect"}));
|
||||
client.write_all(line.as_bytes()).unwrap();
|
||||
rx.recv_timeout(Duration::from_secs(5)).unwrap().reply.send(json!({"ok": true})).unwrap();
|
||||
let mut reply = String::new();
|
||||
read.read_line(&mut reply).unwrap();
|
||||
assert_eq!(serde_json::from_str::<Value>(&reply).unwrap(), json!({"id": id, "ok": true}));
|
||||
}
|
||||
client.shutdown(std::net::Shutdown::Write).unwrap();
|
||||
server.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_or_invalid_token_leaves_the_listener_closed() {
|
||||
for token in [None, Some(String::new()), Some("a".repeat(63)), Some("a".repeat(65)), Some("z".repeat(64))] {
|
||||
|
||||
@@ -37,6 +37,13 @@ pub const CONTEXT: usize = 4;
|
||||
pub const MIN_PIECE_BEATS: usize = 4;
|
||||
/// Crossfade at each joint (seconds).
|
||||
pub const XFADE_S: f64 = 0.02;
|
||||
/// Long enough for a complete music cue while bounding the audio decoded and analysed by Remix.
|
||||
pub const MAX_ANALYSIS_SECONDS: u64 = 10 * 60;
|
||||
/// A dense similarity matrix is useful for planning, but must remain a bounded allocation.
|
||||
const MAX_SIMILARITY_CELLS: usize = 4_000_000;
|
||||
/// Remix analysis supports ordinary production and high-resolution audio rates, not arbitrary
|
||||
/// integers supplied through a public library API.
|
||||
pub const MAX_ANALYSIS_SAMPLE_RATE: u32 = 384_000;
|
||||
|
||||
/// One piece of the output: `len` samples read from the source at `src`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
@@ -139,6 +146,12 @@ impl Analysis {
|
||||
/// Why music cannot be remixed.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum RemixError {
|
||||
/// The source sample rate is zero or above the supported analysis limit.
|
||||
InvalidSampleRate,
|
||||
/// The source would exceed the bounded Remix analysis duration.
|
||||
TooLong,
|
||||
/// The detected beat grid would need too much similarity storage or work.
|
||||
TooComplex,
|
||||
/// Not enough rhythmic content to find a beat (silence, noise, speech).
|
||||
NoBeat,
|
||||
/// The source has fewer beats than a remix needs.
|
||||
@@ -150,6 +163,9 @@ pub enum RemixError {
|
||||
impl std::fmt::Display for RemixError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(match self {
|
||||
RemixError::InvalidSampleRate => "the audio sample rate is not supported for remix analysis",
|
||||
RemixError::TooLong => "the music is too long to remix",
|
||||
RemixError::TooComplex => "the music has too many beats to remix",
|
||||
RemixError::NoBeat => "no beat found in the audio",
|
||||
RemixError::TooShort => "the music is too short to remix",
|
||||
RemixError::TargetTooShort => "the target duration is too short for this music",
|
||||
@@ -188,7 +204,8 @@ fn stft_mag(x: &[f32], n: usize, hop: usize) -> Vec<Vec<f32>> {
|
||||
/// STFT frame for the onset envelope: the power of two nearest to 46 ms (1024 at 22.05 kHz, 2048
|
||||
/// at 44.1/48 kHz).
|
||||
fn onset_frame(sr: u32) -> usize {
|
||||
let n = (sr as f64 * 0.046).max(256.0);
|
||||
// `onset_envelope` is public on its own, so it must remain bounded even outside `analyze`.
|
||||
let n = (sr.min(MAX_ANALYSIS_SAMPLE_RATE) as f64 * 0.046).max(256.0);
|
||||
1usize << n.log2().round() as u32
|
||||
}
|
||||
|
||||
@@ -344,31 +361,34 @@ fn beat_features(x: &[f32], sr: u32, beats: &[i64]) -> (Vec<[f32; 12]>, Vec<[f32
|
||||
}
|
||||
frame_cep.push(cep);
|
||||
}
|
||||
let mut chroma = Vec::with_capacity(beats.len());
|
||||
let mut cep = Vec::with_capacity(beats.len());
|
||||
// Frames and beats are both ordered. Assign every frame once instead of scanning the entire
|
||||
// feature timeline for every beat interval.
|
||||
let mut chroma = vec![[0f32; 12]; beats.len()];
|
||||
let mut cep = vec![[0f32; 12]; beats.len()];
|
||||
let mut counts = vec![0f32; beats.len()];
|
||||
let end = x.len() as i64;
|
||||
for (i, &b) in beats.iter().enumerate() {
|
||||
let e = beats.get(i + 1).copied().unwrap_or(end);
|
||||
let (mut c, mut q, mut cnt) = ([0f32; 12], [0f32; 12], 0f32);
|
||||
for (t, (fc, fq)) in frame_chroma.iter().zip(&frame_cep).enumerate() {
|
||||
let centre = (t * hop + n / 2) as i64;
|
||||
if centre >= b && centre < e {
|
||||
for k in 0..12 {
|
||||
c[k] += fc[k];
|
||||
q[k] += fq[k];
|
||||
}
|
||||
cnt += 1.0;
|
||||
}
|
||||
let mut beat = 0;
|
||||
for (t, (fc, fq)) in frame_chroma.iter().zip(&frame_cep).enumerate() {
|
||||
let centre = (t * hop + n / 2) as i64;
|
||||
while beat + 1 < beats.len() && centre >= beats[beat + 1] {
|
||||
beat += 1;
|
||||
}
|
||||
if cnt > 0.0 {
|
||||
q.iter_mut().for_each(|v| *v /= cnt);
|
||||
if centre >= beats[beat] && centre < end {
|
||||
for k in 0..12 {
|
||||
chroma[beat][k] += fc[k];
|
||||
cep[beat][k] += fq[k];
|
||||
}
|
||||
counts[beat] += 1.0;
|
||||
}
|
||||
}
|
||||
for ((c, q), count) in chroma.iter_mut().zip(&mut cep).zip(counts) {
|
||||
if count > 0.0 {
|
||||
q.iter_mut().for_each(|v| *v /= count);
|
||||
}
|
||||
let norm = c.iter().map(|v| v * v).sum::<f32>().sqrt();
|
||||
if norm > 0.0 {
|
||||
c.iter_mut().for_each(|v| *v /= norm);
|
||||
}
|
||||
chroma.push(c);
|
||||
cep.push(q);
|
||||
}
|
||||
// z-score the cepstra across beats
|
||||
let m = cep.len().max(1) as f32;
|
||||
@@ -380,6 +400,10 @@ fn beat_features(x: &[f32], sr: u32, beats: &[i64]) -> (Vec<[f32; 12]>, Vec<[f32
|
||||
(chroma, cep)
|
||||
}
|
||||
|
||||
fn similarity_cells(beats: usize) -> Result<usize, RemixError> {
|
||||
beats.checked_mul(beats).filter(|&n| n <= MAX_SIMILARITY_CELLS).ok_or(RemixError::TooComplex)
|
||||
}
|
||||
|
||||
fn cos(a: &[f32; 12], b: &[f32; 12]) -> f32 {
|
||||
let d: f32 = a.iter().zip(b).map(|(x, y)| x * y).sum();
|
||||
let na = a.iter().map(|v| v * v).sum::<f32>().sqrt();
|
||||
@@ -398,7 +422,13 @@ fn local_max(env: &[f32], t: usize, r: usize) -> f32 {
|
||||
|
||||
/// Analyse planar audio (channels are summed to mono).
|
||||
pub fn analyze(channels: &[&[f32]], sample_rate: u32) -> Result<Analysis, RemixError> {
|
||||
if sample_rate == 0 || sample_rate > MAX_ANALYSIS_SAMPLE_RATE {
|
||||
return Err(RemixError::InvalidSampleRate);
|
||||
}
|
||||
let len = channels.iter().map(|c| c.len()).max().unwrap_or(0);
|
||||
if (len as u64) > u64::from(sample_rate).saturating_mul(MAX_ANALYSIS_SECONDS) {
|
||||
return Err(RemixError::TooLong);
|
||||
}
|
||||
let k = 1.0 / channels.len().max(1) as f32;
|
||||
let mono: Vec<f32> = (0..len).map(|i| channels.iter().map(|c| c.get(i).copied().unwrap_or(0.0)).sum::<f32>() * k).collect();
|
||||
let (env, n, hop) = onset_envelope(&mono, sample_rate);
|
||||
@@ -434,7 +464,7 @@ pub fn analyze(channels: &[&[f32]], sample_rate: u32) -> Result<Analysis, RemixE
|
||||
}
|
||||
let (chroma, cep) = beat_features(&mono, sample_rate, &beats);
|
||||
let m = beats.len();
|
||||
let mut sim = vec![0f32; m * m];
|
||||
let mut sim = vec![0f32; similarity_cells(m)?];
|
||||
for i in 0..m {
|
||||
for j in 0..m {
|
||||
sim[i * m + j] = 0.6 * cos(&chroma[i], &chroma[j]) + 0.4 * (1.0 + cos(&cep[i], &cep[j])) * 0.5;
|
||||
@@ -796,6 +826,22 @@ mod tests {
|
||||
assert!(analyze(&[&noise], SR).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn analysis_duration_and_similarity_storage_are_bounded() {
|
||||
let too_long = vec![0.0; MAX_ANALYSIS_SECONDS as usize + 1];
|
||||
assert_eq!(analyze(&[&too_long], 1), Err(RemixError::TooLong));
|
||||
assert_eq!(similarity_cells(2_000), Ok(4_000_000));
|
||||
assert_eq!(similarity_cells(2_001), Err(RemixError::TooComplex));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn public_analysis_rate_is_bounded_before_fft_planning() {
|
||||
assert_eq!(analyze(&[], 0), Err(RemixError::InvalidSampleRate));
|
||||
assert_eq!(analyze(&[], u32::MAX), Err(RemixError::InvalidSampleRate));
|
||||
let (_, n, _) = onset_envelope(&[], u32::MAX);
|
||||
assert!(n <= onset_frame(MAX_ANALYSIS_SAMPLE_RATE));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_crossfades_without_clicks_and_ignores_block_size() {
|
||||
let (m, _, a) = music(120.0, 24);
|
||||
|
||||
@@ -20,6 +20,9 @@ pub struct StreamResampler {
|
||||
next_out: Option<i64>,
|
||||
}
|
||||
|
||||
/// Maximum source frames fetched in a single resampler pull.
|
||||
pub const MAX_RESAMPLE_SOURCE_SPAN: usize = 384_000 * 10;
|
||||
|
||||
impl StreamResampler {
|
||||
/// Rates of zero are treated as 1 Hz rather than dividing by zero.
|
||||
pub fn new(from: u32, to: u32) -> Self {
|
||||
@@ -53,7 +56,7 @@ impl StreamResampler {
|
||||
}
|
||||
let held_end = self.held_start + self.held.first().map_or(0, Vec::len) as i64;
|
||||
if last + 1 > held_end {
|
||||
let want = (last + 1 - held_end) as usize;
|
||||
let want = ((last + 1 - held_end) as usize).min(MAX_RESAMPLE_SOURCE_SPAN);
|
||||
let got = pull(held_end, want);
|
||||
if self.held.is_empty() {
|
||||
self.held = vec![Vec::new(); got.len().max(1)];
|
||||
|
||||
@@ -77,9 +77,13 @@ pub fn mixdown(channels: &[&[f32]]) -> Vec<f32> {
|
||||
/// Low-pass filter (Blackman-windowed sinc, cutoff at 0.42 × the new sample rate) and keep every
|
||||
/// `d`-th sample.
|
||||
pub fn decimate(x: &[f32], d: usize) -> Vec<f32> {
|
||||
if d <= 1 {
|
||||
if x.is_empty() || d <= 1 {
|
||||
return x.to_vec();
|
||||
}
|
||||
// Factors above the input length cannot produce another output sample. Cap the useful factor
|
||||
// as well as the filter width so a public caller cannot turn a tiny input into a huge plan.
|
||||
const MAX_FACTOR: usize = 4096;
|
||||
let d = d.min(x.len()).min(MAX_FACTOR);
|
||||
let half = 8 * d;
|
||||
let fc = 0.42 / d as f64; // cycles per input sample
|
||||
let taps: Vec<f32> = {
|
||||
@@ -396,6 +400,13 @@ mod tests {
|
||||
assert!(rh < 0.01, "{rh}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decimate_does_not_plan_a_filter_for_empty_or_extreme_requests() {
|
||||
assert!(decimate(&[], usize::MAX).is_empty());
|
||||
let tiny = [1.0, -1.0];
|
||||
assert_eq!(decimate(&tiny, usize::MAX), decimate(&tiny, tiny.len()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pure_delay_exact_both_signs() {
|
||||
let sr = 48_000;
|
||||
|
||||
@@ -108,7 +108,7 @@ pub struct ClickParams {
|
||||
/// left | right | middle
|
||||
#[serde(default)]
|
||||
pub button: Option<String>,
|
||||
/// 2 for double-click.
|
||||
/// Number of clicks (0–10; defaults to 1). Use 2 for double-click. Larger values are rejected.
|
||||
#[serde(default)]
|
||||
pub count: Option<u32>,
|
||||
/// {"shift":bool,"alt":bool,"command":bool,"ctrl":bool}
|
||||
@@ -122,6 +122,7 @@ pub struct DragParams {
|
||||
pub from: Value,
|
||||
/// End point, same forms.
|
||||
pub to: Value,
|
||||
/// Movement steps (0–1024; defaults to 12; 0 or 1 uses 2). Larger values are rejected.
|
||||
#[serde(default)]
|
||||
pub steps: Option<u32>,
|
||||
#[serde(default)]
|
||||
@@ -497,6 +498,7 @@ impl FilmcraftMcp {
|
||||
self.ui("ui.elements", json!({"prefix": p.prefix.unwrap_or_default()})).await
|
||||
}
|
||||
|
||||
/// Forward a click to the live editor; an omitted count is sent as null and uses its default.
|
||||
#[tool(
|
||||
title = "Live app: click",
|
||||
description = "Live app: click an element by id or at x,y (points). Supports right/middle button, double-click (count=2) and modifiers.",
|
||||
@@ -513,6 +515,7 @@ impl FilmcraftMcp {
|
||||
self.ui("ui.click", v).await
|
||||
}
|
||||
|
||||
/// Forward a drag to the live editor, which applies the step default and validates its bounds.
|
||||
#[tool(
|
||||
title = "Live app: drag",
|
||||
description = "Live app: press-drag-release from one point/element to another (move clips, trim edges, scrub, resize panels, drop project items onto tracks).",
|
||||
|
||||
@@ -133,6 +133,10 @@ pub(crate) fn apply(pic: &mut Picture, g: &FilmGrainParams, seq: &SequenceHeader
|
||||
for i in 0..n - 1 {
|
||||
let dy = ys[i + 1] as i32 - ys[i] as i32;
|
||||
let dx = xs[i + 1] as i32 - xs[i] as i32;
|
||||
// The header parser rejects non-increasing points; never divide by zero regardless.
|
||||
if dx <= 0 {
|
||||
continue;
|
||||
}
|
||||
let delta = dy * ((65536 + (dx >> 1)) / dx);
|
||||
for x in 0..dx {
|
||||
l[xs[i] as usize + x as usize] = ys[i] as i32 + ((x * delta + 32768) >> 16);
|
||||
@@ -274,3 +278,34 @@ pub(crate) fn apply(pic: &mut Picture, g: &FilmGrainParams, seq: &SequenceHeader
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// Regression: two equal scaling points divided by zero building the scaling lookup.
|
||||
#[test]
|
||||
fn equal_scaling_points_do_not_panic() {
|
||||
let mut g = FilmGrainParams { apply_grain: true, num_y_points: 2, ..Default::default() };
|
||||
g.point_y_value[..2].copy_from_slice(&[64, 64]);
|
||||
g.point_y_scaling[..2].copy_from_slice(&[20, 40]);
|
||||
let (w, h) = (16usize, 16usize);
|
||||
let mut pic = Picture {
|
||||
width: w as u32,
|
||||
height: h as u32,
|
||||
bit_depth: 10,
|
||||
subsampling_x: 1,
|
||||
subsampling_y: 1,
|
||||
mono_chrome: false,
|
||||
planes: [vec![512; w * h], vec![512; w * h / 4], vec![512; w * h / 4]],
|
||||
color_primaries: 1,
|
||||
transfer_characteristics: 1,
|
||||
matrix_coefficients: 1,
|
||||
full_range: false,
|
||||
pts: 0,
|
||||
draft: false,
|
||||
};
|
||||
apply(&mut pic, &g, &SequenceHeader::default());
|
||||
assert_eq!(pic.planes[0].len(), w * h);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1251,6 +1251,9 @@ impl FrameHeader {
|
||||
g.point_y_value[i] = r.f(8)? as u8;
|
||||
g.point_y_scaling[i] = r.f(8)? as u8;
|
||||
}
|
||||
if !strictly_increasing(&g.point_y_value[..g.num_y_points]) {
|
||||
return Err(Error::Invalid("point_y_value"));
|
||||
}
|
||||
g.chroma_scaling_from_luma = if seq.color.mono_chrome { false } else { r.flag()? };
|
||||
if seq.color.mono_chrome || g.chroma_scaling_from_luma || (seq.color.subsampling_x == 1 && seq.color.subsampling_y == 1 && g.num_y_points == 0) {
|
||||
g.num_cb_points = 0;
|
||||
@@ -1264,6 +1267,9 @@ impl FrameHeader {
|
||||
g.point_cb_value[i] = r.f(8)? as u8;
|
||||
g.point_cb_scaling[i] = r.f(8)? as u8;
|
||||
}
|
||||
if !strictly_increasing(&g.point_cb_value[..g.num_cb_points]) {
|
||||
return Err(Error::Invalid("point_cb_value"));
|
||||
}
|
||||
g.num_cr_points = r.f(4)? as usize;
|
||||
if g.num_cr_points > 10 {
|
||||
return Err(Error::Invalid("num_cr_points"));
|
||||
@@ -1272,6 +1278,9 @@ impl FrameHeader {
|
||||
g.point_cr_value[i] = r.f(8)? as u8;
|
||||
g.point_cr_scaling[i] = r.f(8)? as u8;
|
||||
}
|
||||
if !strictly_increasing(&g.point_cr_value[..g.num_cr_points]) {
|
||||
return Err(Error::Invalid("point_cr_value"));
|
||||
}
|
||||
}
|
||||
g.grain_scaling_minus_8 = r.f(2)?;
|
||||
g.ar_coeff_lag = r.f(2)?;
|
||||
@@ -1322,6 +1331,12 @@ pub(crate) fn default_gm_params() -> [[i32; 6]; 8] {
|
||||
p
|
||||
}
|
||||
|
||||
/// Film grain scaling points must strictly increase (6.8.20); equal points would divide by zero when
|
||||
/// the scaling lookup is interpolated.
|
||||
fn strictly_increasing(values: &[u8]) -> bool {
|
||||
values.windows(2).all(|w| w.first() < w.get(1))
|
||||
}
|
||||
|
||||
fn inverse_recenter(r: i32, v: i32) -> i32 {
|
||||
if v > 2 * r {
|
||||
v
|
||||
@@ -1359,3 +1374,17 @@ fn decode_subexp(r: &mut BitReader, num_syms: i32) -> Result<i32> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::strictly_increasing;
|
||||
|
||||
#[test]
|
||||
fn film_grain_points_must_strictly_increase() {
|
||||
assert!(strictly_increasing(&[]));
|
||||
assert!(strictly_increasing(&[7]));
|
||||
assert!(strictly_increasing(&[0, 64, 255]));
|
||||
assert!(!strictly_increasing(&[0, 64, 64]));
|
||||
assert!(!strictly_increasing(&[128, 64]));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@ Only the specification text is read:
|
||||
Each table is emitted as a nested Rust array whose shape is checked against the declared
|
||||
dimensions; the element type is the narrowest of u8/i8/u16/i16/i32 that holds every value.
|
||||
"""
|
||||
import ast
|
||||
import operator
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
@@ -43,7 +45,30 @@ def ev(expr):
|
||||
e = expr.strip().replace("\\", "")
|
||||
e = re.sub(r"\b([A-Z][A-Z0-9_]*)\b", lambda m: str(consts[m.group(1)]) if m.group(1) in consts else m.group(1), e)
|
||||
e = e.replace("/", "//")
|
||||
return int(eval(e, {"__builtins__": {}}, {}))
|
||||
binary = {
|
||||
ast.Add: operator.add,
|
||||
ast.Sub: operator.sub,
|
||||
ast.Mult: operator.mul,
|
||||
ast.FloorDiv: operator.floordiv,
|
||||
ast.Mod: operator.mod,
|
||||
ast.LShift: operator.lshift,
|
||||
ast.RShift: operator.rshift,
|
||||
ast.BitOr: operator.or_,
|
||||
ast.BitAnd: operator.and_,
|
||||
ast.BitXor: operator.xor,
|
||||
}
|
||||
unary = {ast.UAdd: operator.pos, ast.USub: operator.neg, ast.Invert: operator.invert}
|
||||
|
||||
def calculate(node):
|
||||
if isinstance(node, ast.Constant) and isinstance(node.value, int):
|
||||
return node.value
|
||||
if isinstance(node, ast.BinOp) and type(node.op) in binary:
|
||||
return binary[type(node.op)](calculate(node.left), calculate(node.right))
|
||||
if isinstance(node, ast.UnaryOp) and type(node.op) in unary:
|
||||
return unary[type(node.op)](calculate(node.operand))
|
||||
raise ValueError("unsupported specification expression")
|
||||
|
||||
return int(calculate(ast.parse(e, mode="eval").body))
|
||||
|
||||
|
||||
# --- constants: symbol table (section 3) ---
|
||||
|
||||
@@ -12,6 +12,12 @@ use filmcraft_time::Tick;
|
||||
|
||||
use filmcraft_text::{ParagraphStyle, TextStyle, layout, render};
|
||||
|
||||
/// Caption overlays are transient render resources, not source images. Keep one cue from
|
||||
/// materialising an unbounded mask or RGBA buffer.
|
||||
const MAX_OVERLAY_PIXELS: usize = 16 << 20;
|
||||
const MAX_CAPTION_SIZE_1080: f32 = 256.0;
|
||||
const MAX_OUTLINE_RADIUS: f32 = 64.0;
|
||||
|
||||
/// A rendered caption: premultiplied linear-light RGBA f32 pixels at `(x, y)` in the frame.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Overlay {
|
||||
@@ -110,7 +116,7 @@ pub fn render_caption(c: &Caption, style: &CaptionStyle, w: usize, h: usize) ->
|
||||
return None;
|
||||
}
|
||||
let scale = h as f32 / 1080.0;
|
||||
let px = (style.size * scale).max(4.0);
|
||||
let px = (style.size.clamp(4.0, MAX_CAPTION_SIZE_1080) * scale).clamp(4.0, h as f32);
|
||||
let max_w = w as f32 * 0.9;
|
||||
let ts = caption_style(&style.font, px);
|
||||
let mut lines: Vec<String> = Vec::new();
|
||||
@@ -124,7 +130,7 @@ pub fn render_caption(c: &Caption, style: &CaptionStyle, w: usize, h: usize) ->
|
||||
}
|
||||
let vm = filmcraft_text::fonts::face(filmcraft_text::resolve(&ts.family, &ts.style).face).metrics(px);
|
||||
let (asc, desc) = (vm.ascent, vm.descent);
|
||||
let lh = px * style.line_spacing.max(0.8);
|
||||
let lh = px * style.line_spacing.clamp(0.8, 4.0);
|
||||
let pad_x = (px * 0.3).round();
|
||||
let block_h = lh * lines.len() as f32;
|
||||
let (line_override, align_override) = vtt_overrides(&c.settings);
|
||||
@@ -148,15 +154,24 @@ pub fn render_caption(c: &Caption, style: &CaptionStyle, w: usize, h: usize) ->
|
||||
CaptionAlign::Center => (w as f32 - lw) / 2.0,
|
||||
})
|
||||
.collect();
|
||||
let outline = (style.outline * scale).max(0.0);
|
||||
let outline = (style.outline * scale).clamp(0.0, MAX_OUTLINE_RADIUS);
|
||||
let grow = outline.ceil() + 1.0;
|
||||
let bx0 = xs.iter().zip(&widths).map(|(x, _)| x - pad_x - grow).fold(f32::MAX, f32::min).floor();
|
||||
let bx1 = xs.iter().zip(&widths).map(|(x, lw)| x + lw + pad_x + grow).fold(f32::MIN, f32::max).ceil();
|
||||
let by0 = (top - grow).floor();
|
||||
let by1 = (top + block_h + grow).ceil();
|
||||
let (ox, oy) = (bx0 as i32, by0 as i32);
|
||||
let ow = (bx1 - bx0).max(1.0) as usize;
|
||||
let oh = (by1 - by0).max(1.0) as usize;
|
||||
// Text can be wider than the frame (an unbroken imported WebVTT word, for example). Raster
|
||||
// only the visible intersection; `draw` receives the original coordinates and clips into it.
|
||||
let (left, top) = (bx0.clamp(0.0, w as f32), by0.clamp(0.0, h as f32));
|
||||
let (right, bottom) = (bx1.clamp(0.0, w as f32), by1.clamp(0.0, h as f32));
|
||||
if right <= left || bottom <= top {
|
||||
return None;
|
||||
}
|
||||
let (ox, oy) = (left as i32, top as i32);
|
||||
let (ow, oh) = ((right - left) as usize, (bottom - top) as usize);
|
||||
if ow.checked_mul(oh).is_none_or(|pixels| pixels > MAX_OVERLAY_PIXELS) {
|
||||
return None;
|
||||
}
|
||||
let mut cover = vec![0.0f32; ow * oh];
|
||||
let mut bg = vec![0.0f32; ow * oh];
|
||||
for (i, line) in lines.iter().enumerate() {
|
||||
@@ -319,6 +334,21 @@ mod tests {
|
||||
assert!(canvas.chunks(4).any(|p| p[0] > 0.9));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overlay_is_clipped_to_the_frame_for_an_unbroken_word() {
|
||||
let o = render_caption(&cap(&"W".repeat(10_000)), &CaptionStyle::default(), 640, 360).unwrap();
|
||||
assert!(o.x >= 0 && o.y >= 0);
|
||||
assert!(o.w <= 640 && o.h <= 360, "{}x{}", o.w, o.h);
|
||||
assert!(o.w.checked_mul(o.h).unwrap() <= MAX_OVERLAY_PIXELS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_outline_is_bounded() {
|
||||
let st = CaptionStyle { outline: 1_000_000.0, ..Default::default() };
|
||||
let o = render_caption(&cap("Safe"), &st, 640, 360).unwrap();
|
||||
assert!(o.w <= 640 && o.h <= 360, "{}x{}", o.w, o.h);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outline_draws_around_text() {
|
||||
let st = CaptionStyle { background: false, outline: 4.0, outline_color: [255, 0, 0, 255], ..Default::default() };
|
||||
|
||||
@@ -344,6 +344,8 @@ struct Win {
|
||||
defined: bool,
|
||||
visible: bool,
|
||||
rows: Vec<String>,
|
||||
max_rows: usize,
|
||||
max_cols: usize,
|
||||
}
|
||||
|
||||
struct Dec708 {
|
||||
@@ -416,8 +418,9 @@ impl Dec708 {
|
||||
fn put(&mut self, c: char) {
|
||||
let w = self.w();
|
||||
match w.rows.last_mut() {
|
||||
Some(row) => row.push(c),
|
||||
None => w.rows.push(c.to_string()),
|
||||
Some(row) if row.chars().count() < w.max_cols => row.push(c),
|
||||
None if w.max_rows > 0 && w.max_cols > 0 => w.rows.push(c.to_string()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
fn bitmap(&mut self, m: u8, f: impl Fn(&mut Win)) {
|
||||
@@ -441,7 +444,8 @@ impl Dec708 {
|
||||
}
|
||||
}
|
||||
0x0c => self.w().rows = vec![String::new()],
|
||||
0x0d => self.w().rows.push(String::new()),
|
||||
0x0d if self.w().rows.len() < self.w().max_rows => self.w().rows.push(String::new()),
|
||||
0x0d => {}
|
||||
0x0e => {
|
||||
if let Some(r) = self.w().rows.last_mut() {
|
||||
r.clear();
|
||||
@@ -500,11 +504,24 @@ impl Dec708 {
|
||||
0x98..=0x9f => {
|
||||
let n = (c - 0x98) as usize;
|
||||
let vis = arg(i) & 0x20 != 0;
|
||||
let max_rows = (arg(i + 3) & 0x0f) as usize + 1;
|
||||
let max_cols = (arg(i + 4) & 0x3f) as usize + 1;
|
||||
let w = &mut self.win[n];
|
||||
if !w.defined {
|
||||
*w = Win { defined: true, visible: vis, rows: vec![String::new()] };
|
||||
*w = Win { defined: true, visible: vis, rows: vec![String::new()], max_rows, max_cols };
|
||||
} else {
|
||||
w.visible = vis;
|
||||
w.max_rows = max_rows;
|
||||
w.max_cols = max_cols;
|
||||
w.rows.truncate(max_rows);
|
||||
for row in &mut w.rows {
|
||||
if let Some((at, _)) = row.char_indices().nth(max_cols) {
|
||||
row.truncate(at);
|
||||
}
|
||||
}
|
||||
if w.rows.is_empty() {
|
||||
w.rows.push(String::new());
|
||||
}
|
||||
}
|
||||
self.cur = n;
|
||||
i += 6;
|
||||
@@ -699,4 +716,12 @@ mod tests {
|
||||
assert_eq!(got, vec![(60, 120, "Hello, world!"), (120, 200, "Second caption\nwith two lines"), (260, 300, "Café “q” ♪")]);
|
||||
assert!(parse("garbage").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decoder_keeps_708_text_within_declared_window_dimensions() {
|
||||
let mut decoder = Dec708::new();
|
||||
// Define window 0 with one row and one column, then try to overflow both.
|
||||
decoder.service_data(&[0x98, 0, 0, 0, 0, 0, 0, b'A', b'B', 0x0d, b'C']);
|
||||
assert_eq!(decoder.win[0].rows, ["A"]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -304,7 +304,12 @@ pub fn wrap_rows(text: &str) -> Vec<String> {
|
||||
for line in text.lines() {
|
||||
let mut cur = String::new();
|
||||
for word in line.split_whitespace() {
|
||||
let mut word = word.to_string();
|
||||
if rows.len() == 15 {
|
||||
return rows;
|
||||
}
|
||||
// SCC can emit at most 15 32-column rows. Do not repeatedly copy an unbounded word
|
||||
// only to discard all but that small prefix below.
|
||||
let mut word: String = word.chars().take(COLS * (15 - rows.len())).collect();
|
||||
loop {
|
||||
let need = if cur.is_empty() { word.chars().count() } else { cur.chars().count() + 1 + word.chars().count() };
|
||||
if need <= COLS {
|
||||
@@ -316,11 +321,17 @@ pub fn wrap_rows(text: &str) -> Vec<String> {
|
||||
}
|
||||
if !cur.is_empty() {
|
||||
rows.push(std::mem::take(&mut cur));
|
||||
if rows.len() == 15 {
|
||||
return rows;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let head: String = word.chars().take(COLS).collect();
|
||||
word = word.chars().skip(COLS).collect();
|
||||
rows.push(head);
|
||||
if rows.len() == 15 {
|
||||
return rows;
|
||||
}
|
||||
if word.is_empty() {
|
||||
break;
|
||||
}
|
||||
@@ -541,6 +552,13 @@ mod tests {
|
||||
assert_eq!(rows.join(" "), "the quick brown fox jumps over the lazy dog and keeps running far away");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrapping_discards_an_unrepresentable_word_without_quadratic_copying() {
|
||||
let rows = wrap_rows(&"x".repeat(100_000));
|
||||
assert_eq!(rows.len(), 15);
|
||||
assert!(rows.iter().all(|row| row.len() == COLS));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cue_at_zero_is_delayed_not_lost() {
|
||||
let doc = Document { cues: vec![cue(0, 60, "Start")], ..Default::default() };
|
||||
|
||||
@@ -212,9 +212,13 @@ fn encode_text(text: &str) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
let mut rest = text;
|
||||
while let Some(ch) = rest.chars().next() {
|
||||
if ch == '<'
|
||||
&& let Some(end) = rest.find('>')
|
||||
{
|
||||
let is_tag_start = ch == '<'
|
||||
&& rest
|
||||
.get(1..)
|
||||
.and_then(|tail| tail.strip_prefix('/').or(Some(tail)))
|
||||
.and_then(|name| name.as_bytes().first())
|
||||
.is_some_and(u8::is_ascii_alphabetic);
|
||||
if is_tag_start && let Some(end) = rest.find('>') {
|
||||
let tag = &rest[1..end];
|
||||
// Only a syntactically plausible tag is markup. Comparisons such as
|
||||
// "2 < 3 > 1" and "x < y > z" must not lose their middle text.
|
||||
@@ -232,6 +236,18 @@ fn encode_text(text: &str) -> Vec<u8> {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if is_tag_start && !rest.contains('>') {
|
||||
// This cannot become markup later. Encode the remaining literal text in one pass
|
||||
// instead of searching the shrinking suffix after every opening bracket.
|
||||
for ch in rest.chars() {
|
||||
if ch == '\n' {
|
||||
out.push(0x8a);
|
||||
} else {
|
||||
out.extend(encode_6937(ch).unwrap_or_else(|| vec![b'?']));
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
if ch == '\n' {
|
||||
out.push(0x8a);
|
||||
} else {
|
||||
@@ -474,6 +490,14 @@ mod tests {
|
||||
assert_eq!(encode_text("<i >Hi</i >"), vec![0x80, b'H', b'i', 0x81]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unterminated_markup_is_encoded_in_one_pass() {
|
||||
let text = format!("<i{}", "<".repeat(10_000));
|
||||
let encoded = encode_text(&text);
|
||||
assert_eq!(encoded.len(), text.len());
|
||||
assert_eq!(encoded[0], b'<');
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn programme_start_is_subtracted() {
|
||||
let doc = Document {
|
||||
|
||||
+133
-18
@@ -39,6 +39,67 @@ const NS_XML: &str = "http://www.w3.org/XML/1998/namespace";
|
||||
const IMSC1_TEXT: &str = "http://www.w3.org/ns/ttml/profile/imsc1/text";
|
||||
const DFXP_PROFILE: &str = "http://www.w3.org/ns/ttml/profile/dfxp-presentation";
|
||||
|
||||
/// TTML is normally only a few containers deep. Keep the XML parser and our recursive content
|
||||
/// walkers safely below a native-stack-sized nesting level for untrusted caption files.
|
||||
const MAX_XML_DEPTH: usize = 128;
|
||||
|
||||
/// Count XML element nesting without constructing an XML tree. This deliberately only decides
|
||||
/// where markup ends (respecting quoted attributes, comments, CDATA and processing instructions);
|
||||
/// `roxmltree` remains responsible for XML syntax and matching tags.
|
||||
fn check_xml_depth(text: &str) -> Result<()> {
|
||||
let bytes = text.as_bytes();
|
||||
let mut i = 0;
|
||||
let mut depth = 0usize;
|
||||
while let Some(relative) = text[i..].find('<') {
|
||||
let start = i + relative;
|
||||
let rest = &text[start..];
|
||||
if rest.starts_with("<!--") {
|
||||
let Some(end) = text[start + 4..].find("-->") else { return Ok(()) };
|
||||
i = start + 4 + end + 3;
|
||||
continue;
|
||||
}
|
||||
if rest.starts_with("<![CDATA[") {
|
||||
let Some(end) = text[start + 9..].find("]]>") else { return Ok(()) };
|
||||
i = start + 9 + end + 3;
|
||||
continue;
|
||||
}
|
||||
if rest.starts_with("<?") {
|
||||
let Some(end) = text[start + 2..].find("?>") else { return Ok(()) };
|
||||
i = start + 2 + end + 2;
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut j = start + 1;
|
||||
let mut quote = None;
|
||||
while j < bytes.len() {
|
||||
match (bytes[j], quote) {
|
||||
(b'\'' | b'\"', None) => quote = Some(bytes[j]),
|
||||
(b, Some(q)) if b == q => quote = None,
|
||||
(b'>', None) => break,
|
||||
_ => {}
|
||||
}
|
||||
j += 1;
|
||||
}
|
||||
if j == bytes.len() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if rest.starts_with("</") {
|
||||
depth = depth.checked_sub(1).ok_or_else(|| Error::Syntax { line: 1, msg: "unexpected XML closing tag".into() })?;
|
||||
} else if !rest.starts_with("<!") {
|
||||
let self_closing = bytes[start + 1..j].iter().rev().find(|b| !b.is_ascii_whitespace()) == Some(&b'/');
|
||||
if !self_closing {
|
||||
depth += 1;
|
||||
if depth > MAX_XML_DEPTH {
|
||||
return Err(Error::Syntax { line: 1, msg: format!("TTML XML nesting exceeds {MAX_XML_DEPTH} elements") });
|
||||
}
|
||||
}
|
||||
}
|
||||
i = j + 1;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn esc(s: &str) -> String {
|
||||
let mut o = String::with_capacity(s.len());
|
||||
for c in s.chars() {
|
||||
@@ -70,7 +131,23 @@ fn time_expr(t: Tick, rate: Option<FrameRate>) -> String {
|
||||
/// Caption text (`\n` lines, `<i>`/`<b>`/`<u>` tags) → TTML inline content.
|
||||
fn inline(text: &str) -> String {
|
||||
let mut out = String::new();
|
||||
let mut open: Vec<&str> = Vec::new();
|
||||
// Keep the logical style state separate from what has been written. There are only three
|
||||
// supported styles, so duplicate or crossing input tags cannot grow an unbounded span stack.
|
||||
let mut active: Vec<&'static str> = Vec::new();
|
||||
let mut emitted: Vec<&'static str> = Vec::new();
|
||||
let sync = |out: &mut String, active: &[&'static str], emitted: &mut Vec<&'static str>| {
|
||||
if *emitted != active {
|
||||
for _ in emitted.drain(..) {
|
||||
out.push_str("</span>");
|
||||
}
|
||||
for attr in active {
|
||||
out.push_str("<span ");
|
||||
out.push_str(attr);
|
||||
out.push('>');
|
||||
emitted.push(attr);
|
||||
}
|
||||
}
|
||||
};
|
||||
let mut rest = text;
|
||||
while !rest.is_empty() {
|
||||
if let Some(after) = rest.strip_prefix('<')
|
||||
@@ -89,26 +166,16 @@ fn inline(text: &str) -> String {
|
||||
};
|
||||
if let Some(a) = attr {
|
||||
if closing {
|
||||
if let Some(pos) = open.iter().rposition(|x| *x == a) {
|
||||
// close spans opened after it, then it, then reopen the inner ones
|
||||
let inner: Vec<&str> = open.drain(pos..).skip(1).collect();
|
||||
for _ in 0..=inner.len() {
|
||||
out.push_str("</span>");
|
||||
}
|
||||
for x in inner {
|
||||
out.push_str(&format!("<span {x}>"));
|
||||
open.push(x);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
out.push_str(&format!("<span {a}>"));
|
||||
open.push(a);
|
||||
active.retain(|style| *style != a);
|
||||
} else if !active.contains(&a) {
|
||||
active.push(a);
|
||||
}
|
||||
}
|
||||
rest = &after[end + 1..];
|
||||
continue;
|
||||
}
|
||||
let Some(ch) = rest.chars().next() else { break };
|
||||
sync(&mut out, &active, &mut emitted);
|
||||
if ch == '\n' {
|
||||
out.push_str("<br/>");
|
||||
} else {
|
||||
@@ -116,7 +183,7 @@ fn inline(text: &str) -> String {
|
||||
}
|
||||
rest = &rest[ch.len_utf8()..];
|
||||
}
|
||||
for _ in open {
|
||||
for _ in emitted {
|
||||
out.push_str("</span>");
|
||||
}
|
||||
out
|
||||
@@ -277,8 +344,42 @@ fn speaker_names(root: roxmltree::Node) -> HashMap<String, String> {
|
||||
fn speaker(n: roxmltree::Node, names: &HashMap<String, String>) -> Option<String> {
|
||||
let refs = n.attributes().find(|a| a.name() == "agent" && metadata_namespace(a.namespace())).map(|a| a.value()).or_else(|| n.attribute("agent"));
|
||||
refs.map(|refs| {
|
||||
// The project stores one speaker label; multiple agents become an ordered display label.
|
||||
refs.split_whitespace().map(|id| names.get(id).cloned().unwrap_or_else(|| id.trim_start_matches('_').replace('_', " "))).collect::<Vec<_>>().join(", ")
|
||||
// The project stores one speaker label. Keep document-defined long names and repeated
|
||||
// references from multiplying into an unbounded per-cue allocation.
|
||||
const MAX_REFS: usize = 64;
|
||||
const MAX_LABEL_BYTES: usize = 4096;
|
||||
let mut out = String::new();
|
||||
for id in refs.split_whitespace().take(MAX_REFS) {
|
||||
if !out.is_empty() {
|
||||
if out.len() + 2 > MAX_LABEL_BYTES {
|
||||
break;
|
||||
}
|
||||
out.push_str(", ");
|
||||
}
|
||||
let name = names.get(id).map(String::as_str).unwrap_or(id);
|
||||
if names.contains_key(id) {
|
||||
let room = MAX_LABEL_BYTES.saturating_sub(out.len());
|
||||
let mut end = 0;
|
||||
for (i, ch) in name.char_indices() {
|
||||
if i + ch.len_utf8() > room {
|
||||
break;
|
||||
}
|
||||
end = i + ch.len_utf8();
|
||||
}
|
||||
out.push_str(&name[..end]);
|
||||
} else {
|
||||
for ch in id.trim_start_matches('_').chars() {
|
||||
if out.len() + ch.len_utf8() > MAX_LABEL_BYTES {
|
||||
break;
|
||||
}
|
||||
out.push(if ch == '_' { ' ' } else { ch });
|
||||
}
|
||||
}
|
||||
if out.len() == MAX_LABEL_BYTES {
|
||||
break;
|
||||
}
|
||||
}
|
||||
out
|
||||
})
|
||||
}
|
||||
|
||||
@@ -482,6 +583,7 @@ fn clean(s: &str) -> String {
|
||||
}
|
||||
|
||||
pub fn parse(text: &str) -> Result<Document> {
|
||||
check_xml_depth(text)?;
|
||||
let xml = roxmltree::Document::parse(text).map_err(|_| Error::NotFormat("TTML"))?;
|
||||
let root = xml.root_element();
|
||||
if root.tag_name().name() != "tt" {
|
||||
@@ -649,6 +751,12 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deeply_nested_ttml_is_rejected_before_xml_parsing() {
|
||||
let xml = format!("<tt>{}<p begin=\"0s\" end=\"1s\">caption</p>{}</tt>", "<div>".repeat(MAX_XML_DEPTH), "</div>".repeat(MAX_XML_DEPTH));
|
||||
assert!(matches!(parse(&xml), Err(Error::Syntax { msg, .. }) if msg.contains("nesting exceeds")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_timing_parameters_keep_defaults() {
|
||||
for (params, begin, end) in [
|
||||
@@ -816,4 +924,11 @@ mod tests {
|
||||
let d = write(&doc, Flavor::Dfxp, Some(r), "en");
|
||||
assert!(d.contains(DFXP_PROFILE) && d.contains("begin=\"00:00:01.001\""), "{d}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_and_crossing_spans_have_bounded_output() {
|
||||
let text = format!("{}x{}", "<i>".repeat(1_000), "</i>".repeat(1_000));
|
||||
let out = inline(&text);
|
||||
assert_eq!(out, "<span tts:fontStyle=\"italic\">x</span>");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,7 +83,7 @@ pub fn parse(text: &str) -> Result<Document> {
|
||||
doc.blocks.push(b.join("\n"));
|
||||
continue;
|
||||
}
|
||||
if !seen_cue && !first.contains("-->") && (first == "STYLE" || first == "REGION") {
|
||||
if !seen_cue && !b.iter().any(|l| l.contains("-->")) && (first == "STYLE" || first == "REGION") {
|
||||
doc.blocks.push(b.join("\n"));
|
||||
continue;
|
||||
}
|
||||
@@ -152,6 +152,15 @@ mod tests {
|
||||
|
||||
const SAMPLE: &str = "WEBVTT - with header\nKind: captions\n\nSTYLE\n::cue { color: yellow }\n\nNOTE a comment\nspanning lines\n\nintro\n00:01.000 --> 00:00:03.500 line:90% align:start\n<v.loud Ann Smith>Hello there\nsecond line\n\n00:00:04.000 --> 00:00:05.000\n<i>plain</i> & tags\n";
|
||||
|
||||
#[test]
|
||||
fn style_region_identifier_cue_is_kept() {
|
||||
for id in ["STYLE", "REGION"] {
|
||||
let d = parse(&format!("WEBVTT\n\n{id}\n00:00:01.000 --> 00:00:02.000\nHi\n")).unwrap();
|
||||
assert_eq!(d.cues.len(), 1, "{id}");
|
||||
assert!(d.blocks.is_empty(), "{id}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_sample() {
|
||||
let d = parse(SAMPLE).unwrap();
|
||||
|
||||
@@ -154,6 +154,8 @@ fn random_access(data: &[u8], hevc: bool) -> bool {
|
||||
|
||||
/// The packets of a framed audio stream: every frame header found in the stream of chunks, and
|
||||
/// the samples each frame decodes to. Bytes that start no frame are skipped (resynchronisation).
|
||||
const MAX_FRAMED_AUDIO_SCAN_BYTES: u64 = 256 << 20;
|
||||
|
||||
fn split_frames(
|
||||
file: &AviFile,
|
||||
bytes: &crate::Src,
|
||||
@@ -161,12 +163,18 @@ fn split_frames(
|
||||
ts: &filmcraft_mpegts::Codec,
|
||||
) -> (Vec<Piece>, Vec<usize>, Vec<u32>, Option<filmcraft_mpegts::FrameInfo>) {
|
||||
let Some(st) = file.streams.get(s) else { return Default::default() };
|
||||
// AVI indexes can reference the same physical chunk repeatedly. Keep header scanning bounded
|
||||
// by both the source itself and a practical absolute limit before issuing those repeated reads.
|
||||
let budget = bytes.0.len().saturating_mul(4).min(MAX_FRAMED_AUDIO_SCAN_BYTES);
|
||||
// chunk start positions in the concatenated stream
|
||||
let mut at = Vec::with_capacity(st.chunks.len());
|
||||
let mut total = 0usize;
|
||||
for c in &st.chunks {
|
||||
at.push(total);
|
||||
total = total.saturating_add(c.size as usize);
|
||||
if total as u64 > budget {
|
||||
return Default::default();
|
||||
}
|
||||
}
|
||||
let locate = |pos: usize| -> Option<(usize, usize)> {
|
||||
let k = at.partition_point(|&a| a <= pos).checked_sub(1)?;
|
||||
|
||||
@@ -446,11 +446,13 @@ pub struct GopCache {
|
||||
budget: usize,
|
||||
}
|
||||
|
||||
/// The cache always has room for this many frames, whatever their size: a frame-threaded decoder
|
||||
/// runs up to ~2x its thread count ahead of the frame it returns, and playback prefetches ahead
|
||||
/// of the playhead, so a byte budget alone would evict 4K frames before they are shown and force
|
||||
/// a re-decode from the keyframe.
|
||||
/// The cache reserves room for this many small frames: a frame-threaded decoder runs up to ~2x
|
||||
/// its thread count ahead of the frame it returns, and playback prefetches ahead of the playhead.
|
||||
const MIN_FRAMES: usize = 64;
|
||||
/// A source cache never retains more decoded pixels than this, regardless of frame dimensions.
|
||||
/// In particular, untrusted VP9 `show_existing_frame` samples must not turn the small-frame
|
||||
/// reserve above into an unbounded number of full-resolution copies.
|
||||
const FRAME_CACHE_CEILING: usize = 384 << 20;
|
||||
|
||||
/// A running decoder up to this many samples before the wanted sample's sync sample keeps going
|
||||
/// rather than restarting at the sync sample.
|
||||
@@ -533,7 +535,7 @@ impl GopCache {
|
||||
pool: pool.clone(),
|
||||
});
|
||||
pool.register(&shared);
|
||||
Self { shared, explicit_color, rotation: 0, crop: None, budget: 384 << 20 }
|
||||
Self { shared, explicit_color, rotation: 0, crop: None, budget: FRAME_CACHE_CEILING }
|
||||
}
|
||||
|
||||
/// Turn every decoded frame clockwise by `quarter_turns` × 90° (the container's display
|
||||
@@ -568,6 +570,12 @@ impl GopCache {
|
||||
self.store_finished(st, pts, self.finish(f), draft);
|
||||
}
|
||||
|
||||
/// The small-frame reserve may use the configured cache budget, but may never enlarge it.
|
||||
fn cache_budget(&self, frame_bytes: usize) -> usize {
|
||||
let reserve = MIN_FRAMES.saturating_mul(frame_bytes).min(FRAME_CACHE_CEILING);
|
||||
self.budget.min(FRAME_CACHE_CEILING).max(reserve)
|
||||
}
|
||||
|
||||
/// Cache a frame [`Self::finish`] has been applied to.
|
||||
fn store_finished(&self, st: &mut State, pts: i64, f: VideoFrame, draft: bool) {
|
||||
st.last_used = Instant::now();
|
||||
@@ -577,7 +585,7 @@ impl GopCache {
|
||||
} else {
|
||||
st.drafts.remove(&pts);
|
||||
}
|
||||
let budget = self.budget.max(MIN_FRAMES * f.byte_size());
|
||||
let budget = self.cache_budget(f.byte_size());
|
||||
let before = st.bytes;
|
||||
st.bytes += f.byte_size();
|
||||
if let Some(old) = st.frames.insert(pts, Arc::new(f)) {
|
||||
@@ -1449,6 +1457,17 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn large_frames_cannot_enlarge_the_gop_cache_ceiling() {
|
||||
let mut c = GopCache::new(None);
|
||||
c.budget = usize::MAX;
|
||||
assert_eq!(c.cache_budget(1), FRAME_CACHE_CEILING);
|
||||
assert_eq!(c.cache_budget(FRAME_CACHE_CEILING), FRAME_CACHE_CEILING);
|
||||
c.budget = 0;
|
||||
assert_eq!(c.cache_budget(4), MIN_FRAMES * 4, "small frames retain the playback reserve");
|
||||
assert_eq!(c.cache_budget(FRAME_CACHE_CEILING), FRAME_CACHE_CEILING, "large frames are capped instead of reserving 64 copies");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn evicted_frames_are_decoded_into_again() {
|
||||
let s = Big(200);
|
||||
|
||||
+10
-10
@@ -1,14 +1,14 @@
|
||||
//! The raw image data of a DNG: finding the main raw IFD, and reading its strips or tiles
|
||||
//! (uncompressed or lossless JPEG) into one array of 16-bit samples.
|
||||
|
||||
#[cfg(feature = "threads")]
|
||||
use rayon::prelude::*;
|
||||
|
||||
use crate::tiff::{Ifd, Tiff, tags};
|
||||
use crate::{Error, Result, ljpeg};
|
||||
|
||||
/// Largest raw image decoded, in samples (width × height × samples per pixel).
|
||||
pub const MAX_SAMPLES: usize = 1 << 27;
|
||||
/// Decoded tiles are padded to tile boundaries, so their aggregate can exceed the useful image.
|
||||
/// Keep that temporary work within the same bound as a complete decoded raw frame.
|
||||
const MAX_DECODED_TILE_SAMPLES: usize = MAX_SAMPLES;
|
||||
|
||||
/// Photometric interpretations of DNG raw data.
|
||||
pub const PHOTOMETRIC_CFA: u64 = 32803;
|
||||
@@ -225,23 +225,23 @@ pub fn read(t: &Tiff, ifd: &Ifd) -> Result<RawImage> {
|
||||
if total / 8 > coded {
|
||||
return Err(Error::Truncated);
|
||||
}
|
||||
let mut decoded_samples = 0usize;
|
||||
for c in &chunks {
|
||||
let n = c.w.checked_mul(c.h).and_then(|v| v.checked_mul(spp)).ok_or(Error::TooLarge)?;
|
||||
if n > MAX_SAMPLES {
|
||||
decoded_samples = decoded_samples.checked_add(n).ok_or(Error::TooLarge)?;
|
||||
if n > MAX_SAMPLES || decoded_samples > MAX_DECODED_TILE_SAMPLES {
|
||||
return Err(Error::TooLarge);
|
||||
}
|
||||
if n / 8 > c.bytes.len() {
|
||||
return Err(Error::Truncated);
|
||||
}
|
||||
}
|
||||
#[cfg(feature = "threads")]
|
||||
let decoded: Vec<Result<Vec<u16>>> = chunks.par_iter().map(|c| decode_chunk(c, &l, t.big_endian)).collect();
|
||||
#[cfg(not(feature = "threads"))]
|
||||
let decoded: Vec<Result<Vec<u16>>> = chunks.iter().map(|c| decode_chunk(c, &l, t.big_endian)).collect();
|
||||
let mut data = vec![0u16; total];
|
||||
let row_len = l.width * spp;
|
||||
for (c, d) in chunks.iter().zip(decoded) {
|
||||
let d = d?;
|
||||
for c in &chunks {
|
||||
// Copy each padded tile immediately. Retaining every decoded tile until assembly lets
|
||||
// repeated ranges multiply memory beyond the final raw frame's bounded allocation.
|
||||
let d = decode_chunk(c, &l, t.big_endian)?;
|
||||
let cw = c.w.min(l.width - c.x);
|
||||
let ch = c.h.min(l.height - c.y);
|
||||
for r in 0..ch {
|
||||
|
||||
+11
-3
@@ -5,12 +5,16 @@
|
||||
//! outside the file is an error when it is read, the walk visits at most [`MAX_IFDS`] directories,
|
||||
//! never the same one twice (cyclic chains end), and goes at most [`MAX_DEPTH`] SubIFD levels deep.
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use crate::{Error, Result};
|
||||
|
||||
/// Most IFDs one file may have (main chain and SubIFDs together).
|
||||
pub const MAX_IFDS: usize = 64;
|
||||
/// Deepest SubIFD nesting followed.
|
||||
pub const MAX_DEPTH: usize = 4;
|
||||
/// Invalid SubIFD edges count too: otherwise a file can make failed offset probes quadratic.
|
||||
const MAX_IFD_ATTEMPTS: usize = MAX_IFDS * 16;
|
||||
|
||||
/// TIFF field types (TIFF 6.0 §2, plus type 13 IFD from TIFF Technical Note 1).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
@@ -156,18 +160,22 @@ impl<'a> Tiff<'a> {
|
||||
/// each. Bounded by [`MAX_IFDS`] and [`MAX_DEPTH`]; a directory reached twice ends that branch.
|
||||
pub fn all_ifds(&self, first: u64) -> Result<Vec<Ifd>> {
|
||||
let mut out: Vec<Ifd> = Vec::new();
|
||||
let mut seen: Vec<u64> = Vec::new();
|
||||
let mut seen = HashSet::new();
|
||||
let mut attempts = 0usize;
|
||||
let mut stack: Vec<(u64, usize)> = vec![(first, 0)];
|
||||
while let Some((off, depth)) = stack.pop() {
|
||||
let mut next = off;
|
||||
while next != 0 {
|
||||
if seen.contains(&next) {
|
||||
if !seen.insert(next) {
|
||||
break;
|
||||
}
|
||||
attempts += 1;
|
||||
if attempts > MAX_IFD_ATTEMPTS {
|
||||
return Err(Error::Malformed("too many IFD offsets"));
|
||||
}
|
||||
if out.len() >= MAX_IFDS {
|
||||
return Err(Error::Malformed("too many IFDs"));
|
||||
}
|
||||
seen.push(next);
|
||||
let (ifd, after) = match self.read_ifd(next, depth) {
|
||||
Ok(x) => x,
|
||||
// a broken directory ends the chain; the first one must be readable
|
||||
|
||||
@@ -215,6 +215,67 @@ pub fn move_captions(seq: &mut Sequence, ids: &[ClipId], delta: Tick) -> Result<
|
||||
Ok(d)
|
||||
}
|
||||
|
||||
/// Place copies of `captions` on `track`, overwriting what lies under them (captions there are cut
|
||||
/// back, split or removed), as Paste and Option-drag do. The copies keep their text, speaker and
|
||||
/// settings at their own `start`/`duration`, and get new ids (returned in time order).
|
||||
pub fn overwrite_captions(seq: &mut Sequence, track: TrackId, captions: &[Caption], ctx: &mut EditCtx) -> Result<Vec<ClipId>> {
|
||||
let min = ctx.min_duration;
|
||||
let mut placed: Vec<Caption> = captions.iter().filter(|c| c.duration >= min).cloned().collect();
|
||||
if placed.is_empty() {
|
||||
return Err(EditError::Nothing);
|
||||
}
|
||||
placed.sort_by_key(|c| c.start);
|
||||
if placed.first().is_some_and(|c| c.start < Tick::ZERO) {
|
||||
return Err(EditError::Other("captions cannot start before the sequence".into()));
|
||||
}
|
||||
if placed.windows(2).any(|w| w[0].end() > w[1].start) {
|
||||
return Err(EditError::Other("captions overlap".into()));
|
||||
}
|
||||
let tr = track_mut(seq, track)?;
|
||||
for c in &mut placed {
|
||||
c.id = ClipId(ctx.alloc());
|
||||
c.cue_id = None;
|
||||
}
|
||||
for c in &placed {
|
||||
clear_range(tr, c.start, c.end(), min, ctx);
|
||||
}
|
||||
let ids = placed.iter().map(|c| c.id).collect();
|
||||
tr.captions.extend(placed);
|
||||
tr.sort();
|
||||
Ok(ids)
|
||||
}
|
||||
|
||||
/// Clear `a..b` on a caption track without closing the gap: captions inside go, captions across an
|
||||
/// edge are cut back, a caption across the whole range is split. Pieces shorter than `min` go.
|
||||
fn clear_range(tr: &mut CaptionTrack, a: Tick, b: Tick, min: Tick, ctx: &mut EditCtx) {
|
||||
let mut out = Vec::with_capacity(tr.captions.len() + 1);
|
||||
for c in tr.captions.drain(..) {
|
||||
let (s, e) = (c.start, c.end());
|
||||
if e <= a || s >= b {
|
||||
out.push(c);
|
||||
continue;
|
||||
}
|
||||
let mut kept_left = false;
|
||||
if s < a && a - s >= min {
|
||||
let mut l = c.clone();
|
||||
l.duration = a - s;
|
||||
out.push(l);
|
||||
kept_left = true;
|
||||
}
|
||||
if e > b && e - b >= min {
|
||||
let mut r = c;
|
||||
if kept_left {
|
||||
r.id = ClipId(ctx.alloc());
|
||||
r.cue_id = None;
|
||||
}
|
||||
r.start = b;
|
||||
r.duration = e - b;
|
||||
out.push(r);
|
||||
}
|
||||
}
|
||||
tr.captions = out;
|
||||
}
|
||||
|
||||
/// Delete captions. With `ripple`, later captions on the same track move left to close each gap.
|
||||
pub fn delete_captions(seq: &mut Sequence, ids: &[ClipId], ripple: bool) -> Result<usize> {
|
||||
let mut n = 0;
|
||||
@@ -385,6 +446,40 @@ mod tests {
|
||||
assert_eq!(spans(&seq, tid), vec![(0, 40, "a".into()), (40, 80, "c".into())]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overwrite_cuts_splits_and_removes_what_is_under() {
|
||||
let (mut seq, tid) = seq_with(&[(0, 100, "a"), (100, 50, "b"), (200, 100, "c")]);
|
||||
let mut n = 1;
|
||||
let copy = |s: i64, d: i64, t: &str| Caption {
|
||||
id: ClipId(1),
|
||||
start: Tick(s),
|
||||
duration: Tick(d),
|
||||
text: t.into(),
|
||||
speaker: None,
|
||||
cue_id: Some("x".into()),
|
||||
settings: String::new(),
|
||||
};
|
||||
// across a's tail and all of b; then inside c (c is split around it)
|
||||
let ids = overwrite_captions(&mut seq, tid, &[copy(60, 95, "x"), copy(230, 40, "y")], &mut ctx(&mut n)).unwrap();
|
||||
assert_eq!(ids.len(), 2);
|
||||
assert_eq!(spans(&seq, tid), vec![(0, 60, "a".into()), (60, 155, "x".into()), (200, 230, "c".into()), (230, 270, "y".into()), (270, 300, "c".into())]);
|
||||
let tr = seq.caption_track(tid).unwrap();
|
||||
assert!(tr.captions.iter().all(|c| c.cue_id.is_none()));
|
||||
let seen: std::collections::HashSet<u64> = tr.captions.iter().map(|c| c.id.0).collect();
|
||||
assert_eq!(seen.len(), 5, "every piece has its own id");
|
||||
assert!(seq.check().is_ok());
|
||||
// a sliver under one frame is dropped, not kept
|
||||
let (mut seq, tid) = seq_with(&[(0, 100, "a")]);
|
||||
overwrite_captions(&mut seq, tid, &[copy(5, 90, "x")], &mut ctx(&mut n)).unwrap();
|
||||
assert_eq!(spans(&seq, tid), vec![(5, 95, "x".into())]);
|
||||
// hostile input: before zero, overlapping copies, nothing
|
||||
assert!(overwrite_captions(&mut seq, tid, &[copy(-5, 50, "x")], &mut ctx(&mut n)).is_err());
|
||||
assert!(overwrite_captions(&mut seq, tid, &[copy(0, 50, "x"), copy(40, 50, "y")], &mut ctx(&mut n)).is_err());
|
||||
assert_eq!(overwrite_captions(&mut seq, tid, &[], &mut ctx(&mut n)), Err(EditError::Nothing));
|
||||
seq.caption_track_mut(tid).unwrap().locked = true;
|
||||
assert_eq!(overwrite_captions(&mut seq, tid, &[copy(0, 50, "x")], &mut ctx(&mut n)), Err(EditError::Locked));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locked_refuses() {
|
||||
let (mut seq, tid) = seq_with(&[(0, 100, "a")]);
|
||||
|
||||
+70
-10
@@ -26,7 +26,7 @@ pub mod transitions;
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use filmcraft_project::{ClipId, ItemId, Sequence, Track, TrackId, TrackItem, Transition, TransitionId};
|
||||
use filmcraft_project::{ClipId, ItemId, Sequence, Track, TrackId, TrackItem, Transition, TransitionAlign, TransitionId};
|
||||
use filmcraft_time::{Tick, TimeRange};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
||||
@@ -196,24 +196,59 @@ fn transition_anchor(track: &Track, tr: &Transition) -> Option<Tick> {
|
||||
}
|
||||
|
||||
/// Keep transitions on their edit points after an edit moved cuts (ripple trim, roll, slide, ripple
|
||||
/// speed change): each transition shifts by as much as its anchor clip edge moved.
|
||||
/// speed change): each transition shifts by as much as its anchor clip edge moved. A transition
|
||||
/// between two clips stays within them, whether its cut moved or one of the clips got shorter
|
||||
/// ([`keep_within_clips`]).
|
||||
fn transitions_follow_cuts(before: &Sequence, after: &mut Sequence) {
|
||||
let frame = after.settings.frame_rate.frame_duration();
|
||||
for track in after.all_tracks_mut() {
|
||||
let Some(old) = before.track(track.id) else { continue };
|
||||
let shifts: Vec<Tick> = track
|
||||
let shifts: Vec<(Tick, Option<(Tick, Tick, Tick)>)> = track
|
||||
.transitions
|
||||
.iter()
|
||||
.map(|tr| match (transition_anchor(old, tr), transition_anchor(track, tr)) {
|
||||
(Some(a), Some(b)) => b - a,
|
||||
_ => Tick::ZERO,
|
||||
.map(|tr| {
|
||||
let d = match (transition_anchor(old, tr), transition_anchor(track, tr)) {
|
||||
(Some(a), Some(b)) => b - a,
|
||||
_ => Tick::ZERO,
|
||||
};
|
||||
(d, clips_span(track, tr))
|
||||
})
|
||||
.collect();
|
||||
for (tr, d) in track.transitions.iter_mut().zip(shifts) {
|
||||
for (tr, (d, within)) in track.transitions.iter_mut().zip(shifts) {
|
||||
tr.start += d;
|
||||
if let Some(span) = within {
|
||||
keep_within_clips(tr, span, frame);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The two clips of a transition between clips as (first clip's start, cut, second clip's end),
|
||||
/// while they still overlap the cut's span.
|
||||
fn clips_span(track: &Track, tr: &Transition) -> Option<(Tick, Tick, Tick)> {
|
||||
match (tr.from.and_then(|c| track.item(c)), tr.to.and_then(|c| track.item(c))) {
|
||||
(Some(f), Some(t)) if f.start < t.end() => Some((f.start, t.start, t.end())),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Move a transition that reaches past its two clips (`span`, from [`clips_span`]) back inside
|
||||
/// them, shortened when the clips are now shorter than it. Its stored alignment follows where that
|
||||
/// leaves it on the cut, as `transitions::set_span` stores it (interchange): Custom Start has no
|
||||
/// variant.
|
||||
fn keep_within_clips(tr: &mut Transition, (lo, cut, hi): (Tick, Tick, Tick), frame: Tick) {
|
||||
if tr.start >= lo && tr.end() <= hi {
|
||||
return;
|
||||
}
|
||||
tr.duration = tr.duration.min(hi - lo);
|
||||
tr.start = tr.start.clamp(lo, hi - tr.duration);
|
||||
tr.align = match transitions::alignment(tr, cut, frame) {
|
||||
transitions::Alignment::Start => TransitionAlign::StartAtCut,
|
||||
transitions::Alignment::End => TransitionAlign::EndAtCut,
|
||||
transitions::Alignment::Center | transitions::Alignment::Custom => TransitionAlign::CenterAtCut,
|
||||
};
|
||||
}
|
||||
|
||||
/// Keep a clip's one-sided transitions (a fade in from nothing, a fade out to nothing) on its edges
|
||||
/// after a regular trim moved them, no longer than the clip (#374). Transitions between two clips
|
||||
/// are left alone: a regular trim cannot move their cut.
|
||||
@@ -234,6 +269,18 @@ fn fades_follow_edges(track: &mut Track, clip: ClipId) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep each transition between `clip` and its neighbor within the two clips after a regular trim
|
||||
/// or a non-ripple speed change made `clip` shorter ([`keep_within_clips`]).
|
||||
fn transitions_stay_within_clips(track: &mut Track, clip: ClipId, frame: Tick) {
|
||||
let spans: Vec<Option<(Tick, Tick, Tick)>> =
|
||||
track.transitions.iter().map(|tr| clips_span(track, tr).filter(|_| tr.from == Some(clip) || tr.to == Some(clip))).collect();
|
||||
for (tr, span) in track.transitions.iter_mut().zip(spans) {
|
||||
if let Some(span) = span {
|
||||
keep_within_clips(tr, span, frame);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn place(track: &mut Track, item: TrackItem) {
|
||||
let idx = track.items.partition_point(|i| i.start <= item.start);
|
||||
track.items.insert(idx, item);
|
||||
@@ -765,8 +812,12 @@ pub fn trim(seq: &mut Sequence, clip: ClipId, edge: Edge, mode: TrimMode, delta:
|
||||
}
|
||||
}
|
||||
transitions_follow_cuts(seq, &mut work);
|
||||
} else if let Ok(track) = track_mut(&mut work, tid) {
|
||||
fades_follow_edges(track, clip);
|
||||
} else {
|
||||
let frame = work.settings.frame_rate.frame_duration();
|
||||
if let Ok(track) = track_mut(&mut work, tid) {
|
||||
fades_follow_edges(track, clip);
|
||||
transitions_stay_within_clips(track, clip, frame);
|
||||
}
|
||||
}
|
||||
work.check().map_err(EditError::Other)?;
|
||||
*seq = work;
|
||||
@@ -895,6 +946,10 @@ pub fn roll(seq: &mut Sequence, left: ClipId, right: ClipId, delta: Tick, ctx: &
|
||||
let mut d = delta;
|
||||
// left out-point limits
|
||||
d = d.max(-(l.duration - ctx.min_duration)).min(r.duration - ctx.min_duration);
|
||||
// a transition on this cut moves with it and stays within the two clips
|
||||
for x in seq.all_tracks().flat_map(|t| &t.transitions).filter(|x| x.from == Some(left) && x.to == Some(right)) {
|
||||
d = d.max((l.start - x.start).min(Tick::ZERO)).min((r.end() - x.end()).max(Tick::ZERO));
|
||||
}
|
||||
// a reversed clip consumes media from the opposite end, so its media limit is the other one
|
||||
if l.reverse {
|
||||
d = d.min(Tick((head(ctx, l).0 as f64 / l.speed.abs()).floor() as i64));
|
||||
@@ -1130,7 +1185,12 @@ pub fn set_speed_group(seq: &mut Sequence, clips: &[ClipId], speed: f64, reverse
|
||||
it.speed = speed;
|
||||
it.reverse = reverse;
|
||||
it.duration = new_dur;
|
||||
fades_follow_edges(work.track_mut(tid).ok_or(EditError::NoTrack(tid))?, *clip);
|
||||
let frame = work.settings.frame_rate.frame_duration();
|
||||
let track = work.track_mut(tid).ok_or(EditError::NoTrack(tid))?;
|
||||
fades_follow_edges(track, *clip);
|
||||
if !ripple {
|
||||
transitions_stay_within_clips(track, *clip, frame);
|
||||
}
|
||||
origins.push((tid, old_end, delta));
|
||||
}
|
||||
let main_from = origins.first().map(|o| o.1).unwrap_or_default();
|
||||
|
||||
@@ -527,6 +527,69 @@ fn fades_follow_a_non_ripple_speed_change() {
|
||||
fx.seq.check().unwrap();
|
||||
}
|
||||
|
||||
/// A regular trim or a non-ripple speed change that shortens one of the two clips of a transition
|
||||
/// keeps the transition within them, and its stored alignment follows where it now sits on the cut.
|
||||
#[test]
|
||||
fn a_transition_stays_within_its_clips_after_a_regular_trim_or_speed_change() {
|
||||
for (case, expected) in [("Out trim of the second clip", 7), ("In trim of the first clip", 9), ("speed change of the second clip", 7)] {
|
||||
let mut fx = Fx::new();
|
||||
let v1 = fx.v(0);
|
||||
let a = fx.put(v1, 0, 10, 10);
|
||||
let b = fx.put(v1, 10, 10, 10);
|
||||
fx.seq.track_mut(v1).unwrap().transitions = vec![Transition {
|
||||
id: TransitionId(1),
|
||||
effect: filmcraft_project::find_effect("cross_dissolve").unwrap().instance(),
|
||||
start: f(8),
|
||||
duration: f(4),
|
||||
from: Some(a),
|
||||
to: Some(b),
|
||||
align: filmcraft_project::TransitionAlign::EndAtCut,
|
||||
reverse: false,
|
||||
}];
|
||||
let mut n = fx.next;
|
||||
match case {
|
||||
"Out trim of the second clip" => {
|
||||
trim(&mut fx.seq, b, Edge::Out, TrimMode::Regular, -f(9), &mut Fx::ctx(&mut n)).unwrap();
|
||||
}
|
||||
"In trim of the first clip" => {
|
||||
trim(&mut fx.seq, a, Edge::In, TrimMode::Regular, f(9), &mut Fx::ctx(&mut n)).unwrap();
|
||||
}
|
||||
_ => set_speed(&mut fx.seq, b, 10.0, false, false, &mut Fx::ctx(&mut n)).unwrap(),
|
||||
}
|
||||
let tr = &fx.seq.track(v1).unwrap().transitions[0];
|
||||
assert_eq!((tr.start, tr.duration), (f(expected), f(4)), "{case}");
|
||||
assert_eq!(tr.align, filmcraft_project::TransitionAlign::CenterAtCut, "{case}");
|
||||
fx.seq.check().unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
/// A regular trim that leaves the two clips of a transition shorter than it, together, shortens
|
||||
/// the transition to the clips.
|
||||
#[test]
|
||||
fn a_regular_trim_shortens_a_transition_longer_than_its_clips() {
|
||||
let mut fx = Fx::new();
|
||||
let v1 = fx.v(0);
|
||||
let a = fx.put(v1, 0, 2, 10);
|
||||
let b = fx.put(v1, 2, 10, 10);
|
||||
fx.seq.track_mut(v1).unwrap().transitions = vec![Transition {
|
||||
id: TransitionId(1),
|
||||
effect: filmcraft_project::find_effect("cross_dissolve").unwrap().instance(),
|
||||
start: f(0),
|
||||
duration: f(4),
|
||||
from: Some(a),
|
||||
to: Some(b),
|
||||
align: filmcraft_project::TransitionAlign::StartAtCut,
|
||||
reverse: false,
|
||||
}];
|
||||
let mut n = fx.next;
|
||||
trim(&mut fx.seq, b, Edge::Out, TrimMode::Regular, -f(9), &mut Fx::ctx(&mut n)).unwrap();
|
||||
assert_eq!(fx.spans(v1), vec![(0, 2), (2, 1)]);
|
||||
let tr = &fx.seq.track(v1).unwrap().transitions[0];
|
||||
assert_eq!((tr.start, tr.duration), (f(0), f(3)), "the transition covers both clips and no more");
|
||||
assert_eq!(tr.align, filmcraft_project::TransitionAlign::CenterAtCut);
|
||||
fx.seq.check().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transitions_travel_with_moved_clips() {
|
||||
// V1: a [0,10) fading in, b [10,20), c [20,30); b→c crossfade, c fading out
|
||||
@@ -869,6 +932,102 @@ fn roll_reversed_clips_keeps_media_mapping() {
|
||||
assert_eq!((r.start, r.duration, r.source_in), (f(40), f(20), f(50)));
|
||||
}
|
||||
|
||||
/// A rolling edit moves the transition on its cut with the cut, and stops where the transition
|
||||
/// would leave one of its two clips.
|
||||
#[test]
|
||||
fn roll_keeps_the_transition_on_its_clips() {
|
||||
let mut fx = Fx::new();
|
||||
let v1 = fx.v(0);
|
||||
let a = fx.put(v1, 0, 10, 10);
|
||||
let b = fx.put(v1, 10, 10, 10);
|
||||
fx.seq.track_mut(v1).unwrap().transitions = vec![Transition {
|
||||
id: TransitionId(1),
|
||||
effect: filmcraft_project::find_effect("cross_dissolve").unwrap().instance(),
|
||||
start: f(8),
|
||||
duration: f(4),
|
||||
from: Some(a),
|
||||
to: Some(b),
|
||||
align: Default::default(),
|
||||
reverse: false,
|
||||
}];
|
||||
let mut n = fx.next;
|
||||
// left: the transition reaches the sequence start after 8 frames, not the 9 the clips allow
|
||||
assert_eq!(roll(&mut fx.seq, a, b, -f(9), &mut Fx::ctx(&mut n)).unwrap(), -f(8));
|
||||
assert_eq!(fx.spans(v1), vec![(0, 2), (2, 18)]);
|
||||
let tr = &fx.seq.track(v1).unwrap().transitions[0];
|
||||
assert_eq!((tr.start, tr.duration), (f(0), f(4)), "the transition moved with the cut");
|
||||
// right: it reaches the end of the second clip after 16 frames, not 17
|
||||
assert_eq!(roll(&mut fx.seq, a, b, f(17), &mut Fx::ctx(&mut n)).unwrap(), f(16));
|
||||
assert_eq!(fx.spans(v1), vec![(0, 18), (18, 2)]);
|
||||
let tr = &fx.seq.track(v1).unwrap().transitions[0];
|
||||
assert_eq!((tr.start, tr.duration), (f(16), f(4)));
|
||||
fx.seq.check().unwrap();
|
||||
}
|
||||
|
||||
/// A ripple trim or a slide that moves a cut close to the start of its first clip keeps the
|
||||
/// transition on that cut within its two clips, and its stored alignment follows: an End at Cut
|
||||
/// transition that now crosses the cut is stored as centered (Custom Start has no variant).
|
||||
#[test]
|
||||
fn edits_that_move_a_cut_keep_its_transition_on_its_clips() {
|
||||
for slide_it in [false, true] {
|
||||
let mut fx = Fx::new();
|
||||
let v1 = fx.v(0);
|
||||
let a = fx.put(v1, 0, 10, 10);
|
||||
let b = fx.put(v1, 10, 10, 10);
|
||||
fx.put(v1, 20, 10, 10);
|
||||
fx.seq.track_mut(v1).unwrap().transitions = vec![Transition {
|
||||
id: TransitionId(1),
|
||||
effect: filmcraft_project::find_effect("cross_dissolve").unwrap().instance(),
|
||||
start: f(6),
|
||||
duration: f(4),
|
||||
from: Some(a),
|
||||
to: Some(b),
|
||||
align: TransitionAlign::EndAtCut,
|
||||
reverse: false,
|
||||
}];
|
||||
let mut n = fx.next;
|
||||
if slide_it {
|
||||
slide(&mut fx.seq, b, -f(9), &mut Fx::ctx(&mut n)).unwrap();
|
||||
} else {
|
||||
trim(&mut fx.seq, a, Edge::Out, TrimMode::Ripple, -f(9), &mut Fx::ctx(&mut n)).unwrap();
|
||||
}
|
||||
let t = fx.seq.track(v1).unwrap();
|
||||
let (cut, a_start) = (t.item(b).unwrap().start, t.item(a).unwrap().start);
|
||||
assert_eq!(cut, f(1), "slide {slide_it}");
|
||||
let tr = &t.transitions[0];
|
||||
assert_eq!((tr.start, tr.duration), (a_start, f(4)), "slide {slide_it}: the transition starts with its first clip");
|
||||
assert_eq!(tr.align, TransitionAlign::CenterAtCut, "slide {slide_it}");
|
||||
fx.seq.check().unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
/// A ripple trim that shortens the second clip from its end leaves the cut where it is, and the
|
||||
/// transition on that cut still stays within its two clips.
|
||||
#[test]
|
||||
fn a_ripple_trim_of_the_second_clips_end_keeps_the_transition_within_it() {
|
||||
let mut fx = Fx::new();
|
||||
let v1 = fx.v(0);
|
||||
let a = fx.put(v1, 0, 10, 10);
|
||||
let b = fx.put(v1, 10, 10, 10);
|
||||
fx.seq.track_mut(v1).unwrap().transitions = vec![Transition {
|
||||
id: TransitionId(1),
|
||||
effect: filmcraft_project::find_effect("cross_dissolve").unwrap().instance(),
|
||||
start: f(8),
|
||||
duration: f(4),
|
||||
from: Some(a),
|
||||
to: Some(b),
|
||||
align: TransitionAlign::CenterAtCut,
|
||||
reverse: false,
|
||||
}];
|
||||
let mut n = fx.next;
|
||||
trim(&mut fx.seq, b, Edge::Out, TrimMode::Ripple, -f(9), &mut Fx::ctx(&mut n)).unwrap();
|
||||
assert_eq!(fx.spans(v1), vec![(0, 10), (10, 1)]);
|
||||
let tr = &fx.seq.track(v1).unwrap().transitions[0];
|
||||
assert_eq!((tr.start, tr.duration), (f(7), f(4)), "the transition ends with the second clip");
|
||||
assert_eq!(tr.align, TransitionAlign::CenterAtCut);
|
||||
fx.seq.check().unwrap();
|
||||
}
|
||||
|
||||
/// #374: moving an edge must not change which media sits under any timeline time the clip still
|
||||
/// covers (the timeline's live trim preview draws its waveform from this item), and must leave the
|
||||
/// item as the trim itself does.
|
||||
|
||||
@@ -22,6 +22,18 @@ pub fn is_through_edit(a: &TrackItem, b: &TrackItem) -> bool {
|
||||
if a.end() != b.start || a.item != b.item || a.speed != b.speed || a.reverse != b.reverse || a.multicam != b.multicam {
|
||||
return false;
|
||||
}
|
||||
if a.enabled != b.enabled
|
||||
|| a.gain_db != b.gain_db
|
||||
|| a.effects != b.effects
|
||||
|| a.scale_to_frame != b.scale_to_frame
|
||||
|| a.hold_filters != b.hold_filters
|
||||
|| a.time_interpolation != b.time_interpolation
|
||||
|| a.field_options != b.field_options
|
||||
|| a.audio_stream != b.audio_stream
|
||||
|| a.source_channels != b.source_channels
|
||||
{
|
||||
return false;
|
||||
}
|
||||
match (a.frame_hold, b.frame_hold) {
|
||||
(None, None) => {}
|
||||
(Some(x), Some(y)) => return x == y,
|
||||
@@ -214,6 +226,20 @@ mod tests {
|
||||
assert_eq!(q.video_tracks[0].items, orig.video_tracks[0].items);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pieces_with_different_settings_are_not_through_edits() {
|
||||
let tweaks: [fn(&mut TrackItem); 3] = [|t| t.enabled = false, |t| t.gain_db = -20.0, |t| t.scale_to_frame = true];
|
||||
for tweak in tweaks {
|
||||
let mut q = seq_with(vec![item(10, 0, 100, 500)]);
|
||||
let mut n = 1000;
|
||||
razor(&mut q, &[], Tick(40), &mut ctx(&mut n));
|
||||
tweak(&mut q.video_tracks[0].items[1]);
|
||||
assert!(through_edits(&q).is_empty());
|
||||
assert_eq!(join_through_edits(&mut q, &[]), 0);
|
||||
assert_eq!(q.video_tracks[0].items.len(), 2);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reversed_and_speed_changed_pieces_join() {
|
||||
for (speed, reverse) in [(1.0, true), (1.5, false), (0.7, true)] {
|
||||
|
||||
@@ -130,6 +130,9 @@ fn dequantize(pcm: &[u8], channels: usize, bits: u16) -> Vec<Vec<f32>> {
|
||||
out
|
||||
}
|
||||
|
||||
const MAX_EXPORT_AUDIO_CHANNELS: usize = 64;
|
||||
const MAX_EXPORT_AUDIO_SAMPLES: usize = 64 * 1024 * 1024;
|
||||
|
||||
/// Decode a media range at `sample_rate` (planar).
|
||||
fn decode(s: &Session, need: &AudioNeed, sample_rate: u32) -> Result<Vec<Vec<f32>>> {
|
||||
let src = s
|
||||
@@ -138,7 +141,12 @@ fn decode(s: &Session, need: &AudioNeed, sample_rate: u32) -> Result<Vec<Vec<f32
|
||||
.ok_or_else(|| EngineError::Other(format!("media {} is offline", need.path.as_deref().unwrap_or("?"))))?;
|
||||
let start = need.start.to_units_floor(sample_rate as i64);
|
||||
let frames = (need.end.to_units_floor(sample_rate as i64) - start).max(0) as usize;
|
||||
let mut out: Vec<Vec<f32>> = vec![Vec::with_capacity(frames); need.channels.max(1) as usize];
|
||||
let ch = (need.channels.max(1) as usize).min(MAX_EXPORT_AUDIO_CHANNELS);
|
||||
let total_samples = ch.checked_mul(frames).ok_or_else(|| EngineError::Other("audio export buffer size overflow".into()))?;
|
||||
if total_samples > MAX_EXPORT_AUDIO_SAMPLES {
|
||||
return Err(EngineError::Other(format!("audio export buffer size {total_samples} samples exceeds maximum allowed ({MAX_EXPORT_AUDIO_SAMPLES})")));
|
||||
}
|
||||
let mut out: Vec<Vec<f32>> = vec![Vec::with_capacity(frames); ch];
|
||||
let chunk = sample_rate as usize * 10;
|
||||
let mut done = 0usize;
|
||||
while done < frames {
|
||||
|
||||
@@ -35,6 +35,102 @@ fn journaled(s: &mut Session) -> u64 {
|
||||
s.persistence.as_ref().unwrap().status.journaled_revision
|
||||
}
|
||||
|
||||
fn discard_fixture(tag: &str) -> (Session, std::path::PathBuf) {
|
||||
let d = temp_dir(tag);
|
||||
let mut s = Session::default();
|
||||
start(&mut s, &d);
|
||||
let candidates = ["first", "second", "third"]
|
||||
.into_iter()
|
||||
.map(|id| {
|
||||
let dir = d.join(id);
|
||||
std::fs::create_dir(&dir).unwrap();
|
||||
std::fs::write(dir.join("snapshot.fcproj"), b"retained snapshot").unwrap();
|
||||
autosave::RecoveryCandidate { id: id.into(), dir, meta: Default::default() }
|
||||
})
|
||||
.collect();
|
||||
s.persistence.as_mut().unwrap().candidates = candidates;
|
||||
(s, d)
|
||||
}
|
||||
|
||||
fn block_discard(dir: &std::path::Path) {
|
||||
// An ordinary file cannot be removed as a directory, on Windows or Unix.
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
std::fs::write(dir, b"occupied directory path").unwrap();
|
||||
}
|
||||
|
||||
fn recovery_ids(s: &mut Session) -> Vec<String> {
|
||||
s.execute("file.recoveryList", json!({})).unwrap().as_array().unwrap().iter().map(|v| v["id"].as_str().unwrap().to_owned()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discard_failure_preserves_candidates_and_allows_retry() {
|
||||
let (mut s, d) = discard_fixture("discard-first-error");
|
||||
block_discard(&d.join("first"));
|
||||
assert!(s.execute("file.discardRecovery", json!({"all": true})).is_err());
|
||||
assert_eq!(recovery_ids(&mut s), ["first", "second", "third"]);
|
||||
assert!(s.is_enabled("file.discardRecovery"));
|
||||
assert!(d.join("second/snapshot.fcproj").exists());
|
||||
assert!(d.join("third/snapshot.fcproj").exists());
|
||||
std::fs::remove_file(d.join("first")).unwrap();
|
||||
std::fs::create_dir(d.join("first")).unwrap();
|
||||
assert_eq!(s.execute("file.discardRecovery", json!({"all": true})).unwrap()["discarded"], 3);
|
||||
assert!(recovery_ids(&mut s).is_empty());
|
||||
s.shutdown();
|
||||
std::fs::remove_dir_all(d).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discard_failure_keeps_only_not_yet_deleted_candidates() {
|
||||
let (mut s, d) = discard_fixture("discard-second-error");
|
||||
block_discard(&d.join("second"));
|
||||
assert!(s.execute("file.discardRecovery", json!({"all": true})).is_err());
|
||||
assert_eq!(recovery_ids(&mut s), ["second", "third"]);
|
||||
assert!(!d.join("first").exists());
|
||||
assert!(d.join("third/snapshot.fcproj").exists());
|
||||
s.shutdown();
|
||||
std::fs::remove_dir_all(d).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discard_selected_failure_keeps_unselected_candidates() {
|
||||
let (mut s, d) = discard_fixture("discard-selected-error");
|
||||
block_discard(&d.join("second"));
|
||||
assert!(s.execute("file.discardRecovery", json!({"id": "second"})).is_err());
|
||||
assert_eq!(recovery_ids(&mut s), ["first", "second", "third"]);
|
||||
assert!(d.join("first/snapshot.fcproj").exists());
|
||||
assert!(d.join("third/snapshot.fcproj").exists());
|
||||
s.shutdown();
|
||||
std::fs::remove_dir_all(d).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discard_default_failure_does_not_select_another_candidate() {
|
||||
let (mut s, d) = discard_fixture("discard-default-error");
|
||||
block_discard(&d.join("first"));
|
||||
let error = s.execute("file.discardRecovery", json!({})).unwrap_err().to_string();
|
||||
assert!(error.contains("first"), "{error}");
|
||||
assert_eq!(recovery_ids(&mut s), ["first", "second", "third"]);
|
||||
assert!(d.join("second/snapshot.fcproj").exists());
|
||||
s.shutdown();
|
||||
std::fs::remove_dir_all(d).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discard_selection_and_unknown_id_preserve_other_candidates() {
|
||||
let (mut s, d) = discard_fixture("discard-selection");
|
||||
assert!(s.execute("file.discardRecovery", json!({"id": "unknown"})).is_err());
|
||||
assert_eq!(recovery_ids(&mut s), ["first", "second", "third"]);
|
||||
assert_eq!(s.execute("file.discardRecovery", json!({"id": "second"})).unwrap()["discarded"], 1);
|
||||
assert_eq!(recovery_ids(&mut s), ["first", "third"]);
|
||||
assert!(!d.join("second").exists());
|
||||
assert_eq!(s.execute("file.discardRecovery", json!({})).unwrap()["discarded"], 1);
|
||||
assert_eq!(recovery_ids(&mut s), ["third"]);
|
||||
assert!(!d.join("first").exists());
|
||||
assert!(d.join("third/snapshot.fcproj").exists());
|
||||
s.shutdown();
|
||||
std::fs::remove_dir_all(d).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preferences_get_set_and_persist() {
|
||||
let d = temp_dir("prefs");
|
||||
|
||||
@@ -164,6 +164,58 @@ pub fn delete(s: &mut Session, ids: &[ClipId], ripple: bool) -> Result<Value> {
|
||||
Ok(json!({"deleted": n}))
|
||||
}
|
||||
|
||||
/// Copy the selected captions to the caption clipboard (Edit ▸ Copy / Cut with captions selected).
|
||||
/// Returns how many were copied.
|
||||
pub fn copy(s: &mut Session) -> usize {
|
||||
let Some(q) = s.active_sequence() else { return 0 };
|
||||
let sel = &s.state.caption_selection;
|
||||
let picked: Vec<(usize, filmcraft_project::Caption)> =
|
||||
q.caption_tracks.iter().enumerate().flat_map(|(ti, t)| t.captions.iter().filter(|c| sel.contains(&c.id)).map(move |c| (ti, c.clone()))).collect();
|
||||
let min = picked.iter().map(|(_, c)| c.start).min().unwrap_or_default();
|
||||
s.state.caption_clipboard = picked.into_iter().map(|(ti, c)| (ti, filmcraft_project::Caption { start: c.start - min, ..c })).collect();
|
||||
s.state.clipboard.clear();
|
||||
s.state.clipboard_markers.clear();
|
||||
s.state.caption_clipboard.len()
|
||||
}
|
||||
|
||||
/// Paste the caption clipboard at the playhead, each caption on the caption track it was copied
|
||||
/// from (the last one when that track is gone), overwriting what is there. Paste Insert first opens
|
||||
/// a gap the copied span long on those tracks. The pasted captions are selected and the playhead
|
||||
/// moves to their end.
|
||||
pub fn paste(s: &mut Session, insert: bool) -> Result<Value> {
|
||||
let at = s.playhead();
|
||||
let clip = s.state.caption_clipboard.clone();
|
||||
let span = clip.iter().map(|(_, c)| c.end()).max().unwrap_or_default();
|
||||
let mut by_track: Vec<(TrackId, Vec<filmcraft_project::Caption>)> = Vec::new();
|
||||
{
|
||||
let q = s.active_sequence().ok_or(EngineError::NoSequence)?;
|
||||
for (ti, c) in clip {
|
||||
let Some(t) = q.caption_tracks.get(ti).or(q.caption_tracks.last()) else {
|
||||
return Err(bad("edit.paste", "the sequence has no caption track to paste into"));
|
||||
};
|
||||
let c = filmcraft_project::Caption { start: c.start + at, ..c };
|
||||
match by_track.iter_mut().find(|(id, _)| *id == t.id) {
|
||||
Some((_, v)) => v.push(c),
|
||||
None => by_track.push((t.id, vec![c])),
|
||||
}
|
||||
}
|
||||
}
|
||||
let ids = s.edit_sequence(if insert { "Paste Insert Captions" } else { "Paste Captions" }, |q, ctx, st| {
|
||||
let mut ids = Vec::new();
|
||||
for (tid, caps) in &by_track {
|
||||
if insert && let Some(tr) = q.caption_track_mut(*tid).filter(|t| !t.locked) {
|
||||
edit::captions::insert_gap(tr, at, span, ctx);
|
||||
}
|
||||
ids.extend(edit::captions::overwrite_captions(q, *tid, caps, ctx)?);
|
||||
}
|
||||
st.caption_selection = ids.clone();
|
||||
st.selection.clear();
|
||||
Ok(ids)
|
||||
})?;
|
||||
s.set_playhead(at + span);
|
||||
Ok(json!({"captions": ids.iter().map(|c| c.0).collect::<Vec<_>>()}))
|
||||
}
|
||||
|
||||
fn caption_json(s: &Session, c: &filmcraft_project::Caption) -> Value {
|
||||
let q = s.active_sequence();
|
||||
let (rate, df) = q.map(|q| (q.settings.frame_rate, q.settings.drop_frame)).unwrap_or_default();
|
||||
@@ -467,6 +519,44 @@ pub fn commands() -> Vec<CommandSpec> {
|
||||
let applied = s.edit_sequence("Move Captions", |q, _, _| Ok(edit::captions::move_captions(q, &ids, d)?))?;
|
||||
Ok(json!({"applied": applied.0}))
|
||||
}),
|
||||
spec("captions.duplicate", "Duplicate Captions", &[], None, r#"{"captions":[id]?,"delta":ticks|"deltaFrames":i64}"#, has_captions, |s, p| {
|
||||
// Option-drag in the Timeline: copies `delta` later (or earlier) on their own
|
||||
// tracks, overwriting what is there; the originals stay
|
||||
let mut ids = caption_ids(s, p);
|
||||
// each caption is copied once, however often it is named
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
ids.retain(|id| seen.insert(*id));
|
||||
if ids.is_empty() {
|
||||
return Err(EngineError::Edit(edit::EditError::Nothing));
|
||||
}
|
||||
let d = delta_p(s, p).ok_or_else(|| bad("captions.duplicate", "need `delta` or `deltaFrames`"))?;
|
||||
let q = s.active_sequence().ok_or(EngineError::NoSequence)?;
|
||||
let mut by_track: Vec<(TrackId, Vec<filmcraft_project::Caption>)> = Vec::new();
|
||||
for id in &ids {
|
||||
let (tid, c) = q.find_caption(*id).ok_or(EngineError::Edit(edit::EditError::NoItem(*id)))?;
|
||||
match by_track.iter_mut().find(|(t, _)| *t == tid) {
|
||||
Some((_, v)) => v.push(c.clone()),
|
||||
None => by_track.push((tid, vec![c.clone()])),
|
||||
}
|
||||
}
|
||||
// never before the start of the sequence
|
||||
let earliest = by_track.iter().flat_map(|(_, v)| v.iter().map(|c| c.start)).min().unwrap_or_default();
|
||||
let d = d.max(-earliest);
|
||||
if d == Tick::ZERO {
|
||||
return Err(EngineError::Edit(edit::EditError::Nothing));
|
||||
}
|
||||
let new = s.edit_sequence("Duplicate Captions", |q, ctx, st| {
|
||||
let mut new = Vec::new();
|
||||
for (tid, caps) in &by_track {
|
||||
let moved: Vec<_> = caps.iter().map(|c| filmcraft_project::Caption { start: c.start + d, ..c.clone() }).collect();
|
||||
new.extend(edit::captions::overwrite_captions(q, *tid, &moved, ctx)?);
|
||||
}
|
||||
st.caption_selection = new.clone();
|
||||
st.selection.clear();
|
||||
Ok(new)
|
||||
})?;
|
||||
Ok(json!({"captions": new.iter().map(|c| c.0).collect::<Vec<_>>(), "delta": d.0}))
|
||||
}),
|
||||
spec("captions.delete", "Delete Captions", &[], None, r#"{"captions":[id]?,"ripple":bool=false}"#, has_captions, |s, p| {
|
||||
let ids = caption_ids(s, p);
|
||||
delete(s, &ids, bool_p(p, "ripple").unwrap_or(false))
|
||||
|
||||
@@ -158,3 +158,69 @@ fn caption_list_track_filter_never_falls_back_to_every_track() {
|
||||
assert!(s.execute("captions.list", json!({"track": sel})).is_err(), "{sel} names no caption track");
|
||||
}
|
||||
}
|
||||
|
||||
fn caption_spans(s: &Session) -> Vec<(f64, f64, String)> {
|
||||
let secs = |t: Tick| (t.seconds() * 10.0).round() / 10.0; // the demo sequence snaps to 23.976 fps frames
|
||||
s.active_sequence().unwrap().caption_tracks[0].captions.iter().map(|c| (secs(c.start), secs(c.end()), c.text.clone())).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn captions_duplicate_copies_and_overwrites() {
|
||||
let mut s = demo();
|
||||
for (sec, text) in [(1.0, "one"), (3.0, "two")] {
|
||||
s.execute("playhead.set", json!({"seconds": sec})).unwrap();
|
||||
s.execute("captions.add", json!({"text": text, "durationSeconds": 1.0})).unwrap();
|
||||
}
|
||||
let one = s.active_sequence().unwrap().caption_tracks[0].captions[0].id;
|
||||
// Option-drag: a copy 5 s later, the original stays; the copy is selected
|
||||
let r = s.execute("captions.duplicate", json!({"captions": [one.0], "delta": 5 * TICKS_PER_SECOND})).unwrap();
|
||||
assert_eq!(caption_spans(&s), vec![(1.0, 2.0, "one".into()), (3.0, 4.0, "two".into()), (6.0, 7.0, "one".into())]);
|
||||
assert_eq!(s.state.caption_selection.len(), 1);
|
||||
assert_eq!(r["captions"][0].as_u64(), Some(s.state.caption_selection[0].0));
|
||||
// a copy dropped half over `two` overwrites that half
|
||||
s.execute("captions.duplicate", json!({"captions": [one.0], "delta": TICKS_PER_SECOND * 3 / 2})).unwrap();
|
||||
assert_eq!(caption_spans(&s)[1..3], [(2.5, 3.5, "one".into()), (3.5, 4.0, "two".into())]);
|
||||
// never before zero; zero delta is nothing
|
||||
s.execute("captions.duplicate", json!({"captions": [one.0], "delta": -100 * TICKS_PER_SECOND})).unwrap();
|
||||
assert_eq!(caption_spans(&s)[0], (0.0, 1.0, "one".into()));
|
||||
assert!(s.execute("captions.duplicate", json!({"captions": [one.0], "delta": 0})).is_err());
|
||||
assert!(s.execute("captions.duplicate", json!({"captions": [one.0]})).is_err(), "needs a delta");
|
||||
assert!(s.execute("captions.duplicate", json!({"captions": [], "delta": TICKS_PER_SECOND})).is_err(), "needs a caption");
|
||||
let before = s.active_sequence().unwrap().caption_tracks[0].captions.len();
|
||||
s.execute("captions.duplicate", json!({"captions": [one.0, one.0], "delta": 20 * TICKS_PER_SECOND})).unwrap();
|
||||
assert_eq!(s.active_sequence().unwrap().caption_tracks[0].captions.len(), before + 1, "a caption named twice is copied once");
|
||||
s.active_sequence().unwrap().check().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn captions_copy_cut_and_paste() {
|
||||
let mut s = demo();
|
||||
// Copy / Paste with only captions selected copies captions, pasted at the playhead
|
||||
s.execute("playhead.set", json!({"seconds": 1.0})).unwrap();
|
||||
s.execute("captions.add", json!({"text": "hi", "durationSeconds": 1.0})).unwrap();
|
||||
assert!(s.state.selection.is_empty());
|
||||
assert_eq!(s.execute("edit.copy", json!({})).unwrap()["copied"], 1);
|
||||
s.execute("playhead.set", json!({"seconds": 10.0})).unwrap();
|
||||
s.execute("edit.paste", json!({})).unwrap();
|
||||
assert_eq!(caption_spans(&s), vec![(1.0, 2.0, "hi".into()), (10.0, 11.0, "hi".into())]);
|
||||
assert!((s.playhead().seconds() - 11.0).abs() < 0.05, "the playhead moves past the paste");
|
||||
// Cut takes it away and Paste puts it back
|
||||
s.execute("edit.cut", json!({})).unwrap();
|
||||
assert_eq!(caption_spans(&s), vec![(1.0, 2.0, "hi".into())]);
|
||||
s.execute("playhead.set", json!({"seconds": 4.0})).unwrap();
|
||||
s.execute("edit.paste", json!({})).unwrap();
|
||||
assert_eq!(caption_spans(&s)[1], (4.0, 5.0, "hi".into()));
|
||||
// copying clips afterwards makes Paste place clips again, not captions
|
||||
let clip = s.active_sequence().unwrap().video_tracks[0].items[0].id;
|
||||
s.execute("timeline.select", json!({"clips": [clip.0]})).unwrap();
|
||||
s.execute("edit.copy", json!({})).unwrap();
|
||||
assert!(s.state.caption_clipboard.is_empty() && !s.state.clipboard.is_empty());
|
||||
// undo takes back a caption paste as one step
|
||||
let mut s = demo();
|
||||
s.execute("captions.add", json!({"text": "x", "durationSeconds": 1.0})).unwrap();
|
||||
s.execute("edit.copy", json!({})).unwrap();
|
||||
s.execute("playhead.set", json!({"seconds": 5.0})).unwrap();
|
||||
s.execute("edit.paste", json!({})).unwrap();
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(caption_spans(&s).len(), 1);
|
||||
}
|
||||
|
||||
@@ -85,6 +85,9 @@ pub(crate) fn commands() -> Vec<CommandSpec> {
|
||||
|
||||
fn import_lut(s: &mut Session, path: &str, name: Option<&str>) -> Result<(String, Value)> {
|
||||
let text = std::fs::read_to_string(path).map_err(|e| bad("lut.import", format!("{path}: {e}")))?;
|
||||
if text.len() > filmcraft_project::MAX_LUT_TEXT_BYTES {
|
||||
return Err(bad("lut.import", format!("{path}: LUT source exceeds the {} byte limit", filmcraft_project::MAX_LUT_TEXT_BYTES)));
|
||||
}
|
||||
let fmt = LutFormat::from_path(path).ok_or_else(|| bad("lut.import", "expected a .cube or .3dl file"))?;
|
||||
let lut = Lut::parse(&text, Some(fmt)).map_err(|e| bad("lut.import", format!("{path}: {e}")))?;
|
||||
let stem = std::path::Path::new(path).file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_else(|| "LUT".into());
|
||||
|
||||
+151
-15
@@ -79,6 +79,11 @@ pub(crate) fn has_selection(s: &Session) -> std::result::Result<(), String> {
|
||||
has_seq(s)?;
|
||||
if s.state.selection.is_empty() { Err("no clips selected".into()) } else { Ok(()) }
|
||||
}
|
||||
/// Clips or captions selected (Copy / Cut work on either).
|
||||
fn has_clips_or_captions(s: &Session) -> std::result::Result<(), String> {
|
||||
has_seq(s)?;
|
||||
if s.state.selection.is_empty() && s.state.caption_selection.is_empty() { Err("no clips or captions selected".into()) } else { Ok(()) }
|
||||
}
|
||||
/// Clips or captions selected (Clear / Ripple Delete work on either).
|
||||
fn has_any_selection(s: &Session) -> std::result::Result<(), String> {
|
||||
has_seq(s)?;
|
||||
@@ -107,6 +112,9 @@ fn can_undo(s: &Session) -> std::result::Result<(), String> {
|
||||
fn can_redo(s: &Session) -> std::result::Result<(), String> {
|
||||
if s.history.can_redo() { Ok(()) } else { Err("nothing to redo".into()) }
|
||||
}
|
||||
fn has_history(s: &Session) -> std::result::Result<(), String> {
|
||||
if s.history.can_undo() || s.history.can_redo() { Ok(()) } else { Err("the history is empty".into()) }
|
||||
}
|
||||
fn has_in_out(s: &Session) -> std::result::Result<(), String> {
|
||||
let seq = s.active_sequence().ok_or("no sequence is open")?;
|
||||
if seq.mark_in.is_some() || seq.mark_out.is_some() { Ok(()) } else { Err("mark an In or Out point first".into()) }
|
||||
@@ -120,7 +128,7 @@ pub(crate) fn has_project_selection(s: &Session) -> std::result::Result<(), Stri
|
||||
}
|
||||
fn has_clipboard(s: &Session) -> std::result::Result<(), String> {
|
||||
has_seq(s)?;
|
||||
if s.state.clipboard.is_empty() { Err("clipboard is empty".into()) } else { Ok(()) }
|
||||
if s.state.clipboard.is_empty() && s.state.caption_clipboard.is_empty() { Err("clipboard is empty".into()) } else { Ok(()) }
|
||||
}
|
||||
|
||||
/// The ids a caller named under `many` (a list) or `one` (a single id), when the command documents
|
||||
@@ -627,6 +635,41 @@ pub fn trim_delta(s: &Session, clip: ClipId, edge: Edge, mode: TrimMode, delta:
|
||||
clamp_trim_linked(q, &with_links(s, &[clip]), edge, mode, delta, &ctx)
|
||||
}
|
||||
|
||||
/// The delta a roll of every cut in `pairs` can apply together: `delta` clamped by each pair's
|
||||
/// media and the frame left in each clip, each tried on a copy of `q`. 0 when a pair would roll
|
||||
/// by another amount (a clip whose Out point is past the end of its media rolls the other way).
|
||||
fn clamp_roll_linked(q: &filmcraft_project::Sequence, pairs: &[(ClipId, ClipId)], delta: Tick, ctx: &mut edit::EditCtx) -> Result<Tick> {
|
||||
let mut d = delta;
|
||||
for &(pl, pr) in pairs {
|
||||
let mut probe = q.clone();
|
||||
let x = edit::roll(&mut probe, pl, pr, d, ctx)?;
|
||||
if x.abs() < d.abs() {
|
||||
d = x;
|
||||
}
|
||||
}
|
||||
if d != Tick::ZERO {
|
||||
for &(pl, pr) in pairs {
|
||||
let mut probe = q.clone();
|
||||
if edit::roll(&mut probe, pl, pr, d, ctx)? != d {
|
||||
return Ok(Tick::ZERO);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(d)
|
||||
}
|
||||
|
||||
/// The delta `timeline.roll` would apply for `delta`, without editing anything: the Timeline's
|
||||
/// Rolling Edit drag shows this, so the cut stops where the roll on release stops instead of
|
||||
/// following the pointer past the media.
|
||||
pub fn roll_delta(s: &Session, left: ClipId, right: ClipId, delta: Tick) -> Result<Tick> {
|
||||
let q = s.active_sequence().ok_or(EngineError::NoSequence)?;
|
||||
let durations = |id: ItemId| crate::media_duration(&s.project, &s.media, id);
|
||||
let starts = |id: ItemId| crate::media_start(&s.project, id);
|
||||
let mut next = s.project.next_id;
|
||||
let mut ctx = edit::EditCtx { next_id: &mut next, media_duration: &durations, media_start: &starts, min_duration: q.settings.frame_rate.frame_duration() };
|
||||
clamp_roll_linked(q, &linked_roll_pairs(q, left, right, s.state.linked_selection), delta, &mut ctx)
|
||||
}
|
||||
|
||||
/// Sequence settings matching a media clip (New Sequence From Clip, New Sequence from an item):
|
||||
/// its frame size, rate and pixel aspect ratio (Interpret Footage's, else the file's), so a
|
||||
/// 1440 x 1080 clip with 4:3 pixels makes a 1440 x 1080 sequence that displays 16:9.
|
||||
@@ -1521,18 +1564,27 @@ fn build() -> Vec<CommandSpec> {
|
||||
let all = bool_p(p, "all").unwrap_or(false);
|
||||
let id = str_p(p, "id").map(str::to_string);
|
||||
let Some(per) = s.persistence.as_mut() else { return Ok(json!({"discarded": 0})) };
|
||||
let mut n = 0;
|
||||
let mut n = 0usize;
|
||||
let mut keep = Vec::new();
|
||||
let mut failure = None;
|
||||
for (i, c) in std::mem::take(&mut per.candidates).into_iter().enumerate() {
|
||||
let hit = all || id.as_deref().map_or(i == 0, |want| want == c.id);
|
||||
if hit {
|
||||
crate::autosave::discard_candidate(&c).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
n += 1;
|
||||
if hit && failure.is_none() {
|
||||
match crate::autosave::discard_candidate(&c) {
|
||||
Ok(()) => n = n.saturating_add(1),
|
||||
Err(e) => {
|
||||
failure = Some(EngineError::Other(format!("could not discard recovery session {}: {e}", c.id)));
|
||||
keep.push(c);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
keep.push(c);
|
||||
}
|
||||
}
|
||||
per.candidates = keep;
|
||||
if let Some(e) = failure {
|
||||
return Err(e);
|
||||
}
|
||||
if n == 0 {
|
||||
return Err(bad("file.discardRecovery", "no such recovery session"));
|
||||
}
|
||||
@@ -1615,7 +1667,16 @@ fn build() -> Vec<CommandSpec> {
|
||||
// ================= Edit =================
|
||||
cmd!("edit.undo", "Undo", ["Edit"], Some("Cmd+Z"), "{}", can_undo, |s, _| Ok(json!({"undone": s.undo()}))),
|
||||
cmd!("edit.redo", "Redo", ["Edit"], Some("Cmd+Shift+Z"), "{}", can_redo, |s, _| Ok(json!({"redone": s.redo()}))),
|
||||
cmd!("edit.cut", "Cut", ["Edit"], Some("Cmd+X"), "{}", has_selection, |s, _| {
|
||||
// the History panel menu; neither one changes the project or its unsaved state
|
||||
cmd!("history.deleteRedoable", "Delete Redoable Actions", [], None, "{}", can_redo, |s, _| Ok(json!({"deleted": s.history.delete_redoable()}))),
|
||||
cmd!("history.clear", "Clear History", [], None, "{}", has_history, |s, _| Ok(json!({"deleted": s.history.clear()}))),
|
||||
cmd!("edit.cut", "Cut", ["Edit"], Some("Cmd+X"), "{}", has_clips_or_captions, |s, _| {
|
||||
if s.state.selection.is_empty() {
|
||||
crate::captions::copy(s);
|
||||
let caps = s.state.caption_selection.clone();
|
||||
crate::captions::delete(s, &caps, false)?;
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
copy_selection(s);
|
||||
let sel = with_links(s, &s.state.selection.clone());
|
||||
s.edit_sequence("Cut", |q, _, st| {
|
||||
@@ -1625,7 +1686,10 @@ fn build() -> Vec<CommandSpec> {
|
||||
})?;
|
||||
Ok(Value::Null)
|
||||
}),
|
||||
cmd!("edit.copy", "Copy", ["Edit"], Some("Cmd+C"), "{}", has_selection, |s, _| {
|
||||
cmd!("edit.copy", "Copy", ["Edit"], Some("Cmd+C"), "{}", has_clips_or_captions, |s, _| {
|
||||
if s.state.selection.is_empty() {
|
||||
return Ok(json!({"copied": crate::captions::copy(s)}));
|
||||
}
|
||||
copy_selection(s);
|
||||
Ok(json!({"copied": s.state.clipboard.len()}))
|
||||
}),
|
||||
@@ -2266,6 +2330,55 @@ fn build() -> Vec<CommandSpec> {
|
||||
crate::previews::delete(s, true)
|
||||
}),
|
||||
query!("sequence.renderBar", "Render Bar", "{}", |s, _| crate::previews::bar_json(s)),
|
||||
// ================= Work area bar =================
|
||||
cmd!("sequence.workAreaBar", "Work Area Bar", [], None, r#"{"show":bool?}"#, has_seq, |s, p| {
|
||||
let shown = s.active_sequence().ok_or(EngineError::NoSequence)?.work_area.is_some();
|
||||
let show = bool_p(p, "show").unwrap_or(!shown);
|
||||
if show == shown {
|
||||
return Ok(json!({"show": show}));
|
||||
}
|
||||
let fd = s.sequence_rate().frame_duration();
|
||||
s.edit_sequence("Work Area Bar", |q, _, _| {
|
||||
// shown again, the bar spans the whole sequence
|
||||
q.work_area = show.then(|| TimeRange::from_bounds(Tick::ZERO, q.duration().max(fd)));
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"show": show}))
|
||||
}),
|
||||
cmd!(
|
||||
"sequence.setWorkAreaIn",
|
||||
"Set Work Area Bar In Point",
|
||||
[],
|
||||
None,
|
||||
r#"{"time":ticks?,"frame":i64?,"seconds":f64?,"timecode":str?}"#,
|
||||
has_seq,
|
||||
|s, p| set_work_area_point(s, p, true)
|
||||
),
|
||||
cmd!(
|
||||
"sequence.setWorkAreaOut",
|
||||
"Set Work Area Bar Out Point",
|
||||
[],
|
||||
None,
|
||||
r#"{"time":ticks?,"frame":i64?,"seconds":f64?,"timecode":str?}"#,
|
||||
has_seq,
|
||||
|s, p| set_work_area_point(s, p, false)
|
||||
),
|
||||
cmd!("sequence.setWorkArea", "Set Work Area", [], None, r#"{"start":ticks,"end":ticks}"#, has_seq, |s, p| {
|
||||
const CMD: &str = "sequence.setWorkArea";
|
||||
let tick = |key: &str| p.get(key).and_then(Value::as_i64).map(Tick).ok_or_else(|| bad(CMD, format!("need `{key}` (ticks)")));
|
||||
let (start, end) = (tick("start")?, tick("end")?);
|
||||
if start < Tick::ZERO {
|
||||
return Err(bad(CMD, "`start` must not be negative"));
|
||||
}
|
||||
if end <= start {
|
||||
return Err(bad(CMD, "`end` must be after `start`"));
|
||||
}
|
||||
s.edit_sequence("Set Work Area", |q, _, _| {
|
||||
q.work_area = Some(TimeRange::from_bounds(start, end));
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"start": start.0, "end": end.0}))
|
||||
}),
|
||||
cmd!("sequence.matchFrame", "Match Frame", ["Sequence"], Some("F"), "{}", has_seq, |s, _| {
|
||||
let t = s.playhead();
|
||||
let seq = s.active_sequence().ok_or(EngineError::NoSequence)?;
|
||||
@@ -2776,14 +2889,7 @@ fn build() -> Vec<CommandSpec> {
|
||||
let (x, n, linked) = s.edit_sequence("Rolling Edit", |q, ctx, _| {
|
||||
let pairs = linked_roll_pairs(q, l, r, follow);
|
||||
let linked = pairs.len() > 1;
|
||||
let mut d = d;
|
||||
for &(pl, pr) in &pairs {
|
||||
let mut probe = q.clone();
|
||||
let x = edit::roll(&mut probe, pl, pr, d, ctx)?;
|
||||
if x.abs() < d.abs() {
|
||||
d = x;
|
||||
}
|
||||
}
|
||||
let d = clamp_roll_linked(q, &pairs, d, ctx)?;
|
||||
if d != Tick::ZERO {
|
||||
for &(pl, pr) in &pairs {
|
||||
edit::roll(q, pl, pr, d, ctx)?;
|
||||
@@ -3611,6 +3717,32 @@ fn mark(s: &mut Session, p: &Value, is_in: bool) -> Result<Value> {
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
/// Set Work Area Bar In / Out Point: the work area starts (`is_in`) or ends at `time`, else at the
|
||||
/// playhead. An In point at or after the end moves the end to the end of the sequence, or to one
|
||||
/// frame after the In point when the In point is at or past the end of the sequence; an Out point
|
||||
/// at or before the start moves the start to 0. With no work area yet, the other end is the end
|
||||
/// or the start of the sequence.
|
||||
fn set_work_area_point(s: &mut Session, p: &Value, is_in: bool) -> Result<Value> {
|
||||
let cmd = if is_in { "sequence.setWorkAreaIn" } else { "sequence.setWorkAreaOut" };
|
||||
let t = sequence_time_p(s, p, "", cmd)?.unwrap_or(s.playhead());
|
||||
let fd = s.sequence_rate().frame_duration();
|
||||
let seq = s.active_sequence().ok_or(EngineError::NoSequence)?;
|
||||
let (start, end) = seq.work_area.map(|w| (w.start, w.end())).unwrap_or((Tick::ZERO, seq.duration()));
|
||||
let (start, end) = if is_in {
|
||||
(t, if end > t { end } else { seq.duration().max(t + fd) })
|
||||
} else {
|
||||
if t <= Tick::ZERO {
|
||||
return Err(bad(cmd, "the Out point must be after the start of the sequence"));
|
||||
}
|
||||
(if start < t { start } else { Tick::ZERO }, t)
|
||||
};
|
||||
s.edit_sequence(if is_in { "Set Work Area Bar In Point" } else { "Set Work Area Bar Out Point" }, |q, _, _| {
|
||||
q.work_area = Some(TimeRange::from_bounds(start, end));
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"start": start.0, "end": end.0}))
|
||||
}
|
||||
|
||||
pub(crate) fn json_to_param(template: &ParamValue, v: &Value) -> Option<ParamValue> {
|
||||
Some(match template {
|
||||
ParamValue::Float(_) => ParamValue::Float(v.as_f64()?),
|
||||
@@ -3661,9 +3793,13 @@ fn copy_selection(s: &mut Session) {
|
||||
}
|
||||
s.state.clipboard = clip;
|
||||
s.state.clipboard_markers = markers;
|
||||
s.state.caption_clipboard.clear();
|
||||
}
|
||||
|
||||
fn paste(s: &mut Session, insert: bool) -> Result<Value> {
|
||||
if !s.state.caption_clipboard.is_empty() {
|
||||
return crate::captions::paste(s, insert);
|
||||
}
|
||||
let at = s.playhead();
|
||||
let clip = s.state.clipboard.clone();
|
||||
let markers = if s.state.copy_paste_sequence_markers { s.state.clipboard_markers.clone() } else { Vec::new() };
|
||||
|
||||
@@ -130,7 +130,7 @@ fn naming_nothing_usable_does_not_enable_a_command() {
|
||||
assert!(why.starts_with("-1 is not a clip id"), "{why}");
|
||||
assert_eq!(disabled(s.execute("clip.replaceFromBin", json!({"clips": [v1(&s)[0].id.0], "item": 987_654_321u64}))), "select a clip in the Project panel");
|
||||
// `edit.cut` takes no `clips`: it works on the selection only
|
||||
assert_eq!(disabled(s.execute("edit.cut", json!({"clips": [v1(&s)[0].id.0]}))), "no clips selected");
|
||||
assert_eq!(disabled(s.execute("edit.cut", json!({"clips": [v1(&s)[0].id.0]}))), "no clips or captions selected");
|
||||
assert!(std::sync::Arc::ptr_eq(&before, &s.project), "nothing was edited");
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
//! The History panel menu: `history.deleteRedoable` (Delete Redoable Actions) forgets the steps
|
||||
//! Redo would bring back, and `history.clear` (Clear History) forgets every step. Neither one
|
||||
//! changes the project or its unsaved state.
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
/// The demo project with three labeled edits, then two of them undone.
|
||||
fn three_edits_two_undone() -> Session {
|
||||
let mut s = Session::default();
|
||||
s.execute("file.openDemoProject", json!({})).unwrap();
|
||||
for name in ["A", "B", "C"] {
|
||||
s.execute("file.newBin", json!({"name": name})).unwrap();
|
||||
}
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_redoable_actions_forgets_the_undone_steps_only() {
|
||||
let mut s = three_edits_two_undone();
|
||||
let before = s.execute("history.list", json!({})).unwrap();
|
||||
assert_eq!(before["redo"].as_array().unwrap().len(), 2, "{before}");
|
||||
let project = s.project.clone();
|
||||
let dirty = s.is_dirty();
|
||||
|
||||
let r = s.execute("history.deleteRedoable", json!({})).unwrap();
|
||||
assert_eq!(r, json!({"deleted": 2}));
|
||||
let after = s.execute("history.list", json!({})).unwrap();
|
||||
assert_eq!(after["undo"], before["undo"], "the steps Undo takes back stay");
|
||||
assert_eq!(after["redo"], json!([]));
|
||||
assert_eq!(*s.project, *project, "the project does not change");
|
||||
assert_eq!(s.is_dirty(), dirty);
|
||||
assert!(s.execute("edit.redo", json!({})).is_err(), "nothing is left to redo");
|
||||
// Undo still takes back the step before
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
// with nothing to redo the command is disabled
|
||||
s.history.redo.clear();
|
||||
assert!(s.execute("history.deleteRedoable", json!({})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_history_forgets_every_step_and_keeps_the_project() {
|
||||
let mut s = three_edits_two_undone();
|
||||
let undo = s.history.undo.len();
|
||||
assert!(undo > 0);
|
||||
let project = s.project.clone();
|
||||
let dirty = s.is_dirty();
|
||||
|
||||
let r = s.execute("history.clear", json!({})).unwrap();
|
||||
assert_eq!(r, json!({"deleted": undo + 2}));
|
||||
assert_eq!(s.execute("history.list", json!({})).unwrap(), json!({"undo": [], "redo": []}));
|
||||
assert_eq!(*s.project, *project, "the project does not change");
|
||||
assert_eq!(s.is_dirty(), dirty);
|
||||
assert!(s.execute("edit.undo", json!({})).is_err());
|
||||
assert!(s.execute("history.clear", json!({})).is_err(), "an empty history has nothing to clear");
|
||||
|
||||
// a new edit starts a new history and can be undone
|
||||
s.execute("file.newBin", json!({"name": "D"})).unwrap();
|
||||
assert_eq!(s.history.undo.len(), 1);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(*s.project, *project);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn history_commands_ignore_unexpected_parameters() {
|
||||
for p in [json!(null), json!([1, 2]), json!({"deleted": -1}), json!("x")] {
|
||||
let mut s = three_edits_two_undone();
|
||||
let undo = s.history.undo.len();
|
||||
assert_eq!(s.execute("history.deleteRedoable", p.clone()).unwrap(), json!({"deleted": 2}), "{p}");
|
||||
assert_eq!(s.history.undo.len(), undo, "{p}");
|
||||
assert_eq!(s.execute("history.clear", p.clone()).unwrap(), json!({"deleted": undo}), "{p}");
|
||||
assert_eq!(s.execute("history.list", json!({})).unwrap(), json!({"undo": [], "redo": []}), "{p}");
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,5 @@
|
||||
//! Interchange documents (CMX 3600 EDL, FCP7 XML, FCPXML, OTIO, AAF, OMF) ↔ the session's project.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use filmcraft_interchange::{ExportOptions, Format, ImportOptions};
|
||||
@@ -26,27 +24,23 @@ fn is_file_media(item: &filmcraft_project::ProjectItem) -> bool {
|
||||
matches!(&item.kind, ItemKind::Media(m) if matches!(m.media, MediaRef::File { .. }))
|
||||
}
|
||||
|
||||
/// Write embedded audio without replacing an occupied path; return the path used.
|
||||
/// Write embedded audio without replacing an occupied path; return the path used. A numbered
|
||||
/// name changes only the file name: the folder keeps its separators as written (`/` or `\`).
|
||||
fn write_embedded_audio(s: &Session, path: &str, wav: &[u8]) -> Result<String> {
|
||||
let original = Path::new(path);
|
||||
let stem = original.file_stem().ok_or_else(|| EngineError::Other(format!("{path}: missing audio file name")))?;
|
||||
let (folder, name) = path.split_at(path.rfind(['/', '\\']).map_or(0, |i| i + 1));
|
||||
if matches!(name, "" | "." | "..") {
|
||||
return Err(EngineError::Other(format!("{path}: missing audio file name")));
|
||||
}
|
||||
let (stem, extension) = match name.rfind('.') {
|
||||
Some(i) if i > 0 => name.split_at(i),
|
||||
_ => (name, ""),
|
||||
};
|
||||
for number in 1..=1_000 {
|
||||
let candidate = if number == 1 {
|
||||
original.to_path_buf()
|
||||
} else {
|
||||
let mut name = stem.to_os_string();
|
||||
name.push(format!(" ({number})"));
|
||||
if let Some(extension) = original.extension() {
|
||||
name.push(".");
|
||||
name.push(extension);
|
||||
}
|
||||
original.with_file_name(name)
|
||||
};
|
||||
let candidate_text = candidate.to_string_lossy();
|
||||
match s.services.write_file_new(&candidate_text, wav) {
|
||||
Ok(()) => return Ok(candidate_text.into_owned()),
|
||||
let candidate = if number == 1 { path.to_owned() } else { format!("{folder}{stem} ({number}){extension}") };
|
||||
match s.services.write_file_new(&candidate, wav) {
|
||||
Ok(()) => return Ok(candidate),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {}
|
||||
Err(e) => return Err(EngineError::Other(format!("{candidate_text}: {e}"))),
|
||||
Err(e) => return Err(EngineError::Other(format!("{candidate}: {e}"))),
|
||||
}
|
||||
}
|
||||
Err(EngineError::Other(format!("{path}: no unused embedded-audio filename after 1000 attempts")))
|
||||
@@ -81,6 +75,11 @@ pub fn import(s: &mut Session, path: &str, bytes: &[u8], format: Format) -> Resu
|
||||
}
|
||||
_ => filmcraft_interchange::import_with(bytes, format, &opts).map_err(|e| EngineError::Other(format!("{path}: {e}")))?,
|
||||
};
|
||||
for item in fragment.project.items.values() {
|
||||
if let ItemKind::Sequence(sequence) = &item.kind {
|
||||
sequence.settings.validate().map_err(|e| EngineError::Other(format!("{path}: invalid imported sequence settings: {e}")))?;
|
||||
}
|
||||
}
|
||||
let before: std::collections::HashSet<ItemId> = s.project.items.keys().copied().collect();
|
||||
let name = opts.name.clone().unwrap_or_else(|| format.name().to_string());
|
||||
let seqs = s.edit(&format!("Import {name}"), |proj, _| {
|
||||
@@ -232,4 +231,16 @@ mod embedded_audio_tests {
|
||||
assert_eq!(stored["/files/cut Audio (2).wav"], b"second");
|
||||
assert_eq!(stored["/files/cut Audio (3).wav"], b"new audio");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numbered_names_keep_the_path_separators() {
|
||||
for path in ["C:/files/cut Audio.wav", "C:\\files\\cut Audio.wav"] {
|
||||
let files = Arc::new(VirtualFiles::default());
|
||||
files.0.lock().unwrap().insert(path.into(), b"first".to_vec());
|
||||
let session = Session::new(files.clone());
|
||||
let numbered = write_embedded_audio(&session, path, b"new audio").unwrap();
|
||||
assert_eq!(numbered, path.replace("cut Audio.wav", "cut Audio (2).wav"));
|
||||
assert_eq!(files.0.lock().unwrap()[path], b"first");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@ fn premiere_engine_entries_exist() {
|
||||
// UI-owned ids (tools, transport, panels…) are checked by the ui-egui keyboard test
|
||||
let ui_prefixes =
|
||||
["tool.", "playback.", "window.", "view.", "app.", "help.", "mode.", "multicam.", "projectPanel.", "textPanel.", "panel.", "mediaBrowser."];
|
||||
let ui_ids = ["timeline.expandAllTracks", "timeline.minimizeAllTracks", "graphics.beginTextEditing"];
|
||||
let ui_ids = ["timeline.expandAllTracks", "timeline.minimizeAllTracks", "graphics.beginTextEditing", "mixer.showHideTracks", "mixer.meterInputOnly"];
|
||||
for (id, _, _) in presets::premiere() {
|
||||
if id.starts_with("timeline.") && (id.contains("Height") || id.contains("Screen")) || ui_ids.contains(&id) {
|
||||
continue;
|
||||
|
||||
@@ -242,6 +242,22 @@ impl History {
|
||||
pub fn can_redo(&self) -> bool {
|
||||
!self.redo.is_empty()
|
||||
}
|
||||
/// The History panel's Delete Redoable Actions: removes every step that Redo would bring
|
||||
/// back. The project and the undo steps stay as they are. Returns how many steps were removed.
|
||||
pub fn delete_redoable(&mut self) -> usize {
|
||||
let n = self.redo.len();
|
||||
self.redo.clear();
|
||||
n
|
||||
}
|
||||
/// The History panel's Clear History: removes every undo and redo step. The project stays as
|
||||
/// it is. Returns how many steps were removed.
|
||||
pub fn clear(&mut self) -> usize {
|
||||
let n = self.undo.len() + self.redo.len();
|
||||
self.undo.clear();
|
||||
self.redo.clear();
|
||||
self.merge_key = None;
|
||||
n
|
||||
}
|
||||
}
|
||||
|
||||
/// Source patching / track targeting (the track header "V1/A1" source buttons + target toggles).
|
||||
@@ -369,6 +385,10 @@ pub struct EditorState {
|
||||
/// Sequence ▸ Show Through Edits: the timeline marks cuts between continuous pieces of a clip.
|
||||
#[serde(default)]
|
||||
pub show_through_edits: bool,
|
||||
/// Timeline Display Settings ▸ Show Duplicate Frame Markers: a stripe along the bottom of a
|
||||
/// clip under the media frames another clip also shows.
|
||||
#[serde(default)]
|
||||
pub show_duplicate_frame_markers: bool,
|
||||
/// Markers ▸ Ripple Sequence Markers: ripple edits move (and delete) sequence markers too.
|
||||
#[serde(default)]
|
||||
pub ripple_sequence_markers: bool,
|
||||
@@ -381,6 +401,11 @@ pub struct EditorState {
|
||||
/// Sequence markers copied with the clipboard clips (start relative to the copied range).
|
||||
#[serde(skip)]
|
||||
pub clipboard_markers: Vec<filmcraft_project::Marker>,
|
||||
/// Captions copied from the Timeline, with their caption track's index (starts relative to the
|
||||
/// copied range). Copying clips empties it and copying captions empties `clipboard`, so Paste
|
||||
/// places whichever was copied last.
|
||||
#[serde(skip)]
|
||||
pub caption_clipboard: Vec<(usize, filmcraft_project::Caption)>,
|
||||
/// The last Edit ▸ Find… (Find Next continues it).
|
||||
#[serde(default)]
|
||||
pub find: Option<project_tools::FindState>,
|
||||
@@ -1260,6 +1285,8 @@ mod file_tests;
|
||||
#[cfg(test)]
|
||||
mod gap_selection_tests;
|
||||
#[cfg(test)]
|
||||
mod history_tests;
|
||||
#[cfg(test)]
|
||||
mod image_sequence_tests;
|
||||
#[cfg(test)]
|
||||
mod individual_selection_tests;
|
||||
@@ -1349,6 +1376,8 @@ mod trim_tests;
|
||||
mod vfx_tests;
|
||||
#[cfg(test)]
|
||||
mod voiceover_tests;
|
||||
#[cfg(test)]
|
||||
mod work_area_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod source_placement_tests;
|
||||
|
||||
@@ -1027,7 +1027,7 @@ fn inside(dir: &std::path::Path, root: &std::path::Path) -> bool {
|
||||
|
||||
/// Remember the Scratch Disks folders chosen in this session, so projects naming them may use
|
||||
/// them later (see [`trusted_scratch`]).
|
||||
fn remember_scratch_folders(s: &mut Session, dirs: &[String]) {
|
||||
pub(crate) fn remember_scratch_folders(s: &mut Session, dirs: &[String]) {
|
||||
const KEEP: usize = 64;
|
||||
let mut prefs = s.prefs.clone();
|
||||
let list = &mut prefs.auto_save.scratch_folders;
|
||||
|
||||
@@ -508,13 +508,24 @@ pub fn ingest(s: &mut Session, items: &[ItemId]) -> Result<Value> {
|
||||
if !ing.enabled || items.is_empty() {
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
// Ingest settings travel in a project file. A destination only becomes active after this
|
||||
// machine has chosen it, or when it is safely inside the project folder.
|
||||
let destination = ing
|
||||
.destination
|
||||
.as_deref()
|
||||
.filter(|d| !d.is_empty())
|
||||
.and_then(|d| crate::project_tools::trusted_scratch(Some(d), s.path.as_deref(), &s.prefs.auto_save.scratch_folders));
|
||||
if ing.destination.as_deref().is_some_and(|d| !d.is_empty()) && destination.is_none() {
|
||||
s.toast("Ingest: ignoring an untrusted project destination; choose it locally in Ingest Settings to enable copying.");
|
||||
return Ok(json!({"skipped": "untrusted ingest destination"}));
|
||||
}
|
||||
let mut copied = Vec::new();
|
||||
let copy = matches!(ing.action, IngestAction::Copy | IngestAction::CopyAndCreateProxies);
|
||||
if copy {
|
||||
for &i in items {
|
||||
let Some((_, path, _)) = media_path(&s.project, i) else { continue };
|
||||
let dir = match &ing.destination {
|
||||
Some(d) if !d.is_empty() => PathBuf::from(d),
|
||||
let dir = match &destination {
|
||||
Some(d) => d.clone(),
|
||||
_ => Path::new(&path).parent().unwrap_or(Path::new(".")).join("Ingested Media"),
|
||||
};
|
||||
if Path::new(&path).file_name().is_some_and(|name| Path::new(&path) == dir.join(name)) {
|
||||
@@ -527,7 +538,7 @@ pub fn ingest(s: &mut Session, items: &[ItemId]) -> Result<Value> {
|
||||
let mut job = Value::Null;
|
||||
match ing.action {
|
||||
IngestAction::CreateProxies | IngestAction::CopyAndCreateProxies => {
|
||||
let p = json!({"items": items.iter().map(|i| i.0).collect::<Vec<_>>(), "preset": if ing.preset.is_empty() { DEFAULT_PROXY_PRESET } else { ing.preset.as_str() }, "destination": ing.destination});
|
||||
let p = json!({"items": items.iter().map(|i| i.0).collect::<Vec<_>>(), "preset": if ing.preset.is_empty() { DEFAULT_PROXY_PRESET } else { ing.preset.as_str() }, "destination": destination.as_ref().map(|d| d.to_string_lossy().into_owned())});
|
||||
if items.iter().any(|i| s.project.item(*i).and_then(|x| x.as_media()).is_some_and(|m| m.info.video.is_some() && m.info.kind == MediaKind::Movie)) {
|
||||
job = create(s, &p)?;
|
||||
}
|
||||
@@ -542,8 +553,8 @@ pub fn ingest(s: &mut Session, items: &[ItemId]) -> Result<Value> {
|
||||
if clip.info.video.is_none() {
|
||||
continue;
|
||||
}
|
||||
let dir = match &ing.destination {
|
||||
Some(d) if !d.is_empty() => PathBuf::from(d),
|
||||
let dir = match &destination {
|
||||
Some(d) => d.clone(),
|
||||
_ => Path::new(&path).parent().unwrap_or(Path::new(".")).join("Ingested Media"),
|
||||
};
|
||||
let stem = Path::new(&path).file_stem().map(|n| n.to_string_lossy().into_owned()).unwrap_or(name);
|
||||
@@ -680,6 +691,9 @@ pub fn commands() -> Vec<CommandSpec> {
|
||||
}
|
||||
if let Some(d) = p.get("destination") {
|
||||
ing.destination = d.as_str().filter(|d| !d.is_empty()).map(str::to_string);
|
||||
if let Some(d) = ing.destination.as_ref() {
|
||||
crate::project_tools::remember_scratch_folders(s, std::slice::from_ref(d));
|
||||
}
|
||||
}
|
||||
if let Some(pr) = str_p(p, "preset") {
|
||||
if !pr.is_empty() && preset(pr).is_none() {
|
||||
|
||||
@@ -9,7 +9,7 @@ use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
use crate::media_test_util::{frame_rgba, make_movie, psnr, session_with, tmp_dir};
|
||||
use crate::relink::{apply_remap, derive_remap, index_folder};
|
||||
use crate::relink::{apply_remap, derive_remap};
|
||||
|
||||
const W: u32 = 160;
|
||||
const H: u32 = 90;
|
||||
@@ -178,7 +178,7 @@ fn relink_search_does_not_follow_directory_symlinks() {
|
||||
symlink(&outside, inside.join("loop")).unwrap();
|
||||
symlink(outside.join("outside.mov"), inside.join("linked.mov")).unwrap();
|
||||
|
||||
let index = index_folder(&inside, 20_000);
|
||||
let index = crate::relink::index_folder(&inside, 20_000);
|
||||
assert!(index.contains_key("kept.mov"));
|
||||
assert!(!index.contains_key("outside.mov"));
|
||||
// a symlinked file is still media the user can relink to
|
||||
|
||||
@@ -102,13 +102,17 @@ fn analysis(s: &Session, item: ItemId, source_in: Tick, dur: Tick) -> Result<Arc
|
||||
let sr = s.active_sequence().map(|q| q.settings.sample_rate).unwrap_or(48_000).max(1);
|
||||
let m0 = source_in.to_units_floor(sr as i64);
|
||||
let len = (source_in + dur).to_units_floor(sr as i64) - m0;
|
||||
let max = i64::try_from(u64::from(sr).saturating_mul(dsp::MAX_ANALYSIS_SECONDS)).unwrap_or(i64::MAX);
|
||||
if !(0..=max).contains(&len) {
|
||||
return Err(EngineError::Other(format!("cannot remix: audio is longer than {} minutes", dsp::MAX_ANALYSIS_SECONDS / 60)));
|
||||
}
|
||||
let key = (item.0, m0, len, sr);
|
||||
if let Some((_, a)) = cache().lock().unwrap_or_else(|e| e.into_inner()).iter().find(|(k, _)| *k == key) {
|
||||
return Ok(a.clone());
|
||||
}
|
||||
let provider = s.media.provider(s.project.clone(), s.services.clone());
|
||||
let src = filmcraft_render::SourceProvider::source(&provider, item).ok_or_else(|| EngineError::Other("the clip's media is offline".into()))?;
|
||||
let buf = src.audio(m0, len.max(0) as usize, sr).map_err(|e| EngineError::Other(format!("reading the clip's audio: {e}")))?;
|
||||
let buf = src.audio(m0, len as usize, sr).map_err(|e| EngineError::Other(format!("reading the clip's audio: {e}")))?;
|
||||
let refs: Vec<&[f32]> = buf.channels.iter().map(Vec::as_slice).collect();
|
||||
let a = Arc::new(dsp::analyze(&refs, sr).map_err(|e| EngineError::Other(format!("cannot remix: {e}")))?);
|
||||
let mut c = cache().lock().unwrap_or_else(|e| e.into_inner());
|
||||
|
||||
@@ -45,6 +45,59 @@ fn roll_moves_linked_audio_cut() {
|
||||
assert_eq!(range(&s, a1), (f(60), f(96)));
|
||||
}
|
||||
|
||||
/// The delta the Timeline previews while a cut is rolled is the one `timeline.roll` applies on
|
||||
/// release: rolled far to the left, the cut stops where the right clip's media starts, 12 frames
|
||||
/// before its In point.
|
||||
#[test]
|
||||
fn roll_delta_stops_at_the_start_of_the_media() {
|
||||
let mut s = Session::default();
|
||||
s.execute("file.openDemoProject", json!({})).unwrap();
|
||||
s.execute("file.newSequence", json!({"name": "Roll", "video": 1, "audio": 1})).unwrap();
|
||||
let rate = s.sequence_rate();
|
||||
let f = |n: i64| rate.tick_of(n);
|
||||
let item = s.project.items.values().find(|i| i.has_video() && i.has_audio() && i.as_media().is_some()).map(|i| i.id).unwrap();
|
||||
for (frame, source_in) in [(0, 48), (48, 12)] {
|
||||
s.execute("timeline.place", json!({"item": item.0, "frame": frame, "sourceIn": f(source_in).0, "duration": f(48).0})).unwrap();
|
||||
}
|
||||
let q = s.active_sequence().unwrap();
|
||||
let (left, right) = (q.video_tracks[0].items[0].id, q.video_tracks[0].items[1].id);
|
||||
let far = Tick(-3600 * filmcraft_time::TICKS_PER_SECOND);
|
||||
let shown = crate::commands::roll_delta(&s, left, right, far).unwrap();
|
||||
assert_eq!(shown, -f(12), "clamped to the media before the right clip's In point");
|
||||
assert_eq!(crate::commands::roll_delta(&s, left, right, -f(5)).unwrap(), -f(5), "a roll within the media is not clamped");
|
||||
let r = s.execute("timeline.roll", json!({"left": left.0, "right": right.0, "delta": far.0})).unwrap();
|
||||
assert_eq!(r["delta"], json!(shown.0), "the preview and the roll agree");
|
||||
assert_eq!(range(&s, right), (f(36), f(96)));
|
||||
}
|
||||
|
||||
/// A clip whose Out point is past the end of its media (the file became shorter) cannot roll by
|
||||
/// a small amount: `edit::roll` would move its cut the other way. The roll moves nothing, and the
|
||||
/// preview and the roll both report 0.
|
||||
#[test]
|
||||
fn roll_delta_is_zero_when_a_clip_cannot_roll_by_the_asked_amount() {
|
||||
let mut s = Session::default();
|
||||
let [(v0, _), (v1, a1)] = two_clips(&mut s);
|
||||
let rate = s.sequence_rate();
|
||||
let f = |n: i64| rate.tick_of(n);
|
||||
let item = s.active_sequence().unwrap().find_item(v0).unwrap().1.item;
|
||||
match std::sync::Arc::make_mut(&mut s.project).items.get_mut(&item).map(|i| &mut i.kind) {
|
||||
Some(filmcraft_project::ItemKind::Media(m)) => m.info.duration = f(94),
|
||||
other => panic!("not media: {other:?}"),
|
||||
}
|
||||
for asked in [f(1), -f(1)] {
|
||||
assert_eq!(crate::commands::roll_delta(&s, v0, v1, asked).unwrap(), Tick::ZERO, "{asked:?}");
|
||||
let r = s.execute("timeline.roll", json!({"left": v0.0, "right": v1.0, "delta": asked.0})).unwrap();
|
||||
assert_eq!(r["delta"], json!(0), "{r}");
|
||||
assert_eq!(range(&s, v1), (f(48), f(96)), "the cut stays where it was");
|
||||
assert_eq!(range(&s, a1), (f(48), f(96)));
|
||||
}
|
||||
assert_eq!(crate::commands::roll_delta(&s, v0, v1, -f(5)).unwrap(), -f(5), "a roll back into the media applies");
|
||||
let r = s.execute("timeline.roll", json!({"left": v0.0, "right": v1.0, "delta": (-f(5)).0})).unwrap();
|
||||
assert_eq!(r["delta"], json!((-f(5)).0), "{r}");
|
||||
assert_eq!(range(&s, v1), (f(43), f(96)));
|
||||
assert_eq!(range(&s, a1), (f(43), f(96)));
|
||||
}
|
||||
|
||||
/// Linked selection off (or unlinked clips) rolls only the given pair and says so.
|
||||
#[test]
|
||||
fn roll_without_linked_selection_leaves_audio() {
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
//! | `sequence.showThroughEdits` | Sequence ▸ Show Through Edits | |
|
||||
//! | `sequence.joinThroughEdits` | (clip and edit point context menus) Join Through Edits | |
|
||||
//! | `sequence.throughEdits` | query: the through edits of the active sequence | |
|
||||
//! | `sequence.showDuplicateFrameMarkers` | (Timeline Display Settings) Show Duplicate Frame Markers | |
|
||||
//! | `sequence.duplicateFrames` | query: the duplicate frame markers of the active sequence | |
|
||||
//! | `sequence.makeSubsequence` | Sequence ▸ Make Subsequence | Shift+U |
|
||||
//! | `sequence.deleteTracks` | Sequence ▸ Delete Tracks… | |
|
||||
//! | `markers.markSplitVideoIn` … `markers.markSplitAudioOut` | Markers ▸ Mark Split ▸ Video In … Audio Out | |
|
||||
@@ -26,9 +28,12 @@
|
||||
//! that side. Insert / Overwrite from the Source monitor honour source split points: each channel
|
||||
//! takes its own range and lands offset by the difference of the In points (J- and L-cuts).
|
||||
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
|
||||
use filmcraft_edit as edit;
|
||||
use filmcraft_project::{AudioChannels, ClipId, ItemId, Label, Marker, MarkerId, MarkerKind, SplitMarks, TrackId, TrackItem, TrackKind};
|
||||
use filmcraft_time::{Tick, TimeRange};
|
||||
use filmcraft_media::MediaKind;
|
||||
use filmcraft_project::{AudioChannels, ClipId, ItemId, Label, Marker, MarkerId, MarkerKind, Project, Sequence, SplitMarks, TrackId, TrackItem, TrackKind};
|
||||
use filmcraft_time::{FrameRate, Tick, TimeRange};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::commands::{
|
||||
@@ -105,6 +110,22 @@ fn groups() -> Vec<(&'static str, Vec<CommandSpec>)> {
|
||||
s.state.show_through_edits = bool_p(p, "on").unwrap_or(!s.state.show_through_edits);
|
||||
Ok(json!({"showThroughEdits": s.state.show_through_edits}))
|
||||
}),
|
||||
spec("sequence.showDuplicateFrameMarkers", "Show Duplicate Frame Markers", &[], None, r#"{"on":bool?}"#, always, |s, p| {
|
||||
s.state.show_duplicate_frame_markers = bool_p(p, "on").unwrap_or(!s.state.show_duplicate_frame_markers);
|
||||
Ok(json!({"showDuplicateFrameMarkers": s.state.show_duplicate_frame_markers}))
|
||||
}),
|
||||
query("sequence.duplicateFrames", "List Duplicate Frames", "{}", |s, _| {
|
||||
let q = s.active_sequence().ok_or(EngineError::NoSequence)?;
|
||||
Ok(Value::Array(
|
||||
duplicate_frames(&s.project, q)
|
||||
.iter()
|
||||
.map(|d| {
|
||||
let ranges: Vec<Value> = d.ranges.iter().map(|r| json!({"start": r.start.0, "end": r.end().0})).collect();
|
||||
json!({"clip": d.clip.0, "color": d.color, "ranges": ranges})
|
||||
})
|
||||
.collect(),
|
||||
))
|
||||
}),
|
||||
spec(
|
||||
"sequence.joinThroughEdits",
|
||||
"Join Through Edits",
|
||||
@@ -376,6 +397,110 @@ fn join_through_edit_at(s: &mut Session, cut: &Value) -> Result<Value> {
|
||||
Ok(json!({"joined": n}))
|
||||
}
|
||||
|
||||
// ---------- duplicate frames ----------
|
||||
|
||||
/// How many colors duplicate frame markers use; master clips past the last one share it.
|
||||
pub const DUPLICATE_FRAME_COLORS: usize = 10;
|
||||
|
||||
/// One clip's duplicate frame markers: where on the timeline it shows media frames that another
|
||||
/// video clip of the same sequence also shows.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct DuplicateFrames {
|
||||
pub clip: ClipId,
|
||||
/// The color of the clip's master clip, 0 to [`DUPLICATE_FRAME_COLORS`] - 1: master clips with
|
||||
/// duplicate frames are numbered in the order they first appear in the sequence.
|
||||
pub color: usize,
|
||||
/// Timeline ranges inside the clip, in order and apart from each other.
|
||||
pub ranges: Vec<TimeRange>,
|
||||
}
|
||||
|
||||
/// The most timeline frames [`duplicate_frames`] compares in one sequence. A clip whose frames
|
||||
/// would pass that count, in timeline order, is not compared and gets no markers, and later clips
|
||||
/// that still fit are compared.
|
||||
pub const MAX_DUPLICATE_FRAME_CHECKS: i64 = 1_000_000;
|
||||
|
||||
/// Timeline Display Settings ▸ Show Duplicate Frame Markers: the video clips of `q` that show a
|
||||
/// media frame another video clip of `q` also shows. Each frame of the sequence is compared by the
|
||||
/// media frame the clip shows there ([`TrackItem::source_time_at`] at the frame's start, counted in
|
||||
/// the media's frame rate). A subclip counts as its master clip. Clips of still images,
|
||||
/// generators, nested sequences, graphics and adjustment layers, frame holds and clips whose Time
|
||||
/// Remapping speed has keyframes get no markers and are not compared.
|
||||
pub fn duplicate_frames(project: &Project, q: &Sequence) -> Vec<DuplicateFrames> {
|
||||
let rate = q.settings.frame_rate;
|
||||
// every clip that can be compared, with its master clip and the media frame rate, in timeline
|
||||
// order (track order for clips that start together)
|
||||
let mut clips: Vec<(u64, FrameRate, &TrackItem)> =
|
||||
q.video_tracks.iter().flat_map(|tr| &tr.items).filter_map(|it| comparable_master(project, it).map(|(m, r)| (m.0, r, it))).collect();
|
||||
clips.sort_by_key(|(_, _, it)| it.start);
|
||||
// the sequence frames each clip covers, while the limit allows
|
||||
let mut budget = MAX_DUPLICATE_FRAME_CHECKS;
|
||||
let spans: Vec<Option<(i64, i64)>> = clips
|
||||
.iter()
|
||||
.map(|(_, _, it)| {
|
||||
let first = rate.frame_at(it.start);
|
||||
let first = if rate.tick_of(first) < it.start { first.saturating_add(1) } else { first };
|
||||
let last = rate.frame_at(it.end() - Tick(1));
|
||||
let n = last.saturating_sub(first).saturating_add(1);
|
||||
(n > 0 && n <= budget).then(|| {
|
||||
budget -= n;
|
||||
(first, last)
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let shows = |media_rate: FrameRate, it: &TrackItem, f: i64| media_rate.frame_at(it.source_time_at(rate.tick_of(f)));
|
||||
// (master clip, media frame) → the first clip that shows it, and whether another clip does too
|
||||
let mut seen: HashMap<(u64, i64), (ClipId, bool)> = HashMap::new();
|
||||
for ((m, media_rate, it), span) in clips.iter().zip(&spans) {
|
||||
let Some((first, last)) = *span else { continue };
|
||||
for f in first..=last {
|
||||
let e = seen.entry((*m, shows(*media_rate, it, f))).or_insert((it.id, false));
|
||||
e.1 |= e.0 != it.id;
|
||||
}
|
||||
}
|
||||
// colors go to the master clips with a repeated frame, in the order they first appear
|
||||
let repeated: HashSet<u64> = seen.iter().filter(|(_, e)| e.1).map(|((m, _), _)| *m).collect();
|
||||
let mut colors: BTreeMap<u64, usize> = BTreeMap::new();
|
||||
for (m, ..) in clips.iter().filter(|(m, ..)| repeated.contains(m)) {
|
||||
let next = colors.len().min(DUPLICATE_FRAME_COLORS - 1);
|
||||
colors.entry(*m).or_insert(next);
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
for ((m, media_rate, it), span) in clips.iter().zip(&spans) {
|
||||
let Some((first, last)) = *span else { continue };
|
||||
let mut ranges: Vec<TimeRange> = Vec::new();
|
||||
let mut run: Option<i64> = None;
|
||||
for f in first..=last.saturating_add(1) {
|
||||
let repeated = f <= last && seen.get(&(*m, shows(*media_rate, it, f))).is_some_and(|e| e.1);
|
||||
match (repeated, run) {
|
||||
(true, None) => run = Some(f),
|
||||
(false, Some(from)) => {
|
||||
let (a, b) = (rate.tick_of(from).max(it.start), rate.tick_of(f).min(it.end()));
|
||||
ranges.push(TimeRange::new(a, b - a));
|
||||
run = None;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if !ranges.is_empty() {
|
||||
out.push(DuplicateFrames { clip: it.id, color: colors.get(m).copied().unwrap_or(0), ranges });
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The master clip `it` shows frames of and the master clip's frame rate, when its frames can be
|
||||
/// compared.
|
||||
fn comparable_master(project: &Project, it: &TrackItem) -> Option<(ItemId, FrameRate)> {
|
||||
if it.frame_hold.is_some() || !(it.speed.is_finite() && it.speed.abs() > 0.0) || it.duration <= Tick::ZERO {
|
||||
return None;
|
||||
}
|
||||
if it.effect("time_remap").and_then(|e| e.param("speed")).is_some_and(|p| p.is_animated()) {
|
||||
return None;
|
||||
}
|
||||
let (master, media, _) = project.resolve_media(it.item)?;
|
||||
(matches!(media.info.kind, MediaKind::Movie | MediaKind::ImageSequence) && media.info.video.is_some()).then(|| (master, media.frame_rate()))
|
||||
}
|
||||
|
||||
// ---------- subsequence ----------
|
||||
|
||||
/// Sequence ▸ Make Subsequence: a new sequence (same settings and track layout) holding copies of the
|
||||
|
||||
@@ -142,6 +142,171 @@ fn through_edits_show_and_join() {
|
||||
assert_eq!(s.execute("sequence.throughEdits", json!({})).unwrap().as_array().unwrap().len(), n - 2);
|
||||
}
|
||||
|
||||
/// The demo project with a new empty sequence active, and its first two movie items.
|
||||
fn demo_with_empty_sequence() -> (Session, u64, u64) {
|
||||
let mut s = demo();
|
||||
let mut movies: Vec<u64> = s
|
||||
.project
|
||||
.items
|
||||
.values()
|
||||
.filter(|i| i.as_media().is_some_and(|m| m.info.kind == filmcraft_media::MediaKind::Movie && m.info.video.is_some()))
|
||||
.map(|i| i.id.0)
|
||||
.collect();
|
||||
movies.sort();
|
||||
s.execute("file.newSequence", json!({"name": "Duplicates"})).unwrap();
|
||||
(s, movies[0], movies[1])
|
||||
}
|
||||
|
||||
/// The video clip on video track `track` (0 = V1) that starts at `frame`.
|
||||
fn video_clip_at(s: &Session, track: usize, frame: i64) -> u64 {
|
||||
let start = f(s, frame);
|
||||
s.active_sequence().unwrap().video_tracks[track].items.iter().find(|c| c.start == start).unwrap().id.0
|
||||
}
|
||||
|
||||
fn clip_duration(s: &Session, clip: u64) -> Tick {
|
||||
s.active_sequence().unwrap().find_item(ClipId(clip)).unwrap().1.duration
|
||||
}
|
||||
|
||||
/// `sequence.duplicateFrames` as (clip, color, [(start frame, end frame)]).
|
||||
fn duplicates(s: &mut Session) -> Vec<(u64, u64, Vec<(i64, i64)>)> {
|
||||
let frame = f(s, 1).0;
|
||||
let r = s.execute("sequence.duplicateFrames", json!({})).unwrap();
|
||||
r.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|d| {
|
||||
let ranges = d["ranges"].as_array().unwrap().iter().map(|r| (r["start"].as_i64().unwrap() / frame, r["end"].as_i64().unwrap() / frame)).collect();
|
||||
(d["clip"].as_u64().unwrap(), d["color"].as_u64().unwrap(), ranges)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_frame_markers_cover_the_repeated_frames() {
|
||||
let (mut s, forest, other) = demo_with_empty_sequence();
|
||||
assert_eq!(s.execute("sequence.showDuplicateFrameMarkers", json!({})).unwrap()["showDuplicateFrameMarkers"], json!(true));
|
||||
assert_eq!(s.execute("sequence.showDuplicateFrameMarkers", json!({"on": false})).unwrap()["showDuplicateFrameMarkers"], json!(false));
|
||||
// media frames 0–48 at 0, media frames 24–72 at 100: frames 24–48 are shown twice
|
||||
place(&mut s, forest, "V1", 0, 0, 48);
|
||||
place(&mut s, forest, "V1", 100, 24, 48);
|
||||
// another master clip, used once
|
||||
place(&mut s, other, "V2", 0, 0, 48);
|
||||
// a generator used twice has no markers
|
||||
let matte = s.execute("file.newColorMatte", json!({"width": 64, "height": 64, "color": "#ff0000"})).unwrap()["item"].as_u64().unwrap();
|
||||
s.execute("timeline.place", json!({"item": matte, "track": "V2", "frame": 200, "duration": f(&s, 24).0})).unwrap();
|
||||
s.execute("timeline.place", json!({"item": matte, "track": "V2", "frame": 300, "duration": f(&s, 24).0})).unwrap();
|
||||
let (a, b) = (video_clip_at(&s, 0, 0), video_clip_at(&s, 0, 100));
|
||||
assert_eq!(duplicates(&mut s), [(a, 0, vec![(24, 48)]), (b, 0, vec![(100, 124)])]);
|
||||
// the second master clip used again (frames 40–60 twice) takes the next color
|
||||
place(&mut s, other, "V2", 400, 40, 20);
|
||||
place(&mut s, other, "V3", 400, 40, 20);
|
||||
let (c, d, e) = (video_clip_at(&s, 1, 0), video_clip_at(&s, 1, 400), video_clip_at(&s, 2, 400));
|
||||
assert_eq!(
|
||||
duplicates(&mut s),
|
||||
[(a, 0, vec![(24, 48)]), (c, 1, vec![(40, 48)]), (b, 0, vec![(100, 124)]), (d, 1, vec![(400, 420)]), (e, 1, vec![(400, 420)])]
|
||||
);
|
||||
// clips that only touch in the media share no frame
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
place(&mut s, forest, "V3", 500, 72, 24);
|
||||
assert_eq!(duplicates(&mut s), [(a, 0, vec![(24, 48)]), (b, 0, vec![(100, 124)])]);
|
||||
}
|
||||
|
||||
/// Only master clips with duplicate frames take a color: a master clip used once ahead of a
|
||||
/// duplicated one leaves the first color to the duplicated one.
|
||||
#[test]
|
||||
fn duplicate_frame_markers_color_only_master_clips_with_duplicates() {
|
||||
let (mut s, forest, other) = demo_with_empty_sequence();
|
||||
place(&mut s, other, "V1", 0, 0, 48);
|
||||
place(&mut s, forest, "V1", 100, 0, 24);
|
||||
place(&mut s, forest, "V1", 200, 0, 24);
|
||||
let (a, b) = (video_clip_at(&s, 0, 100), video_clip_at(&s, 0, 200));
|
||||
assert_eq!(duplicates(&mut s), [(a, 0, vec![(100, 124)]), (b, 0, vec![(200, 224)])]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_frame_markers_follow_speed_reverse_and_holds() {
|
||||
let (mut s, forest, _) = demo_with_empty_sequence();
|
||||
place(&mut s, forest, "V1", 0, 0, 48);
|
||||
// at 200 % from media frame 24, timeline frames 100–112 show media frames 24, 26 … 46
|
||||
place(&mut s, forest, "V2", 100, 24, 24);
|
||||
let (a, b) = (video_clip_at(&s, 0, 0), video_clip_at(&s, 1, 100));
|
||||
let set = |s: &mut Session, clip: u64, change: fn(&mut filmcraft_project::TrackItem)| {
|
||||
s.edit_sequence("test", |q, _, _| {
|
||||
change(q.find_item_mut(ClipId(clip)).unwrap().1);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
};
|
||||
set(&mut s, b, |it| it.speed = 2.0);
|
||||
let every_other: Vec<(i64, i64)> = (24..48).step_by(2).map(|f| (f, f + 1)).collect();
|
||||
assert_eq!(duplicates(&mut s), [(a, 0, every_other), (b, 0, vec![(100, 112)])]);
|
||||
// reversed, 100 %: timeline frames 0–48 show media frames 47 down to 0, so 0–24 shows 24–48
|
||||
set(&mut s, b, |it| it.speed = 1.0);
|
||||
set(&mut s, a, |it| it.reverse = true);
|
||||
assert_eq!(duplicates(&mut s), [(a, 0, vec![(0, 24)]), (b, 0, vec![(100, 124)])]);
|
||||
// a frame hold and Time Remapping keyframes take a clip out of the comparison
|
||||
set(&mut s, a, |it| it.frame_hold = Some(it.source_in));
|
||||
assert!(duplicates(&mut s).is_empty());
|
||||
set(&mut s, a, |it| it.frame_hold = None);
|
||||
assert_eq!(duplicates(&mut s).len(), 2);
|
||||
// a clip longer than the comparison limit is skipped instead of walked frame by frame
|
||||
let dur = clip_duration(&s, a);
|
||||
set(&mut s, a, |it| it.duration = Tick(i64::MAX / 4));
|
||||
assert!(duplicates(&mut s).is_empty());
|
||||
s.edit_sequence("test", |q, _, _| {
|
||||
q.find_item_mut(ClipId(a)).unwrap().1.duration = dur;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(duplicates(&mut s).len(), 2);
|
||||
set(&mut s, b, |it| {
|
||||
let p = it
|
||||
.effect_mut("time_remap")
|
||||
.unwrap()
|
||||
.params
|
||||
.entry("speed".into())
|
||||
.or_insert_with(|| filmcraft_project::Param::new(filmcraft_project::ParamValue::Float(100.0)));
|
||||
p.keyframes.push(filmcraft_project::Keyframe::new(Tick::ZERO, filmcraft_project::ParamValue::Float(100.0)));
|
||||
p.keyframes.push(filmcraft_project::Keyframe::new(Tick(1), filmcraft_project::ParamValue::Float(50.0)));
|
||||
});
|
||||
assert!(duplicates(&mut s).is_empty());
|
||||
// parameters of the wrong type are ignored: `on` that is not a boolean toggles
|
||||
s.state.show_duplicate_frame_markers = false;
|
||||
for p in [json!({"on": "yes"}), json!({"on": [1]}), json!(null), json!(7)] {
|
||||
let on = s.state.show_duplicate_frame_markers;
|
||||
assert_eq!(s.execute("sequence.showDuplicateFrameMarkers", p).unwrap()["showDuplicateFrameMarkers"], json!(!on));
|
||||
}
|
||||
assert!(s.execute("sequence.duplicateFrames", json!({"clips": "x", "ranges": -1})).is_ok());
|
||||
// without a sequence there is nothing to list
|
||||
s.state.active_sequence = None;
|
||||
assert!(s.execute("sequence.duplicateFrames", json!({})).is_err());
|
||||
}
|
||||
|
||||
/// A clip at 200 % skips every other media frame: clips are compared by the frames they show,
|
||||
/// not by the media between their In and Out.
|
||||
#[test]
|
||||
fn duplicate_frame_markers_compare_the_frames_a_fast_clip_shows() {
|
||||
let (mut s, forest, _) = demo_with_empty_sequence();
|
||||
assert_eq!(s.active_sequence().unwrap().settings.frame_rate, s.project.item(ItemId(forest)).unwrap().as_media().unwrap().frame_rate());
|
||||
// media frames 0, 2, 4 … 46 and 1, 3, 5 … 47: no frame in common
|
||||
place(&mut s, forest, "V1", 0, 0, 24);
|
||||
place(&mut s, forest, "V2", 0, 1, 24);
|
||||
let (even, odd) = (video_clip_at(&s, 0, 0), video_clip_at(&s, 1, 0));
|
||||
for c in [even, odd] {
|
||||
s.edit_sequence("test", |q, _, _| {
|
||||
q.find_item_mut(ClipId(c)).unwrap().1.speed = 2.0;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
assert!(duplicates(&mut s).is_empty());
|
||||
// media frames 0 and 1 at 100 %: the even clip shows frame 0 at timeline frame 0
|
||||
place(&mut s, forest, "V3", 100, 0, 2);
|
||||
let slow = video_clip_at(&s, 2, 100);
|
||||
assert_eq!(duplicates(&mut s), [(even, 0, vec![(0, 1)]), (odd, 0, vec![(0, 1)]), (slow, 0, vec![(100, 102)])]);
|
||||
}
|
||||
|
||||
/// `cut: [left, right]` (the timeline's edit point menu, #219) joins exactly that cut and its
|
||||
/// linked partner, even when one of its pieces is also part of another through edit.
|
||||
#[test]
|
||||
|
||||
@@ -264,6 +264,7 @@ pub const PREMIERE: &[Entry] = &[
|
||||
("source.open", "Shift+O", "Project"),
|
||||
("edit.undo", "Left", "History"),
|
||||
("edit.redo", "Right", "History"),
|
||||
("history.deleteRedoable", "Backspace", "History"),
|
||||
];
|
||||
|
||||
/// Premiere's panel-scoped default keys for the keyboard-only commands (M3.12). FilmCraft Default
|
||||
@@ -289,6 +290,8 @@ pub const PREMIERE_PANEL: &[Entry] = &[
|
||||
("timeline.decreaseAudioHeight", "Alt+-", "Timeline"),
|
||||
("timeline.nextScreen", "PageDown", "Timeline"),
|
||||
("timeline.prevScreen", "PageUp", "Timeline"),
|
||||
("sequence.setWorkAreaIn", "Alt+[", "Timeline"),
|
||||
("sequence.setWorkAreaOut", "Alt+]", "Timeline"),
|
||||
("graphics.nudgeLeft", "Cmd+Left", "Program"),
|
||||
("graphics.nudgeRight", "Cmd+Right", "Program"),
|
||||
("graphics.nudgeUp", "Cmd+Up", "Program"),
|
||||
@@ -350,6 +353,8 @@ pub const PREMIERE_PANEL: &[Entry] = &[
|
||||
("captions.merge", "Alt+M", "Text"),
|
||||
("captions.split", "Alt+S", "Text"),
|
||||
("playback.loop", "Cmd+L", "Audio Track Mixer"),
|
||||
("mixer.showHideTracks", "Cmd+Alt+T", "Audio Track Mixer"),
|
||||
("mixer.meterInputOnly", "Ctrl+Shift+I", "Audio Track Mixer"),
|
||||
("playback.toggle", "Space", "Metadata"),
|
||||
("playback.loop", "Cmd+L", "Metadata"),
|
||||
("mediaBrowser.openInSource", "Shift+O", "Media Browser"),
|
||||
|
||||
@@ -288,7 +288,7 @@ fn list_searches_labels_and_keys() {
|
||||
let v = s.execute("shortcuts.list", json!({"query": "cmd+k", "platform": "windows"})).unwrap();
|
||||
assert!(v.as_array().unwrap().iter().any(|c| c["id"] == "sequence.addEdit"), "{v}");
|
||||
let v = s.execute("shortcuts.list", json!({"panel": "History", "assigned": true})).unwrap();
|
||||
assert_eq!(v.as_array().unwrap().len(), 2, "{v}");
|
||||
assert_eq!(v.as_array().unwrap().len(), 3, "{v}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
//! The work area bar: the Timeline panel menu's Work Area Bar shows and hides it, Set Work Area Bar
|
||||
//! In / Out Point (Alt+[ / Alt+] in the Timeline) move its ends to the playhead, `sequence.setWorkArea`
|
||||
//! sets both ends (a drag of the bar), and Export ▸ Range ▸ Work Area exports it.
|
||||
|
||||
use filmcraft_time::{Tick, TimeRange};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
use crate::shortcuts::{Chord, Platform};
|
||||
|
||||
fn demo() -> Session {
|
||||
let mut s = Session::default();
|
||||
s.execute("file.openDemoProject", json!({})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn area(s: &Session) -> Option<(Tick, Tick)> {
|
||||
s.active_sequence().unwrap().work_area.map(|w| (w.start, w.end()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_work_area_bar_spans_the_sequence_when_shown_and_undoes() {
|
||||
let mut s = demo();
|
||||
let end = s.active_sequence().unwrap().duration();
|
||||
assert!(end > Tick::ZERO);
|
||||
assert_eq!(area(&s), None, "the bar starts hidden");
|
||||
assert!(s.execute("export.resolve", json!({"range": "workArea"})).is_err(), "no work area to export");
|
||||
|
||||
assert_eq!(s.execute("sequence.workAreaBar", json!({})).unwrap(), json!({"show": true}));
|
||||
assert_eq!(area(&s), Some((Tick::ZERO, end)));
|
||||
assert_eq!(s.history.undo.last().map(|u| u.0.as_str()), Some("Work Area Bar"));
|
||||
let frames = s.execute("export.resolve", json!({"preset": "PNG Sequence", "range": "workArea"})).unwrap()["frames"].as_i64().unwrap();
|
||||
let whole = s.execute("export.resolve", json!({"preset": "PNG Sequence", "range": "entire"})).unwrap()["frames"].as_i64().unwrap();
|
||||
assert_eq!(frames, whole, "the work area covers the whole sequence");
|
||||
|
||||
// asking for the state it is already in changes nothing
|
||||
let steps = s.history.undo.len();
|
||||
assert_eq!(s.execute("sequence.workAreaBar", json!({"show": true})).unwrap(), json!({"show": true}));
|
||||
assert_eq!(s.history.undo.len(), steps);
|
||||
|
||||
assert_eq!(s.execute("sequence.workAreaBar", json!({})).unwrap(), json!({"show": false}));
|
||||
assert_eq!(area(&s), None);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(area(&s), Some((Tick::ZERO, end)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_work_area_in_and_out_points_follow_the_playhead() {
|
||||
let mut s = demo();
|
||||
let rate = s.sequence_rate();
|
||||
let f = |n: i64| rate.tick_of(n);
|
||||
let end = s.active_sequence().unwrap().duration();
|
||||
|
||||
// with no bar yet, the In point runs to the end of the sequence
|
||||
s.set_playhead(f(24));
|
||||
assert_eq!(s.execute("sequence.setWorkAreaIn", json!({})).unwrap(), json!({"start": f(24).0, "end": end.0}));
|
||||
assert_eq!(area(&s), Some((f(24), end)));
|
||||
assert_eq!(s.history.undo.last().map(|u| u.0.as_str()), Some("Set Work Area Bar In Point"));
|
||||
|
||||
s.set_playhead(f(72));
|
||||
s.execute("sequence.setWorkAreaOut", json!({})).unwrap();
|
||||
assert_eq!(area(&s), Some((f(24), f(72))));
|
||||
assert_eq!(s.history.undo.last().map(|u| u.0.as_str()), Some("Set Work Area Bar Out Point"));
|
||||
let frames = s.execute("export.resolve", json!({"preset": "PNG Sequence", "range": "workArea"})).unwrap()["frames"].as_i64().unwrap();
|
||||
assert!((47..=49).contains(&frames), "{frames}");
|
||||
|
||||
// an In point past the Out point moves the Out point to the end of the sequence
|
||||
s.execute("sequence.setWorkAreaIn", json!({"frame": 96})).unwrap();
|
||||
assert_eq!(area(&s), Some((f(96), end)));
|
||||
// an Out point before the In point moves the In point to the start
|
||||
s.execute("sequence.setWorkAreaOut", json!({"frame": 48})).unwrap();
|
||||
assert_eq!(area(&s), Some((Tick::ZERO, f(48))));
|
||||
// an In point at or past the end of the sequence keeps one frame
|
||||
s.execute("sequence.setWorkAreaIn", json!({"time": end.0})).unwrap();
|
||||
assert_eq!(area(&s), Some((end, end + rate.frame_duration())));
|
||||
|
||||
let before = area(&s);
|
||||
assert!(s.execute("sequence.setWorkAreaOut", json!({"time": 0})).is_err(), "an Out point at 0 leaves no work area");
|
||||
assert!(s.execute("sequence.setWorkAreaIn", json!({"time": -1})).is_err(), "negative times are refused");
|
||||
assert_eq!(area(&s), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_work_area_takes_both_ends_and_refuses_bad_ones() {
|
||||
let mut s = demo();
|
||||
let rate = s.sequence_rate();
|
||||
let (a, b) = (rate.tick_of(10), rate.tick_of(20));
|
||||
assert_eq!(s.execute("sequence.setWorkArea", json!({"start": a.0, "end": b.0})).unwrap(), json!({"start": a.0, "end": b.0}));
|
||||
assert_eq!(s.active_sequence().unwrap().work_area, Some(TimeRange::from_bounds(a, b)));
|
||||
for p in [
|
||||
json!({"start": b.0, "end": a.0}),
|
||||
json!({"start": a.0, "end": a.0}),
|
||||
json!({"start": -1, "end": b.0}),
|
||||
json!({"start": a.0}),
|
||||
json!({"end": b.0}),
|
||||
json!({"start": 0, "end": i64::MAX}),
|
||||
] {
|
||||
assert!(s.execute("sequence.setWorkArea", p.clone()).is_err(), "{p}");
|
||||
assert_eq!(s.active_sequence().unwrap().work_area, Some(TimeRange::from_bounds(a, b)), "{p}");
|
||||
}
|
||||
let mut empty = Session::default();
|
||||
for id in ["sequence.workAreaBar", "sequence.setWorkAreaIn", "sequence.setWorkAreaOut", "sequence.setWorkArea"] {
|
||||
assert!(empty.execute(id, json!({"start": 0, "end": 1})).is_err(), "{id} needs a sequence");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alt_brackets_set_the_work_area_in_the_timeline() {
|
||||
let s = Session::default();
|
||||
for p in [Platform::Mac, Platform::Windows] {
|
||||
for (keys, cmd) in [("Alt+[", "sequence.setWorkAreaIn"), ("Alt+]", "sequence.setWorkAreaOut")] {
|
||||
let chord = Chord::parse(keys).unwrap();
|
||||
assert_eq!(s.shortcuts.resolve(&chord, Some("Timeline"), p).map(|b| b.command.as_str()), Some(cmd), "{keys} {p:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
//! apply the Export effects (image, name and timecode overlays, video limiter) and convert to the
|
||||
//! encoder's input (straight sRGB RGBA8, or encoded R'G'B' floats for HDR).
|
||||
|
||||
use std::io::Cursor;
|
||||
use std::sync::Arc;
|
||||
|
||||
use filmcraft_geom::Vec2;
|
||||
@@ -14,6 +15,29 @@ use rayon::prelude::*;
|
||||
use crate::settings::{ExportEffects, Placement, Scaling, TextOverlay};
|
||||
use crate::{ExportError, ExportSettings, FrameRenderer, GpuRendering, Result, build_frame_renderer, note_gpu_fallback, note_gpu_frame};
|
||||
|
||||
/// Image overlays originate outside the project and are expanded to f32 working images. This
|
||||
/// admits ordinary 4K artwork while keeping both source decode and resampling bounded.
|
||||
const MAX_OVERLAY_PIXELS: usize = 16 << 20;
|
||||
|
||||
fn bounded_overlay_dimensions(w: usize, h: usize) -> Result<()> {
|
||||
if w == 0 || h == 0 || w.checked_mul(h).is_none_or(|pixels| pixels > MAX_OVERLAY_PIXELS) {
|
||||
return Err(ExportError::Unsupported("image overlay exceeds the pixel budget".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod overlay_budget_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn overlay_pixel_budget_is_checked_without_overflow() {
|
||||
assert!(bounded_overlay_dimensions(4096, 4096).is_ok());
|
||||
assert!(bounded_overlay_dimensions(4097, 4096).is_err());
|
||||
assert!(bounded_overlay_dimensions(usize::MAX, 2).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the rendered picture lands in the output frame.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub(crate) struct Geometry {
|
||||
@@ -392,8 +416,16 @@ fn composite(dst: &mut Image, src: &Image, x: i64, y: i64, opacity: f32) {
|
||||
/// Load a PNG / JPEG overlay as premultiplied linear light, `width` pixels wide.
|
||||
fn load_overlay(path: &str, width: f32) -> Result<Image> {
|
||||
let bytes = std::fs::read(path).map_err(|e| ExportError::Io(format!("image overlay {path}: {e}")))?;
|
||||
let rgba = image::load_from_memory(&bytes).map_err(|e| ExportError::Unsupported(format!("image overlay {path}: {e}")))?.to_rgba8();
|
||||
let mut reader = image::ImageReader::new(Cursor::new(bytes));
|
||||
let mut limits = image::Limits::default();
|
||||
limits.max_image_width = Some(16_384);
|
||||
limits.max_image_height = Some(16_384);
|
||||
limits.max_alloc = Some((MAX_OVERLAY_PIXELS * 4) as u64);
|
||||
reader.limits(limits);
|
||||
let reader = reader.with_guessed_format().map_err(|e| ExportError::Unsupported(format!("image overlay {path}: {e}")))?;
|
||||
let rgba = reader.decode().map_err(|e| ExportError::Unsupported(format!("image overlay {path}: {e}")))?.to_rgba8();
|
||||
let (iw, ih) = (rgba.width() as usize, rgba.height() as usize);
|
||||
bounded_overlay_dimensions(iw, ih)?;
|
||||
let mut img = Image::new(iw, ih);
|
||||
for (o, p) in img.px.as_chunks_mut::<4>().0.iter_mut().zip(rgba.as_raw().as_chunks::<4>().0) {
|
||||
let a = p[3] as f32 / 255.0;
|
||||
@@ -402,8 +434,12 @@ fn load_overlay(path: &str, width: f32) -> Result<Image> {
|
||||
}
|
||||
o[3] = a;
|
||||
}
|
||||
if !width.is_finite() || width <= 0.0 {
|
||||
return Err(ExportError::Unsupported("image overlay width is invalid".into()));
|
||||
}
|
||||
let nw = width.round().max(1.0) as usize;
|
||||
let nh = ((ih as f32 * nw as f32 / iw.max(1) as f32).round() as usize).max(1);
|
||||
let nh = ((ih as f64 * nw as f64 / iw.max(1) as f64).round() as usize).max(1);
|
||||
bounded_overlay_dimensions(nw, nh)?;
|
||||
Ok(if (nw, nh) == (iw, ih) { img } else { resample(&img, nw, nh) })
|
||||
}
|
||||
|
||||
|
||||
@@ -28,11 +28,21 @@ fn parent_dir(path: &Path) -> PathBuf {
|
||||
/// `fsync` the directory so the rename itself survives a power cut. If anything fails the temp file
|
||||
/// is removed and `path` is untouched.
|
||||
pub fn atomic_write(path: &Path, data: &[u8]) -> io::Result<()> {
|
||||
// `rename` replaces the inode, so a new staging file must inherit an existing project's
|
||||
// access mode instead of silently broadening a private file to the process default.
|
||||
let permissions = match fs::metadata(path) {
|
||||
Ok(meta) => Some(meta.permissions()),
|
||||
Err(e) if e.kind() == io::ErrorKind::NotFound => None,
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
let tmp = temp_path(path);
|
||||
let r = (|| {
|
||||
{
|
||||
// closed before the rename
|
||||
let mut f = OpenOptions::new().write(true).create_new(true).open(&tmp)?;
|
||||
if let Some(permissions) = permissions.as_ref() {
|
||||
f.set_permissions(permissions.clone())?;
|
||||
}
|
||||
f.write_all(data)?;
|
||||
f.sync_all()?;
|
||||
}
|
||||
@@ -122,6 +132,21 @@ mod tests {
|
||||
fs::remove_dir_all(&d).unwrap();
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn replacement_preserves_existing_file_permissions() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let d = tmpdir("permissions");
|
||||
let p = d.join("private.fcproj");
|
||||
fs::write(&p, b"before").unwrap();
|
||||
fs::set_permissions(&p, fs::Permissions::from_mode(0o600)).unwrap();
|
||||
atomic_write(&p, b"after").unwrap();
|
||||
assert_eq!(fs::read(&p).unwrap(), b"after");
|
||||
assert_eq!(fs::metadata(&p).unwrap().permissions().mode() & 0o777, 0o600);
|
||||
fs::remove_dir_all(&d).unwrap();
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn new_file_is_published_complete_without_clobbering_or_temp_leaks() {
|
||||
|
||||
@@ -216,6 +216,17 @@ pub fn decode(bytes: &[u8]) -> Result<Loaded, FormatError> {
|
||||
/// Refuse only values that would overflow or exhaust memory later (`Sequence::check_bounds`);
|
||||
/// structural rules such as overlaps are left to the editor, so projects that open today still open.
|
||||
fn validate_loaded(mut loaded: Loaded) -> Result<Loaded, FormatError> {
|
||||
// Embedded LUT text is rendered on the hot path. Drop oversize entries at load time so a
|
||||
// hostile project cannot retain an unrenderable multi-megabyte source or repeatedly offer it
|
||||
// to renderers. Clips that refer to one simply have no LUT, like a deleted library entry.
|
||||
let max_lut = filmcraft_project::MAX_LUT_TEXT_BYTES;
|
||||
loaded.project.luts.retain(|lut| {
|
||||
let keep = lut.text.len() <= max_lut;
|
||||
if !keep {
|
||||
log::warn!("LUT `{}`: {} bytes, dropping entry above {max_lut} byte limit", lut.name, lut.text.len());
|
||||
}
|
||||
keep
|
||||
});
|
||||
// Out-of-range audio stream data is repaired, not refused: failing the load would lose the
|
||||
// whole project over one bad index. A media item keeps its first `MAX_AUDIO_STREAMS` streams; a
|
||||
// clip pointing past that plays stream 0.
|
||||
@@ -223,9 +234,14 @@ fn validate_loaded(mut loaded: Loaded) -> Result<Loaded, FormatError> {
|
||||
for item in loaded.project.items.values_mut() {
|
||||
let name = item.name.clone();
|
||||
match &mut item.kind {
|
||||
filmcraft_project::ItemKind::Media(m) if m.info.audio_streams.len() > max => {
|
||||
log::warn!("media `{name}`: {} audio streams, keeping the first {max}", m.info.audio_streams.len());
|
||||
m.info.audio_streams.truncate(max);
|
||||
filmcraft_project::ItemKind::Media(m) => {
|
||||
if let Some(video) = &m.info.video {
|
||||
filmcraft_project::validate_frame_size(video.width, video.height).map_err(|e| FormatError::Corrupt(format!("media `{name}`: {e}")))?;
|
||||
}
|
||||
if m.info.audio_streams.len() > max {
|
||||
log::warn!("media `{name}`: {} audio streams, keeping the first {max}", m.info.audio_streams.len());
|
||||
m.info.audio_streams.truncate(max);
|
||||
}
|
||||
}
|
||||
filmcraft_project::ItemKind::Sequence(sequence) => {
|
||||
let sequence = std::sync::Arc::make_mut(sequence);
|
||||
@@ -434,6 +450,20 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_embedded_luts_are_dropped_on_load() {
|
||||
let mut p = Project::new("LUT limit");
|
||||
p.luts.push(filmcraft_project::ProjectLut {
|
||||
id: "huge".into(),
|
||||
name: "huge".into(),
|
||||
source_path: None,
|
||||
format: "cube".into(),
|
||||
text: "#".repeat(filmcraft_project::MAX_LUT_TEXT_BYTES + 1).into(),
|
||||
});
|
||||
let loaded = decode(&encode(&p, false)).unwrap();
|
||||
assert!(loaded.project.luts.is_empty());
|
||||
}
|
||||
|
||||
/// What is open is stored beside the project. A file without it, or with one that cannot be
|
||||
/// read, opens all the same.
|
||||
#[test]
|
||||
|
||||
@@ -85,6 +85,20 @@ fn hostile_audio_stream_counts_and_indices_are_repaired_not_rejected() {
|
||||
assert_eq!(loaded.item(ItemId(5)).unwrap().as_media().unwrap().info.audio_streams.len(), filmcraft_media::MAX_AUDIO_STREAMS);
|
||||
}
|
||||
|
||||
/// Persisted media metadata is used to allocate synthetic and offline source frames, so it must
|
||||
/// have the same bounded dimensions as a sequence before the project is opened.
|
||||
#[test]
|
||||
fn hostile_media_video_dimensions_are_rejected() {
|
||||
let mut p = decode(&fixture("v1-edit.fcproj")).unwrap().project;
|
||||
let media = p.item_mut(ItemId(5)).unwrap().as_media_mut().unwrap();
|
||||
let video = media.info.video.as_mut().unwrap();
|
||||
video.width = u32::MAX;
|
||||
video.height = 1;
|
||||
|
||||
let err = decode(&encode(&p, false)).unwrap_err();
|
||||
assert!(matches!(err, FormatError::Corrupt(ref message) if message.contains("media `Ocean_Sunset.mp4`") && message.contains("frame size")), "{err}");
|
||||
}
|
||||
|
||||
/// Schema 8 (before M3.10): no time interpolation / Hold Filters / Field Options / source channels
|
||||
/// on clips, no audio channel map, subclips without Restrict Trims.
|
||||
#[test]
|
||||
|
||||
+31
-5
@@ -139,6 +139,7 @@ impl VideoFrame {
|
||||
/// and bit depth unchanged. Reduced-resolution playback hands the GPU this instead of the full
|
||||
/// picture (a quarter / sixteenth of the upload). None for RGBA frames or `n` < 2.
|
||||
pub fn box_decimated(&self, n: usize) -> Option<VideoFrame> {
|
||||
let n = self.bounded_decimation(n);
|
||||
if n < 2 {
|
||||
return None;
|
||||
}
|
||||
@@ -285,7 +286,7 @@ impl VideoFrame {
|
||||
/// `decode` (a colour-managed curve: log, PQ, HLG scene light…) instead of the frame's
|
||||
/// transfer. Float frames are already linear and ignore it.
|
||||
pub fn to_linear_f32_decimated_with(&self, n: usize, decode: Option<&DecodeTable>) -> (usize, usize, Vec<f32>) {
|
||||
let n = n.max(1);
|
||||
let n = self.bounded_decimation(n);
|
||||
let (ow, oh) = self.decimated_size(n);
|
||||
// every element is written below, so a recycled buffer needs no zero-fill
|
||||
let mut out = pool::take_f32_overwritten(ow * oh * 4);
|
||||
@@ -295,7 +296,7 @@ impl VideoFrame {
|
||||
|
||||
/// The size of the picture after `n`×`n` box decimation.
|
||||
pub fn decimated_size(&self, n: usize) -> (usize, usize) {
|
||||
let n = n.max(1);
|
||||
let n = self.bounded_decimation(n);
|
||||
((self.width as usize / n).max(1), (self.height as usize / n).max(1))
|
||||
}
|
||||
|
||||
@@ -304,7 +305,7 @@ impl VideoFrame {
|
||||
/// by row. Each pixel is exactly what the full conversion gives at that position. The buffer
|
||||
/// may come from the pool ([`pool::recycle_f32`] hands it back).
|
||||
pub fn to_linear_f32_region(&self, n: usize, decode: Option<&DecodeTable>, region: Region) -> (Region, Vec<f32>) {
|
||||
let n = n.max(1);
|
||||
let n = self.bounded_decimation(n);
|
||||
let (ow, oh) = self.decimated_size(n);
|
||||
let r = region.clip(ow, oh);
|
||||
let mut out = pool::take_f32_overwritten(r.w * r.h * 4);
|
||||
@@ -323,6 +324,7 @@ impl VideoFrame {
|
||||
out.fill(0.0);
|
||||
return;
|
||||
}
|
||||
let n = n.min(w.max(h)).max(1);
|
||||
if let PixelData::RgbaF32(d) = &self.data
|
||||
&& n == 1
|
||||
{
|
||||
@@ -343,7 +345,7 @@ impl VideoFrame {
|
||||
Some(t) => t.lookup(v),
|
||||
None => lin[(v.clamp(0.0, 1.0) * 4095.0 + 0.5) as usize],
|
||||
};
|
||||
let inv = 1.0 / (n * n) as f32;
|
||||
let inv = 1.0 / (n as f32 * n as f32);
|
||||
match &self.data {
|
||||
PixelData::RgbaF32(d) => {
|
||||
out.par_chunks_mut(r.w * 4).enumerate().for_each(|(ry, row)| {
|
||||
@@ -477,7 +479,7 @@ impl VideoFrame {
|
||||
/// or its alpha is not stored as integers), so nothing can be skipped. Pixels outside the
|
||||
/// rectangle have alpha exactly 0 and, in the premultiplied conversion, colour 0 as well.
|
||||
pub fn alpha_region(&self, n: usize) -> Option<Region> {
|
||||
let n = n.max(1);
|
||||
let n = self.bounded_decimation(n);
|
||||
let (w, h) = (self.width as usize, self.height as usize);
|
||||
let px = w.checked_mul(h)?;
|
||||
// the first and last x of non-transparent pixels in source row `y`
|
||||
@@ -502,6 +504,12 @@ impl VideoFrame {
|
||||
Some(Region { x: rx0, y: ry0, w: rx1.saturating_sub(rx0), h: ry1.saturating_sub(ry0) }.clip(ow, oh))
|
||||
}
|
||||
|
||||
/// Keep hostile decimation requests from doing work beyond the source picture. Larger boxes
|
||||
/// only resample the edge pixels, so clamping them has no useful effect on the reduced output.
|
||||
fn bounded_decimation(&self, n: usize) -> usize {
|
||||
n.clamp(1, (self.width as usize).max(self.height as usize).max(1))
|
||||
}
|
||||
|
||||
/// Convert to straight-alpha sRGB RGBA8 for display (fast paths for 8-bit sources).
|
||||
pub fn to_rgba8(&self) -> Vec<u8> {
|
||||
let (w, h) = (self.width as usize, self.height as usize);
|
||||
@@ -830,6 +838,24 @@ mod tests {
|
||||
assert!(f.box_decimated(1).is_none());
|
||||
assert!(VideoFrame::rgba8(2, 2, vec![0; 16]).box_decimated(2).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decimation_caps_hostile_factors_to_the_frame_size() {
|
||||
let rgba = VideoFrame::rgba8(1, 1, vec![23, 42, 99, 255]);
|
||||
let (w, h, pixels) = rgba.to_linear_f32_decimated(usize::MAX);
|
||||
assert_eq!((w, h), (1, 1));
|
||||
assert_eq!(pixels, rgba.to_linear_f32());
|
||||
|
||||
let yuv = VideoFrame {
|
||||
width: 2,
|
||||
height: 2,
|
||||
data: PixelData::Yuv8 { planes: [Arc::new(vec![1, 2, 3, 4]), Arc::new(vec![128]), Arc::new(vec![128])], chroma: Chroma::C420, alpha: None },
|
||||
color: ColorInfo::REC709,
|
||||
par: (1, 1),
|
||||
pts: Tick::ZERO,
|
||||
};
|
||||
assert_eq!(yuv.box_decimated(usize::MAX).expect("yuv").luma8(), vec![3]);
|
||||
}
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -18,6 +18,10 @@ use filmcraft_bitstream::{BitReader, annexb_nals, length_prefixed_nals, unescape
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
/// The decoder supports at most 16 reference pictures, so constructing more
|
||||
/// inferred gap pictures cannot improve a usable reference list.
|
||||
const MAX_INFERRED_GAP_FRAMES: u32 = 16;
|
||||
|
||||
/// One slice of a picture job.
|
||||
struct SliceJob {
|
||||
sh: SliceHeader,
|
||||
@@ -234,7 +238,7 @@ impl Decoder {
|
||||
}
|
||||
}
|
||||
// The access unit is complete: hand its picture to a decoding job.
|
||||
self.submit_pending();
|
||||
self.submit_pending()?;
|
||||
if let Some(e) = self.take_error() {
|
||||
result = result.and(Err(e));
|
||||
}
|
||||
@@ -254,7 +258,7 @@ impl Decoder {
|
||||
|
||||
/// Output all remaining pictures (end of stream).
|
||||
pub fn flush(&mut self) -> Vec<Picture> {
|
||||
self.submit_pending();
|
||||
let _ = self.submit_pending();
|
||||
let mut outs = Vec::new();
|
||||
self.dpb.flush(&mut outs);
|
||||
self.emit(outs);
|
||||
@@ -304,7 +308,7 @@ impl Decoder {
|
||||
nal_type::SLICE_DPA | nal_type::SLICE_DPB | nal_type::SLICE_DPC => {
|
||||
return unsupported("data partitioning (Extended profile)");
|
||||
}
|
||||
nal_type::END_SEQ | nal_type::END_STREAM => self.submit_pending(),
|
||||
nal_type::END_SEQ | nal_type::END_STREAM => self.submit_pending()?,
|
||||
// SEI, AUD, filler, SPS extension, prefix NAL, subset SPS, auxiliary and extension slices: skipped.
|
||||
_ => {}
|
||||
}
|
||||
@@ -353,7 +357,7 @@ impl Decoder {
|
||||
Some(p) => is_new_picture(&p.first, &sh, &sps) || sh.first_mb_in_slice == 0 || !Arc::ptr_eq(&p.sps, &sps),
|
||||
};
|
||||
if new_pic {
|
||||
self.submit_pending();
|
||||
self.submit_pending()?;
|
||||
if sh.first_mb_in_slice != 0 && self.active_sps.is_none() {
|
||||
return invalid("stream does not start with the first slice of a picture");
|
||||
}
|
||||
@@ -425,6 +429,10 @@ impl Decoder {
|
||||
} else {
|
||||
let max = sps.max_frame_num();
|
||||
if sh.frame_num != self.prev_ref_frame_num && sh.frame_num != (self.prev_ref_frame_num + 1) % max {
|
||||
let gap = (sh.frame_num + max - self.prev_ref_frame_num - 1) % max;
|
||||
if gap > MAX_INFERRED_GAP_FRAMES {
|
||||
return Err(Error::Invalid("frame number gap exceeds the supported reference limit".into()));
|
||||
}
|
||||
// frame_num gap (8.2.5.2): insert "non-existing" frames. Their samples are never used by
|
||||
// conforming streams; copy the latest decoded frame for concealment.
|
||||
let meta = Arc::new(output_meta(sps, pts, false));
|
||||
@@ -464,8 +472,8 @@ impl Decoder {
|
||||
}
|
||||
|
||||
/// Start decoding the pending picture and do its reference marking / DPB insertion.
|
||||
fn submit_pending(&mut self) {
|
||||
let Some(p) = self.pending.take() else { return };
|
||||
fn submit_pending(&mut self) -> Result<()> {
|
||||
let Some(p) = self.pending.take() else { return Ok(()) };
|
||||
let PendingPic { frame, sps, first, poc, pts, key, slices } = p;
|
||||
let draft = self.draft && first.nal_ref_idc == 0;
|
||||
self.dispatch(frame.clone(), slices, draft);
|
||||
@@ -476,8 +484,9 @@ impl Decoder {
|
||||
let meta = Arc::new(OutputMeta { draft, ..output_meta(&sps, pts, key) });
|
||||
let mut outs = Vec::new();
|
||||
let fpoc = frame.poc;
|
||||
self.dpb.store_picture(&first, frame, fpoc, sps.max_frame_num(), sps.max_num_ref_frames as usize, meta, &mut outs);
|
||||
self.dpb.store_picture(&first, frame, fpoc, sps.max_frame_num(), sps.max_num_ref_frames as usize, meta, &mut outs)?;
|
||||
self.emit(outs);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn dispatch(&mut self, frame: FrameRef, slices: Vec<SliceJob>, draft: bool) {
|
||||
|
||||
+87
-5
@@ -138,8 +138,19 @@ impl<F: Clone> Dpb<F> {
|
||||
poc_state: &mut dyn FnMut(u32),
|
||||
) {
|
||||
let mut unused = (prev_ref_frame_num + 1) % max_frame_num;
|
||||
// Missing frames before the last `max_num_ref_frames` would be dropped by the sliding window
|
||||
// (oldest FrameNumWrap first) before the gap ends, so only their POC / frame_num bookkeeping
|
||||
// runs; building pictures for them would cost up to max_frame_num frames per access unit.
|
||||
let gap = if frame_num >= unused { frame_num - unused } else { (frame_num % max_frame_num) + max_frame_num - unused };
|
||||
let skip = gap.saturating_sub(max_num_ref_frames.max(1).try_into().unwrap_or(u32::MAX));
|
||||
let mut guard = 0;
|
||||
while unused != frame_num && guard < max_frame_num {
|
||||
if guard < skip {
|
||||
poc_state(unused);
|
||||
unused = (unused + 1) % max_frame_num;
|
||||
guard += 1;
|
||||
continue;
|
||||
}
|
||||
self.sliding_window(unused, max_frame_num, max_num_ref_frames);
|
||||
let (frame, poc, meta) = make_frame(unused);
|
||||
poc_state(unused);
|
||||
@@ -260,7 +271,7 @@ impl<F: Clone> Dpb<F> {
|
||||
}
|
||||
|
||||
/// Adaptive memory control (8.2.5.4). Returns true if MMCO 5 was executed.
|
||||
fn apply_mmcos(&mut self, mmcos: &[Mmco], cur_frame_num: u32, max_frame_num: u32, cur_long: &mut Option<u32>) -> bool {
|
||||
fn apply_mmcos(&mut self, mmcos: &[Mmco], cur_frame_num: u32, max_frame_num: u32, cur_long: &mut Option<u32>) -> Result<bool> {
|
||||
let mut had5 = false;
|
||||
let curr_pic_num = cur_frame_num as i32;
|
||||
for m in mmcos {
|
||||
@@ -281,6 +292,7 @@ impl<F: Clone> Dpb<F> {
|
||||
}
|
||||
}
|
||||
3 => {
|
||||
ensure!(self.max_long_term_frame_idx.is_some_and(|max| m.long_term_frame_idx <= max), "MMCO long-term index exceeds the configured limit");
|
||||
let pn = curr_pic_num - (m.difference_of_pic_nums_minus1 as i32 + 1);
|
||||
for e in &mut self.entries {
|
||||
if e.mark == RefMark::Long && e.long_term_frame_idx == m.long_term_frame_idx {
|
||||
@@ -311,6 +323,7 @@ impl<F: Clone> Dpb<F> {
|
||||
had5 = true;
|
||||
}
|
||||
6 => {
|
||||
ensure!(self.max_long_term_frame_idx.is_some_and(|max| m.long_term_frame_idx <= max), "MMCO long-term index exceeds the configured limit");
|
||||
for e in &mut self.entries {
|
||||
if e.mark == RefMark::Long && e.long_term_frame_idx == m.long_term_frame_idx {
|
||||
e.mark = RefMark::Unused;
|
||||
@@ -322,7 +335,7 @@ impl<F: Clone> Dpb<F> {
|
||||
}
|
||||
}
|
||||
self.prune();
|
||||
had5
|
||||
Ok(had5)
|
||||
}
|
||||
|
||||
/// Reference marking for the just-decoded picture and insertion into the DPB with output bumping.
|
||||
@@ -337,7 +350,7 @@ impl<F: Clone> Dpb<F> {
|
||||
max_num_ref_frames: usize,
|
||||
meta: Arc<OutputMeta>,
|
||||
out: &mut Vec<Output<F>>,
|
||||
) {
|
||||
) -> Result<()> {
|
||||
let mut mark = RefMark::Unused;
|
||||
let mut long_idx = 0;
|
||||
let mut had5 = false;
|
||||
@@ -353,7 +366,7 @@ impl<F: Clone> Dpb<F> {
|
||||
} else {
|
||||
let mut cur_long = None;
|
||||
if sh.adaptive_ref_pic_marking {
|
||||
had5 = self.apply_mmcos(&sh.mmcos, sh.frame_num, max_frame_num, &mut cur_long);
|
||||
had5 = self.apply_mmcos(&sh.mmcos, sh.frame_num, max_frame_num, &mut cur_long)?;
|
||||
} else {
|
||||
self.sliding_window(sh.frame_num, max_frame_num, max_num_ref_frames);
|
||||
}
|
||||
@@ -369,6 +382,9 @@ impl<F: Clone> Dpb<F> {
|
||||
}
|
||||
}
|
||||
}
|
||||
if mark == RefMark::Long {
|
||||
ensure!(self.num_refs() < max_num_ref_frames.max(1), "long-term reference exceeds max_num_ref_frames");
|
||||
}
|
||||
if had5 {
|
||||
// All earlier pictures are output before the current one (C.4.4).
|
||||
self.flush(out);
|
||||
@@ -385,7 +401,7 @@ impl<F: Clone> Dpb<F> {
|
||||
let min_poc = self.entries.iter().filter(|e| e.needed_for_output).map(|e| e.poc).min();
|
||||
if min_poc.is_none_or(|m| poc < m) {
|
||||
out.push(Output { frame, poc, meta });
|
||||
return;
|
||||
return Ok(());
|
||||
}
|
||||
if !self.bump(out) {
|
||||
break;
|
||||
@@ -405,6 +421,7 @@ impl<F: Clone> Dpb<F> {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -413,3 +430,68 @@ impl<F: Clone> Default for Dpb<F> {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn meta() -> Arc<OutputMeta> {
|
||||
Arc::new(OutputMeta {
|
||||
pts: 0,
|
||||
key: false,
|
||||
crop: (0, 0, 0, 0),
|
||||
full_range: false,
|
||||
colour_primaries: 2,
|
||||
transfer_characteristics: 2,
|
||||
matrix_coefficients: 2,
|
||||
sar: (1, 1),
|
||||
draft: false,
|
||||
})
|
||||
}
|
||||
|
||||
/// A huge frame_num jump must only materialise the frames the sliding window keeps (#2779).
|
||||
#[test]
|
||||
fn frame_num_gap_materialises_at_most_max_num_ref_frames() {
|
||||
let mut dpb: Dpb<u32> = Dpb::new();
|
||||
let m = meta();
|
||||
let mut made = 0u32;
|
||||
let mut make = |f: u32| {
|
||||
made += 1;
|
||||
(f, 0, m.clone())
|
||||
};
|
||||
let mut updated = Vec::new();
|
||||
let mut upd = |f: u32| updated.push(f);
|
||||
dpb.fill_frame_num_gap(0, 32768, 1 << 16, 4, &mut make, &mut upd);
|
||||
assert!(made <= 4, "gap fill materialised {made} frames, expected <= 4");
|
||||
assert_eq!(updated, (1..32768).collect::<Vec<u32>>());
|
||||
let mut refs: Vec<u32> = dpb.entries.iter().filter(|e| e.mark == RefMark::Short).map(|e| e.frame_num).collect();
|
||||
refs.sort_unstable();
|
||||
assert_eq!(refs, vec![32764, 32765, 32766, 32767]);
|
||||
assert!(dpb.entries.iter().all(|e| e.frame == e.frame_num));
|
||||
}
|
||||
|
||||
/// Existing short-term references are still evicted by a gap no longer than the window.
|
||||
#[test]
|
||||
fn short_frame_num_gap_keeps_sliding_window_semantics() {
|
||||
let mut dpb: Dpb<u32> = Dpb::new();
|
||||
let m = meta();
|
||||
for f in 0..2 {
|
||||
dpb.entries.push(DpbEntry {
|
||||
frame: f,
|
||||
poc: 0,
|
||||
frame_num: f,
|
||||
long_term_frame_idx: 0,
|
||||
mark: RefMark::Short,
|
||||
needed_for_output: false,
|
||||
non_existing: false,
|
||||
meta: m.clone(),
|
||||
});
|
||||
}
|
||||
let mut make = |f: u32| (f, 0, m.clone());
|
||||
let mut upd = |_f: u32| {};
|
||||
dpb.fill_frame_num_gap(1, 5, 16, 4, &mut make, &mut upd);
|
||||
let mut refs: Vec<u32> = dpb.entries.iter().filter(|e| e.mark == RefMark::Short).map(|e| e.frame_num).collect();
|
||||
refs.sort_unstable();
|
||||
assert_eq!(refs, vec![1, 2, 3, 4]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,10 @@ use crate::error::{Result, ensure, unsupported};
|
||||
use crate::tables::*;
|
||||
use filmcraft_bitstream::BitReader;
|
||||
|
||||
/// Maximum supported reconstructed luma area. 8K UHD fits comfortably while
|
||||
/// hostile SPS dimensions cannot allocate full pictures and decoder work state.
|
||||
const MAX_LUMA_SAMPLES: u64 = 1 << 25;
|
||||
|
||||
/// Scaling matrices after applying fall-back rules, stored in raster order (weightScale).
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ScalingMatrices {
|
||||
@@ -516,6 +520,9 @@ impl Sps {
|
||||
}
|
||||
|
||||
pub fn check_supported(&self) -> Result<()> {
|
||||
if u64::from(self.width()).saturating_mul(u64::from(self.height())) > MAX_LUMA_SAMPLES {
|
||||
return unsupported("picture dimensions exceed the decoder memory limit");
|
||||
}
|
||||
if !self.frame_mbs_only {
|
||||
return unsupported("interlaced (field / MBAFF) coding");
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# filmcraft-hevc
|
||||
|
||||
Clean-room, pure-Rust (no `unsafe`) H.265 / HEVC decoder, implemented from the public ITU-T
|
||||
Rec. H.265 (ISO/IEC 23008-2) specification. It is an L0 crate: it depends only on
|
||||
Rec. H.265 (08/2021, ISO/IEC 23008-2) specification. It is an L0 crate: it depends only on
|
||||
`filmcraft-bitstream`, `thiserror`, and optionally `rayon` (default feature `threads`), and builds
|
||||
for `wasm32-unknown-unknown`.
|
||||
|
||||
@@ -69,6 +69,11 @@ the memory traffic in prediction reads, in-loop filters and output) and as `u16`
|
||||
slice decoder and the filters are generic over the sample type (`picture::Sample`), monomorphised
|
||||
once per type.
|
||||
|
||||
Unavailable references keep distinct frame IDs and POCs while sharing one immutable gray backing
|
||||
for the active picture format. This backing is created only when a reference is missing, replaced
|
||||
on geometry or plane-depth changes, and released on flush. Jobs still reading an older format keep
|
||||
its rows alive. Each backing retains the full coded picture dimensions.
|
||||
|
||||
Draft mode (`Decoder::set_draft(true)`; FilmCraft enables it with Settings ▸ Playback ▸ Draft
|
||||
decoding while the Program monitor plays at 1/2 or 1/4): sub-layer non-reference pictures
|
||||
(TRAIL_N, RASL_N, ...) of the highest temporal sub-layer — no later picture predicts from them,
|
||||
|
||||
+145
-1
@@ -77,6 +77,8 @@ pub struct Decoder {
|
||||
pending: Option<PendingPic>,
|
||||
next_id: u32,
|
||||
active_sps: Option<Arc<Sps>>,
|
||||
/// One lazily allocated gray backing for unavailable references in the active format.
|
||||
gray_reference: Option<Frame>,
|
||||
first_picture: bool,
|
||||
after_eos: bool,
|
||||
/// RASL pictures associated with the last IRAP are skipped (it had NoRaslOutputFlag = 1).
|
||||
@@ -132,6 +134,7 @@ impl Decoder {
|
||||
pending: None,
|
||||
next_id: 1,
|
||||
active_sps: None,
|
||||
gray_reference: None,
|
||||
first_picture: true,
|
||||
after_eos: false,
|
||||
skip_rasl: false,
|
||||
@@ -225,6 +228,7 @@ impl Decoder {
|
||||
f.wait_complete();
|
||||
}
|
||||
self.first_picture = true;
|
||||
self.gray_reference = None;
|
||||
pics
|
||||
}
|
||||
|
||||
@@ -392,6 +396,9 @@ impl Decoder {
|
||||
if changed && self.active_sps.is_some() {
|
||||
self.dpb.flush(&mut outs);
|
||||
}
|
||||
if changed {
|
||||
self.gray_reference = None;
|
||||
}
|
||||
self.active_sps = Some(sps.clone());
|
||||
self.dpb.max_dec_pic_buffering = sps.max_dec_pic_buffering as usize;
|
||||
self.dpb.max_num_reorder = sps.max_num_reorder as usize;
|
||||
@@ -412,12 +419,14 @@ impl Decoder {
|
||||
let (w, h) = (sps.width as usize, sps.height as usize);
|
||||
let template = Frame::new(0, 0, w, h, sps.log2_ctb, sps.bit_depth_luma, sps.bit_depth_chroma);
|
||||
let next_id = &mut self.next_id;
|
||||
let gray_reference = &mut self.gray_reference;
|
||||
let mut missing = 0u64;
|
||||
let mut make_missing = |p: i32| -> FrameRef {
|
||||
missing += 1;
|
||||
let id = *next_id;
|
||||
*next_id += 1;
|
||||
Arc::new(Frame::gray(id, p, &template))
|
||||
let gray = gray_reference.get_or_insert_with(|| Frame::gray(0, 0, &template));
|
||||
Arc::new(gray.with_identity(id, p))
|
||||
};
|
||||
let rps = self.dpb.apply_rps(sh, poc, max_lsb, irap_no_rasl, &mut make_missing)?;
|
||||
if missing > 0 {
|
||||
@@ -635,6 +644,141 @@ fn make_picture(o: &Output<FrameRef>) -> Picture {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn missing_refs(decoder: &mut Decoder, poc: u32, deltas: &[i32]) -> Vec<FrameRef> {
|
||||
let stream = crate::synth_tests::missing_reference_picture(poc, deltas);
|
||||
for nal in annexb_nals(&stream) {
|
||||
decoder.handle_nal(nal, i64::from(poc)).unwrap();
|
||||
}
|
||||
decoder.pending.as_ref().unwrap().rps.st_curr_before.iter().map(|r| r.frame.clone()).collect()
|
||||
}
|
||||
|
||||
fn assert_shared_gray_rows(a: &Frame, b: &Frame) {
|
||||
fn check<S: Sample>(a: &Frame, b: &Frame) {
|
||||
let (gy, gc) = (1u16 << (a.bit_depth - 1), 1u16 << (a.bit_depth_c - 1));
|
||||
for row in 0..a.num_rows() {
|
||||
let a = a.row::<S>(row).unwrap();
|
||||
let b = b.row::<S>(row).unwrap();
|
||||
assert!(a.y.iter().all(|v| v.to_u16() == gy));
|
||||
assert!(a.cb.iter().chain(a.cr.iter()).all(|v| v.to_u16() == gc));
|
||||
assert!(a.col.iter().all(|v| v.flags == 0));
|
||||
assert!(std::ptr::eq(a.y.as_ref(), b.y.as_ref()), "missing references duplicated luma row {row}");
|
||||
assert!(std::ptr::eq(a.cb.as_ref(), b.cb.as_ref()), "missing references duplicated Cb row {row}");
|
||||
assert!(std::ptr::eq(a.cr.as_ref(), b.cr.as_ref()), "missing references duplicated Cr row {row}");
|
||||
assert!(std::ptr::eq(a.col.as_ref(), b.col.as_ref()), "missing references duplicated motion row {row}");
|
||||
}
|
||||
}
|
||||
assert_eq!(a.num_rows(), b.num_rows());
|
||||
assert!(a.is_complete() && b.is_complete());
|
||||
assert_eq!((a.width, a.height, a.log2_ctb, a.bit_depth, a.bit_depth_c), (b.width, b.height, b.log2_ctb, b.bit_depth, b.bit_depth_c));
|
||||
if stores_u8(a.bit_depth, a.bit_depth_c) {
|
||||
check::<u8>(a, b);
|
||||
} else {
|
||||
check::<u16>(a, b);
|
||||
}
|
||||
}
|
||||
|
||||
fn backing(frame: &Frame) -> usize {
|
||||
if stores_u8(frame.bit_depth, frame.bit_depth_c) {
|
||||
frame.row::<u8>(0).unwrap().y.as_ptr() as usize
|
||||
} else {
|
||||
frame.row::<u16>(0).unwrap().y.as_ptr() as usize
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Missing references keep their identities while sharing one small immutable gray backing.
|
||||
fn missing_reference_rows_are_shared_with_distinct_identities() {
|
||||
for threads in [1, 4] {
|
||||
let mut decoder = Decoder::with_threads(threads);
|
||||
let refs = missing_refs(&mut decoder, 4, &[-1, -2]);
|
||||
assert_eq!(refs.len(), 2);
|
||||
assert_eq!((refs[0].poc, refs[1].poc), (3, 2));
|
||||
assert_ne!(refs[0].id, refs[1].id);
|
||||
assert_shared_gray_rows(&refs[0], &refs[1]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Concurrent/pending pictures must not each allocate their own full gray samples.
|
||||
fn missing_reference_rows_are_shared_across_pictures() {
|
||||
for threads in [1, 4] {
|
||||
let mut decoder = Decoder::with_threads(threads);
|
||||
let first = missing_refs(&mut decoder, 4, &[-1, -2]);
|
||||
let second = missing_refs(&mut decoder, 5, &[-2, -3]);
|
||||
assert_eq!((first.len(), second.len()), (2, 2));
|
||||
assert_eq!((second[0].poc, second[1].poc), (3, 2));
|
||||
let ids: std::collections::HashSet<_> = first.iter().chain(second.iter()).map(|f| f.id).collect();
|
||||
assert_eq!(ids.len(), 4);
|
||||
for frame in first.iter().chain(second.iter()) {
|
||||
assert_shared_gray_rows(&first[0], frame);
|
||||
}
|
||||
let pictures = decoder.flush();
|
||||
assert_eq!(pictures.len(), 2);
|
||||
for picture in pictures {
|
||||
assert!(picture.y.as_u8().unwrap().iter().chain(picture.u.as_u8().unwrap()).chain(picture.v.as_u8().unwrap()).all(|&v| v == 128));
|
||||
}
|
||||
assert!(decoder.take_error().is_none());
|
||||
assert_eq!(decoder.stats().missing_refs, 4);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Geometry and independent plane-depth changes replace the cache without invalidating readers.
|
||||
fn missing_reference_cache_tracks_format_and_keeps_old_rows_alive() {
|
||||
let mut decoder = Decoder::with_threads(1);
|
||||
let mut held = missing_refs(&mut decoder, 4, &[-1, -2]);
|
||||
let original = decoder.pending.take().unwrap();
|
||||
let mut previous = (96, 64, 4, 8, 8);
|
||||
let formats = [
|
||||
(96, 64, 4, 8, 8),
|
||||
(128, 64, 4, 8, 8),
|
||||
(128, 32, 4, 8, 8),
|
||||
(128, 32, 5, 8, 8),
|
||||
(128, 32, 5, 10, 8),
|
||||
(128, 32, 5, 10, 10),
|
||||
(128, 32, 5, 8, 10),
|
||||
(128, 32, 5, 8, 8),
|
||||
];
|
||||
for format in formats {
|
||||
let mut sps = (*original.sps).clone();
|
||||
(sps.width, sps.height, sps.log2_ctb, sps.bit_depth_luma, sps.bit_depth_chroma) = format;
|
||||
decoder.start_picture(&original.first_sh, &Arc::new(sps), &original.pps, 0).unwrap();
|
||||
let pending = decoder.pending.take().unwrap();
|
||||
let refs: Vec<_> = pending.rps.st_curr_before.iter().map(|r| r.frame.clone()).collect();
|
||||
assert_shared_gray_rows(&refs[0], &refs[1]);
|
||||
if format == previous {
|
||||
assert_eq!(backing(held.last().unwrap()), backing(&refs[0]));
|
||||
} else {
|
||||
assert_ne!(backing(held.last().unwrap()), backing(&refs[0]));
|
||||
}
|
||||
assert_ne!(held.last().unwrap().id, refs[0].id);
|
||||
held.extend(refs);
|
||||
previous = format;
|
||||
}
|
||||
for frame in held {
|
||||
assert_shared_gray_rows(&frame, &frame);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Ordinary streams allocate no gray cache; flush releases it even with retained reference rows.
|
||||
fn missing_reference_cache_is_lazy_and_released_on_flush() {
|
||||
let mut decoder = Decoder::with_threads(1);
|
||||
let stream = crate::synth_tests::missing_reference_picture(0, &[]);
|
||||
let mut pictures = decoder.decode(&stream, 0).unwrap();
|
||||
assert!(decoder.gray_reference.is_none());
|
||||
pictures.extend(decoder.flush());
|
||||
assert_eq!(pictures.len(), 1);
|
||||
let refs = missing_refs(&mut decoder, 4, &[-1, -2]);
|
||||
assert!(decoder.gray_reference.is_some());
|
||||
assert_eq!(decoder.flush().len(), 1);
|
||||
assert!(decoder.gray_reference.is_none());
|
||||
let next = missing_refs(&mut decoder, 7, &[-1, -2]);
|
||||
assert_ne!(backing(&refs[0]), backing(&next[0]));
|
||||
assert_shared_gray_rows(&refs[0], &refs[1]);
|
||||
assert_shared_gray_rows(&next[0], &next[1]);
|
||||
}
|
||||
|
||||
/// The branch-free block counts classify every flag combination as the if / else-if chain did.
|
||||
#[test]
|
||||
fn block_counts_match_the_classification_chain() {
|
||||
|
||||
@@ -61,7 +61,7 @@ impl Sample for u8 {
|
||||
}
|
||||
}
|
||||
fn wrap_rows(rows: Box<[OnceLock<FrameRow<u8>>]>) -> Rows {
|
||||
Rows::U8(rows)
|
||||
Rows::U8(rows.into())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,7 +94,7 @@ impl Sample for u16 {
|
||||
}
|
||||
}
|
||||
fn wrap_rows(rows: Box<[OnceLock<FrameRow<u16>>]>) -> Rows {
|
||||
Rows::U16(rows)
|
||||
Rows::U16(rows.into())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,9 +114,10 @@ pub struct FrameRow<S> {
|
||||
}
|
||||
|
||||
/// The CTB rows of a frame, in its storage type.
|
||||
#[derive(Clone)]
|
||||
pub enum Rows {
|
||||
U8(Box<[OnceLock<FrameRow<u8>>]>),
|
||||
U16(Box<[OnceLock<FrameRow<u16>>]>),
|
||||
U8(Arc<[OnceLock<FrameRow<u8>>]>),
|
||||
U16(Arc<[OnceLock<FrameRow<u16>>]>),
|
||||
}
|
||||
|
||||
/// A decoded (or in-progress) picture as stored in the DPB and referenced by later pictures.
|
||||
@@ -391,6 +392,23 @@ impl Frame {
|
||||
f
|
||||
}
|
||||
|
||||
/// A distinct logical frame sharing this frame's rows. Used for fully published, immutable
|
||||
/// gray references: deblocking needs a unique frame ID even when their samples are identical.
|
||||
pub fn with_identity(&self, id: u32, poc: i32) -> Frame {
|
||||
Frame {
|
||||
id,
|
||||
poc,
|
||||
width: self.width,
|
||||
height: self.height,
|
||||
cwidth: self.cwidth,
|
||||
cheight: self.cheight,
|
||||
log2_ctb: self.log2_ctb,
|
||||
bit_depth: self.bit_depth,
|
||||
bit_depth_c: self.bit_depth_c,
|
||||
rows: self.rows.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// A frame with every row published from full-picture planes and no motion.
|
||||
#[cfg(test)]
|
||||
pub fn from_planes(id: u32, poc: i32, like: &Frame, y: &[u16], cb: &[u16], cr: &[u16]) -> Frame {
|
||||
|
||||
@@ -457,6 +457,28 @@ fn whole(n: usize) -> Vec<Segment> {
|
||||
vec![Segment { start: 0, len: n, dependent: false }]
|
||||
}
|
||||
|
||||
/// Tiny parsed-picture fixture for missing-reference storage regressions in the decoder.
|
||||
pub(crate) fn missing_reference_picture(poc: u32, deltas: &[i32]) -> Vec<u8> {
|
||||
let mut stream = Vec::new();
|
||||
stream.extend(vps());
|
||||
stream.extend(sps(true));
|
||||
stream.extend(pps(0, &None, false));
|
||||
let picture = PicDesc {
|
||||
nal_type: 1,
|
||||
poc,
|
||||
intra: deltas.is_empty(),
|
||||
cu: if deltas.is_empty() { Cu::Pcm } else { Cu::Skip },
|
||||
pps_id: 0,
|
||||
st: deltas.iter().map(|&d| (d, true)).collect(),
|
||||
lt: vec![],
|
||||
num_ref: deltas.len() as u32,
|
||||
list_entry: None,
|
||||
segments: whole(WC * HC),
|
||||
};
|
||||
encode_picture(&picture, &None, &content(0), &mut stream, &mut None);
|
||||
stream
|
||||
}
|
||||
|
||||
/// Encode the test sequence; returns the stream and the exact expected pictures (when the PCM samples
|
||||
/// are not filtered).
|
||||
fn sequence(tiles: &Tiles, pcm_lf_disabled: bool, segments: Vec<Segment>) -> (Vec<u8>, Vec<Planes>) {
|
||||
|
||||
@@ -187,7 +187,9 @@ w("")
|
||||
def taps(eq_no, var, n):
|
||||
for l in lines:
|
||||
if "(%s)" % eq_no in l:
|
||||
expr = l.split("=", 1)[1].split(">>")[0]
|
||||
expr = " ".join(l.split("=", 1)[1].split(">>")[0].split())
|
||||
if len(expr) > 8192:
|
||||
raise SystemExit("equation %s is unexpectedly long" % eq_no)
|
||||
coef = {}
|
||||
for sign, c, k in re.findall(r"([+-]?)\s*(?:(\d+)\s*\*\s*)?%s0,\s*(-?\d)" % var, expr):
|
||||
coef[int(k)] = (-1 if sign == "-" else 1) * (int(c) if c else 1)
|
||||
|
||||
@@ -359,10 +359,12 @@ struct R<'a> {
|
||||
cf: CompoundFile<'a>,
|
||||
paths: Vec<Vec<u16>>,
|
||||
objects: usize,
|
||||
materialized_stream_bytes: usize,
|
||||
}
|
||||
|
||||
const MAX_DEPTH: usize = 200;
|
||||
const MAX_OBJECTS: usize = 2_000_000;
|
||||
const MAX_MATERIALIZED_STREAM_BYTES: usize = 256 << 20;
|
||||
|
||||
/// Parse the object tree of an AAF compound file (the root object).
|
||||
pub(crate) fn read(bytes: &[u8]) -> Result<Obj, String> {
|
||||
@@ -385,7 +387,7 @@ pub(crate) fn read(bytes: &[u8]) -> Result<Obj, String> {
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut r = R { cf, paths, objects: 0 };
|
||||
let mut r = R { cf, paths, objects: 0, materialized_stream_bytes: 0 };
|
||||
r.obj(0, 0)
|
||||
}
|
||||
|
||||
@@ -401,9 +403,15 @@ impl R<'_> {
|
||||
self.cf.child(parent, name).ok_or_else(|| bad(format!("missing storage or stream \"{name}\"")))
|
||||
}
|
||||
|
||||
fn stream(&self, parent: usize, name: &str) -> Result<Vec<u8>, String> {
|
||||
fn stream(&mut self, parent: usize, name: &str) -> Result<Vec<u8>, String> {
|
||||
let id = self.child(parent, name)?;
|
||||
self.cf.read(id).map_err(|e| e.to_string())
|
||||
let data = self.cf.read(id).map_err(|e| e.to_string())?;
|
||||
self.materialized_stream_bytes = self
|
||||
.materialized_stream_bytes
|
||||
.checked_add(data.len())
|
||||
.filter(|&n| n <= MAX_MATERIALIZED_STREAM_BYTES)
|
||||
.ok_or_else(|| bad("AAF materialized stream data exceeds the memory limit"))?;
|
||||
Ok(data)
|
||||
}
|
||||
|
||||
fn obj(&mut self, entry: usize, depth: usize) -> Result<Obj, String> {
|
||||
@@ -435,6 +443,9 @@ impl R<'_> {
|
||||
let (Some(pid), Some(sf), Some(len)) = (rd_u16(&props, 4 + i * 6), rd_u16(&props, 6 + i * 6), rd_u16(&props, 8 + i * 6)) else {
|
||||
return Err(bad("truncated property index"));
|
||||
};
|
||||
if o.props.iter().any(|(previous, _)| *previous == pid) {
|
||||
return Err(bad("duplicate property id"));
|
||||
}
|
||||
let v = props.get(at..at + len as usize).ok_or_else(|| bad("truncated property value"))?;
|
||||
at += len as usize;
|
||||
let value = match sf {
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
//! clip markers. Transitions are supported on the primary storyline (and read from secondary
|
||||
//! storylines); transitions elsewhere are reported.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use filmcraft_geom::Vec2;
|
||||
use filmcraft_media::MediaKind;
|
||||
@@ -105,10 +105,11 @@ fn audio_rate_str(sr: u32) -> String {
|
||||
|
||||
fn parse_audio_rate(s: &str) -> Option<u32> {
|
||||
let s = s.trim();
|
||||
match s.strip_suffix('k') {
|
||||
let r = match s.strip_suffix('k') {
|
||||
Some(k) => k.parse::<f64>().ok().map(|v| (v * 1000.0).round() as u32),
|
||||
None => s.parse().ok(),
|
||||
}
|
||||
};
|
||||
r.filter(|&sr| sr > 0 && sr <= 384_000)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
@@ -132,6 +133,7 @@ struct Imp<'a, 'i, 'r> {
|
||||
assets: HashMap<String, Asset>,
|
||||
media_nodes: HashMap<String, Node<'a, 'i>>,
|
||||
media_items: HashMap<String, ItemId>,
|
||||
media_active: HashSet<String>,
|
||||
effects: HashMap<String, String>,
|
||||
}
|
||||
|
||||
@@ -163,6 +165,7 @@ pub(crate) fn import(text: &str, opts: &ImportOptions, report: &mut Report) -> R
|
||||
assets: HashMap::new(),
|
||||
media_nodes: HashMap::new(),
|
||||
media_items: HashMap::new(),
|
||||
media_active: HashSet::new(),
|
||||
effects: HashMap::new(),
|
||||
};
|
||||
if let Some(res) = child(root, "resources") {
|
||||
@@ -281,9 +284,15 @@ impl<'a, 'i> Imp<'a, 'i, '_> {
|
||||
}
|
||||
let n = *self.media_nodes.get(id)?;
|
||||
let s = child(n, "sequence")?;
|
||||
// Reserve first so recursion terminates.
|
||||
if !self.media_active.insert(id.into()) {
|
||||
self.report.warn(format!("recursive FCPXML media resource {id:?} ignored"));
|
||||
return None;
|
||||
}
|
||||
// Mark the resource before descending: a compound clip can reference itself through a
|
||||
// ref-clip before a completed sequence is available to cache.
|
||||
let name = n.attribute("name").unwrap_or("Compound Clip").to_string();
|
||||
let item = self.sequence(s, &name, None);
|
||||
self.media_active.remove(id);
|
||||
self.media_items.insert(id.into(), item);
|
||||
Some(item)
|
||||
}
|
||||
|
||||
@@ -72,6 +72,10 @@ enum Seg {
|
||||
At(u64, u32),
|
||||
}
|
||||
|
||||
/// Bento values are metadata, not essence payloads. Limit reassembled segments so repeated
|
||||
/// references cannot amplify a small OMF into an enormous allocation.
|
||||
const MAX_VALUE_BYTES: usize = 16 << 20;
|
||||
|
||||
/// Builds a Bento container.
|
||||
pub(crate) struct Writer {
|
||||
data: Vec<u8>,
|
||||
@@ -311,7 +315,18 @@ impl<'a> Container<'a> {
|
||||
|
||||
/// The bytes of a value.
|
||||
pub fn value(&self, p: &Prop) -> Result<Vec<u8>, String> {
|
||||
let total = p.segs.iter().try_fold(0usize, |total, s| {
|
||||
let len = match s {
|
||||
Seg::Imm(b) => b.len(),
|
||||
Seg::At(_, len) => *len as usize,
|
||||
};
|
||||
total.checked_add(len).ok_or_else(|| "Bento value length overflows".to_string())
|
||||
})?;
|
||||
if total > MAX_VALUE_BYTES {
|
||||
return Err("Bento value exceeds the decoded-value budget".into());
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
out.try_reserve_exact(total).map_err(|_| "cannot allocate Bento value")?;
|
||||
for s in &p.segs {
|
||||
match s {
|
||||
Seg::Imm(b) => out.extend_from_slice(b),
|
||||
@@ -355,3 +370,15 @@ impl<'a> Container<'a> {
|
||||
key
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn repeated_segments_cannot_expand_a_value_past_its_budget() {
|
||||
let p = Prop { prop: 1, ty: 1, segs: vec![Seg::At(0, MAX_VALUE_BYTES as u32), Seg::Imm(vec![0])] };
|
||||
let c = Container { data: &[], objects: BTreeMap::new(), names: HashMap::new(), by_name: HashMap::new() };
|
||||
assert!(c.value(&p).unwrap_err().contains("budget"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ use crate::{Error, Report, Result};
|
||||
|
||||
type Uid = [u8; 12];
|
||||
|
||||
const MAX_REFERENCE_VISITS: usize = 65_536;
|
||||
|
||||
struct R<'a, 'b> {
|
||||
c: &'b Container<'a>,
|
||||
report: &'b mut Report,
|
||||
@@ -51,8 +53,10 @@ pub(crate) fn read(bytes: &[u8], report: &mut Report) -> Result<Document> {
|
||||
}
|
||||
let cmobs: Vec<u32> = mob_list.iter().copied().filter(|&m| class_of(m) == Some(*b"CMOB")).collect();
|
||||
let mut referenced = HashSet::new();
|
||||
let mut visited = HashSet::new();
|
||||
let mut visits = 0;
|
||||
for &m in &cmobs {
|
||||
r.collect_refs(m, &mut referenced, 0);
|
||||
r.collect_refs(m, &mut referenced, &mut visited, &mut visits, 0);
|
||||
}
|
||||
let primary: Vec<u32> = r.refs(head, "OMFI:HEAD:PrimaryMobs").into_iter().filter(|m| cmobs.contains(m)).collect();
|
||||
let top: Vec<u32> = if !primary.is_empty() {
|
||||
@@ -122,10 +126,11 @@ impl R<'_, '_> {
|
||||
self.i32(o, "OMFI:CPNT:Length").unwrap_or(0).max(0)
|
||||
}
|
||||
|
||||
fn collect_refs(&self, o: u32, out: &mut HashSet<Uid>, depth: usize) {
|
||||
if depth > 64 {
|
||||
fn collect_refs(&self, o: u32, out: &mut HashSet<Uid>, visited: &mut HashSet<u32>, visits: &mut usize, depth: usize) {
|
||||
if depth > 64 || !visited.insert(o) || *visits >= MAX_REFERENCE_VISITS {
|
||||
return;
|
||||
}
|
||||
*visits += 1;
|
||||
if self.class(o) == *b"SCLP"
|
||||
&& let Some(id) = self.uid(o, "OMFI:SCLP:SourceID")
|
||||
{
|
||||
@@ -133,12 +138,12 @@ impl R<'_, '_> {
|
||||
}
|
||||
for p in ["OMFI:MOBJ:Slots", "OMFI:SEQU:Components", "OMFI:EFFE:EffectSlots"] {
|
||||
for c in self.refs(o, p) {
|
||||
self.collect_refs(c, out, depth + 1);
|
||||
self.collect_refs(c, out, visited, visits, depth + 1);
|
||||
}
|
||||
}
|
||||
for p in ["OMFI:MSLT:Segment", "OMFI:ESLT:ArgValue"] {
|
||||
if let Some(c) = self.r#ref(o, p) {
|
||||
self.collect_refs(c, out, depth + 1);
|
||||
self.collect_refs(c, out, visited, visits, depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,6 +159,17 @@ pub(crate) fn num(v: f64) -> String {
|
||||
}
|
||||
|
||||
pub(crate) fn parse(text: &str) -> Result<roxmltree::Document<'_>, String> {
|
||||
// FCP7 XML commonly carries a plain `<!DOCTYPE xmeml>` declaration, which
|
||||
// roxmltree accepts only with DTD parsing enabled. Internal subsets are not
|
||||
// needed by any supported interchange format and can expand entities far
|
||||
// beyond the input size, so reject them before handing the document to the
|
||||
// parser. External entities remain unresolved because no resolver is set.
|
||||
if let Some(start) = text.find("<!DOCTYPE") {
|
||||
let declaration = &text[start..text[start..].find('>').map_or(text.len(), |end| start + end)];
|
||||
if declaration.contains('[') {
|
||||
return Err("internal DTD subsets are not supported".into());
|
||||
}
|
||||
}
|
||||
let opts = roxmltree::ParsingOptions { allow_dtd: true, ..Default::default() };
|
||||
roxmltree::Document::parse_with_options(text, opts).map_err(|e| e.to_string())
|
||||
}
|
||||
@@ -196,4 +207,10 @@ mod tests {
|
||||
assert_eq!(a.attribute("name"), Some("x\ny\tz\r"));
|
||||
assert_eq!(child(a, "b").and_then(|b| b.text()), Some("a\nb"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_internal_dtd_entity_expansion() {
|
||||
let text = "<!DOCTYPE root [<!ENTITY word 'expanded'>]><root>&word;</root>";
|
||||
assert_eq!(parse(text).unwrap_err(), "internal DTD subsets are not supported");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,6 +98,24 @@ fn doc() -> String {
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn self_referencing_compound_media_is_ignored_without_recursing() {
|
||||
let xml = r#"<fcpxml version="1.10">
|
||||
<resources>
|
||||
<format id="r1" frameDuration="1/24s" width="1920" height="1080"/>
|
||||
<media id="r2" name="Loop"><sequence format="r1"><spine>
|
||||
<ref-clip ref="r2" offset="0s" start="0s" duration="1s"/>
|
||||
</spine></sequence></media>
|
||||
</resources>
|
||||
<library><event><project name="P"><sequence format="r1"><spine>
|
||||
<ref-clip ref="r2" offset="0s" start="0s" duration="1s"/>
|
||||
</spine></sequence></project></event></library>
|
||||
</fcpxml>"#;
|
||||
let (imported, report) = import(xml.as_bytes(), Format::Fcpxml, None).unwrap();
|
||||
assert!(!imported.sequences.is_empty());
|
||||
assert!(report.mentions("recursive FCPXML media"), "{report:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn golden_connected_clips() {
|
||||
let d = doc();
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
//! Animated images (APNG and animated GIF) as movie clips (#402).
|
||||
//! Animated images (APNG, animated GIF, and animated WebP) as movie clips (#402).
|
||||
//!
|
||||
//! The still decoders read only the first picture of these files. [`open`] decodes every frame
|
||||
//! instead, composited onto the full canvas by the `image` crate (APNG blend and dispose operations,
|
||||
//! GIF disposal methods), and keeps them with their timing:
|
||||
//! GIF and WebP disposal methods), and keeps them with their timing:
|
||||
//!
|
||||
//! - each frame lasts its own delay; GIF delays of 10 ms or less play as 100 ms and APNG delays
|
||||
//! under 10 ms as 10 ms, as browsers do, so a zero delay never makes a zero-length frame;
|
||||
//! - each frame lasts its own delay; GIF delays of 10 ms or less play as 100 ms and APNG and WebP
|
||||
//! delays under 10 ms as 10 ms, as browsers do, so a zero delay never makes a zero-length frame;
|
||||
//! - consecutive identical frames are merged (one picture, the sum of their delays);
|
||||
//! - the clip is one pass through the animation (the file's loop count is not applied);
|
||||
//! - transparency is kept: the source reports alpha when any frame has a pixel that is not fully
|
||||
@@ -31,29 +31,37 @@ use crate::{FrameRequest, MediaError, MediaInfo, MediaKind, MediaSource, Result,
|
||||
pub const MAX_FRAMES: usize = 10_000;
|
||||
/// Most bytes of decoded RGBA pictures kept from one file (after merging identical frames).
|
||||
pub const MAX_DECODED_BYTES: usize = 1 << 30;
|
||||
/// Most RGBA bytes in one animation canvas while its decoder produces a frame.
|
||||
const MAX_DECODED_FRAME_BYTES: usize = 512 << 20;
|
||||
|
||||
/// Animated image formats.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Format {
|
||||
Apng,
|
||||
Gif,
|
||||
WebP,
|
||||
}
|
||||
|
||||
impl Format {
|
||||
/// Identify an animated image format from its container signature.
|
||||
fn sniff(b: &[u8]) -> Option<Format> {
|
||||
if b.starts_with(b"\x89PNG\r\n\x1a\n") {
|
||||
Some(Format::Apng)
|
||||
} else if b.starts_with(b"GIF87a") || b.starts_with(b"GIF89a") {
|
||||
Some(Format::Gif)
|
||||
} else if b.get(0..4) == Some(&b"RIFF"[..]) && b.get(8..12) == Some(&b"WEBP"[..]) {
|
||||
Some(Format::WebP)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the codec label shown in media metadata.
|
||||
fn codec(self) -> &'static str {
|
||||
match self {
|
||||
Format::Apng => "APNG",
|
||||
Format::Gif => "GIF",
|
||||
Format::WebP => "WebP",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,6 +74,7 @@ impl Format {
|
||||
let (numer, denom) = match self {
|
||||
Format::Gif if ms <= 10 => (100, 1),
|
||||
Format::Apng if ms < 10 => (10, 1),
|
||||
Format::WebP if ms < 10 => (10, 1),
|
||||
_ => (numer, denom),
|
||||
};
|
||||
let ticks = numer.saturating_mul(TICKS_PER_SECOND as u128) / denom.saturating_mul(1000);
|
||||
@@ -81,8 +90,8 @@ pub struct AnimatedSource {
|
||||
starts: Vec<Tick>,
|
||||
}
|
||||
|
||||
/// Decode `bytes` as an animated image. `None` when it is not an APNG or GIF with at least two
|
||||
/// frames that decode (the caller then opens it as a still).
|
||||
/// Decode `bytes` as an animated image. `None` when it is not an APNG, GIF, or animated WebP with
|
||||
/// at least two frames that decode (the caller then opens it as a still).
|
||||
pub fn open(name: &str, bytes: &[u8]) -> Option<Result<AnimatedSource>> {
|
||||
let format = Format::sniff(bytes)?;
|
||||
let frames = match format {
|
||||
@@ -99,19 +108,34 @@ pub fn open(name: &str, bytes: &[u8]) -> Option<Result<AnimatedSource>> {
|
||||
d.set_limits(Limits::default()).ok()?;
|
||||
d.into_frames()
|
||||
}
|
||||
Format::WebP => {
|
||||
let mut d = image::codecs::webp::WebPDecoder::new(Cursor::new(bytes)).ok()?;
|
||||
if !d.has_animation() {
|
||||
return None;
|
||||
}
|
||||
let (width, height) = d.dimensions();
|
||||
if rgba_bytes(width, height).is_none_or(|bytes| bytes > MAX_DECODED_FRAME_BYTES) {
|
||||
return Some(Err(MediaError::Unsupported(format!("{name}: animated WebP canvas exceeds the {MAX_DECODED_FRAME_BYTES}-byte frame limit"))));
|
||||
}
|
||||
d.set_limits(Limits::default()).ok()?;
|
||||
d.into_frames()
|
||||
}
|
||||
};
|
||||
AnimatedSource::from_frames(name, format, frames, bytes.len() as u64).map(Ok)
|
||||
}
|
||||
|
||||
impl AnimatedSource {
|
||||
/// Build a clip from decoded full-canvas frames, merging consecutive identical pictures.
|
||||
fn from_frames(name: &str, format: Format, frames: Frames<'_>, file_size: u64) -> Option<Self> {
|
||||
let mut pictures: Vec<(u32, u32, Vec<u8>)> = Vec::new();
|
||||
let mut durations: Vec<Tick> = Vec::new();
|
||||
let mut decoded_frames = 0usize;
|
||||
let mut decoded = 0usize;
|
||||
let mut has_alpha = false;
|
||||
for frame in frames.take(MAX_FRAMES) {
|
||||
// a damaged frame ends the animation at the frames before it
|
||||
let Ok(frame) = frame else { break };
|
||||
decoded_frames = decoded_frames.saturating_add(1);
|
||||
let ticks = format.frame_ticks(frame.delay());
|
||||
let buffer = frame.into_buffer();
|
||||
let (w, h) = buffer.dimensions();
|
||||
@@ -131,7 +155,7 @@ impl AnimatedSource {
|
||||
pictures.push((w, h, rgba));
|
||||
durations.push(ticks);
|
||||
}
|
||||
if pictures.len() < 2 {
|
||||
if pictures.len() < 2 && !(format == Format::WebP && decoded_frames >= 2) {
|
||||
return None;
|
||||
}
|
||||
let (width, height) = pictures.first().map(|p| (p.0, p.1))?;
|
||||
@@ -182,6 +206,11 @@ impl AnimatedSource {
|
||||
}
|
||||
}
|
||||
|
||||
/// Checked byte size of an RGBA8 image.
|
||||
fn rgba_bytes(width: u32, height: u32) -> Option<usize> {
|
||||
usize::try_from(width).ok()?.checked_mul(usize::try_from(height).ok()?)?.checked_mul(4)
|
||||
}
|
||||
|
||||
/// The clip's frame rate: the rate of its most common frame duration, when that is a sensible video
|
||||
/// rate (1 to 120 frames per second), else 30 fps. Frames are shown for their own durations
|
||||
/// whatever this says; it sets the clip's timebase (e.g. a sequence made from the clip).
|
||||
@@ -207,14 +236,17 @@ fn rate_of(durations: &[Tick]) -> FrameRate {
|
||||
}
|
||||
|
||||
impl MediaSource for AnimatedSource {
|
||||
/// Return the clip's media metadata.
|
||||
fn info(&self) -> &MediaInfo {
|
||||
&self.info
|
||||
}
|
||||
|
||||
/// Return the picture displayed at the requested media time.
|
||||
fn video_frame(&self, req: FrameRequest) -> Result<Arc<VideoFrame>> {
|
||||
self.frames.get(self.index_at(req.time)).cloned().ok_or(MediaError::NoStream("video"))
|
||||
}
|
||||
|
||||
/// Animated image files do not provide audio.
|
||||
fn audio(&self, _start: i64, _frames: usize, _sample_rate: u32) -> Result<AudioBuffer> {
|
||||
Err(MediaError::NoStream("audio"))
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::*;
|
||||
use crate::{FrameRequest, MediaKind, open_bytes};
|
||||
use crate::{FrameRequest, MediaError, MediaKind, open_bytes};
|
||||
|
||||
const W: u32 = 4;
|
||||
const H: u32 = 2;
|
||||
@@ -51,10 +51,71 @@ fn gif(frames: &[(Vec<u8>, u32)]) -> Vec<u8> {
|
||||
out
|
||||
}
|
||||
|
||||
/// A small animated WebP built from lossless WebP frame bitstreams.
|
||||
fn webp(frames: &[(Vec<u8>, u32)]) -> Vec<u8> {
|
||||
fn chunk(out: &mut Vec<u8>, kind: &[u8; 4], data: &[u8]) {
|
||||
out.extend_from_slice(kind);
|
||||
out.extend_from_slice(&u32::try_from(data.len()).unwrap().to_le_bytes());
|
||||
out.extend_from_slice(data);
|
||||
if !data.len().is_multiple_of(2) {
|
||||
out.push(0);
|
||||
}
|
||||
}
|
||||
|
||||
fn u24(out: &mut [u8], value: u32) {
|
||||
out[0] = value as u8;
|
||||
out[1] = (value >> 8) as u8;
|
||||
out[2] = (value >> 16) as u8;
|
||||
}
|
||||
|
||||
fn frame_chunk(pixels: &[u8], duration_ms: u32) -> Vec<u8> {
|
||||
let mut encoded = Vec::new();
|
||||
image::codecs::webp::WebPEncoder::new_lossless(&mut encoded).encode(pixels, W, H, image::ExtendedColorType::Rgba8).unwrap();
|
||||
|
||||
let mut pos = 12;
|
||||
let bitstream = loop {
|
||||
let header = encoded.get(pos..pos + 8).unwrap();
|
||||
let size = u32::from_le_bytes(header[4..8].try_into().unwrap()) as usize;
|
||||
let end = pos + 8 + size + size % 2;
|
||||
if &header[..4] == b"VP8L" {
|
||||
break encoded.get(pos..end).unwrap().to_vec();
|
||||
}
|
||||
pos = end;
|
||||
};
|
||||
|
||||
let mut data = vec![0; 16];
|
||||
u24(&mut data[6..9], W - 1);
|
||||
u24(&mut data[9..12], H - 1);
|
||||
u24(&mut data[12..15], duration_ms);
|
||||
data[15] = 0x02; // replace the previous canvas instead of blending
|
||||
data.extend_from_slice(&bitstream);
|
||||
data
|
||||
}
|
||||
|
||||
let mut chunks = Vec::new();
|
||||
let mut extended = [0; 10];
|
||||
extended[0] = 0x12; // alpha and animation flags
|
||||
u24(&mut extended[4..7], W - 1);
|
||||
u24(&mut extended[7..10], H - 1);
|
||||
chunk(&mut chunks, b"VP8X", &extended);
|
||||
chunk(&mut chunks, b"ANIM", &[0, 0, 0, 0, 0, 0]);
|
||||
for (pixels, duration_ms) in frames {
|
||||
chunk(&mut chunks, b"ANMF", &frame_chunk(pixels, *duration_ms));
|
||||
}
|
||||
|
||||
let mut out = b"RIFF".to_vec();
|
||||
out.extend_from_slice(&u32::try_from(chunks.len() + 4).unwrap().to_le_bytes());
|
||||
out.extend_from_slice(b"WEBP");
|
||||
out.extend_from_slice(&chunks);
|
||||
out
|
||||
}
|
||||
|
||||
/// Convert a whole number of milliseconds to media ticks.
|
||||
fn ms(n: i64) -> Tick {
|
||||
Tick(n * TICKS_PER_SECOND / 1000)
|
||||
}
|
||||
|
||||
/// Read the decoded RGBA pixels shown at a media time.
|
||||
fn pixels(s: &dyn MediaSource, t: Tick) -> Vec<u8> {
|
||||
match &s.video_frame(FrameRequest::full(t)).unwrap().data {
|
||||
filmcraft_frame::PixelData::Rgba8(p) => p.to_vec(),
|
||||
@@ -111,13 +172,62 @@ fn animated_gif_plays_every_frame_with_transparency() {
|
||||
assert_eq!(&pixels(&*s, ms(40))[0..4], &[0, 255, 0, 255]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn animated_webp_plays_every_frame_with_timing_and_transparency() {
|
||||
let (red, green) = (picture([255, 0, 0], 128), picture([0, 255, 0], 0));
|
||||
let bytes = webp(&[(red.clone(), 40), (green.clone(), 60)]);
|
||||
let s = open_bytes("wave.webp", bytes.into(), &[]).unwrap();
|
||||
let info = s.info();
|
||||
assert_eq!(info.kind, MediaKind::Movie);
|
||||
assert_eq!(info.video.as_ref().unwrap().codec, "WebP");
|
||||
assert_eq!(info.duration, ms(100));
|
||||
assert!(info.video.as_ref().unwrap().has_alpha);
|
||||
assert_eq!(pixels(&*s, Tick::ZERO), red);
|
||||
assert_eq!(pixels(&*s, ms(39)), red);
|
||||
assert_eq!(pixels(&*s, ms(40)), green);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identical_webp_frames_remain_a_clip_when_merged() {
|
||||
let frame = picture([20, 40, 60], 255);
|
||||
let bytes = webp(&[(frame.clone(), 40), (frame, 60)]);
|
||||
let decoded = open("hold.webp", &bytes).expect("animated WebP").expect("decodes");
|
||||
assert_eq!(decoded.frame_count(), 1, "equal pictures still merge");
|
||||
assert_eq!(decoded.info().kind, MediaKind::Movie);
|
||||
assert_eq!(decoded.info().duration, ms(100), "merged frames keep their encoded duration");
|
||||
|
||||
let imported = open_bytes("hold.webp", bytes.into(), &[]).unwrap();
|
||||
assert_eq!(imported.info().kind, MediaKind::Movie, "the still-image fallback must not discard the duration");
|
||||
assert_eq!(imported.info().duration, ms(100));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn animated_webp_rejects_an_oversized_canvas_before_decoding_frames() {
|
||||
let frame = picture([1, 2, 3], 255);
|
||||
let mut bytes = webp(&[(frame.clone(), 40), (frame, 60)]);
|
||||
// VP8X canvas width and height are stored as 24-bit values minus one.
|
||||
for (offset, dimension) in [(24, 25_000u32), (27, 25_000u32)] {
|
||||
let value = (dimension - 1).to_le_bytes();
|
||||
bytes[offset..offset + 3].copy_from_slice(&value[..3]);
|
||||
}
|
||||
assert!(matches!(
|
||||
open("large.webp", &bytes),
|
||||
Some(Err(MediaError::Unsupported(message))) if message.contains("frame limit")
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_frame_files_stay_stills() {
|
||||
let one_frame_apng = apng(&[(picture([1, 2, 3], 255), (1, 10))]);
|
||||
let one_frame_gif = gif(&[(picture([1, 2, 3], 255), 100)]);
|
||||
let one_frame_webp = webp(&[(picture([1, 2, 3], 255), 100)]);
|
||||
let mut plain = std::io::Cursor::new(Vec::new());
|
||||
image::RgbaImage::from_raw(W, H, picture([1, 2, 3], 0)).unwrap().write_to(&mut plain, image::ImageFormat::Png).unwrap();
|
||||
for (name, bytes) in [("one.png", one_frame_apng), ("one.gif", one_frame_gif), ("plain.png", plain.into_inner())] {
|
||||
let mut plain_webp = Vec::new();
|
||||
image::codecs::webp::WebPEncoder::new_lossless(&mut plain_webp).encode(&picture([1, 2, 3], 0), W, H, image::ExtendedColorType::Rgba8).unwrap();
|
||||
for (name, bytes) in
|
||||
[("one.png", one_frame_apng), ("one.gif", one_frame_gif), ("one.webp", one_frame_webp), ("plain.png", plain.into_inner()), ("plain.webp", plain_webp)]
|
||||
{
|
||||
assert!(open(name, &bytes).is_none(), "{name}");
|
||||
let s = open_bytes(name, bytes.into(), &[]).unwrap();
|
||||
assert_eq!(s.info().kind, MediaKind::Still, "{name}");
|
||||
@@ -129,7 +239,8 @@ fn damaged_files_never_panic() {
|
||||
let frames: Vec<_> = (0..6u8).map(|i| (picture([i * 40, 0, 255 - i * 40], i * 40), (1, 10))).collect();
|
||||
let png_bytes = apng(&frames);
|
||||
let gif_bytes = gif(&frames.iter().map(|(p, _)| (p.clone(), 100)).collect::<Vec<_>>());
|
||||
for whole in [png_bytes, gif_bytes] {
|
||||
let webp_bytes = webp(&frames.iter().map(|(p, _)| (p.clone(), 100)).collect::<Vec<_>>());
|
||||
for whole in [png_bytes, gif_bytes, webp_bytes] {
|
||||
for cut in (0..whole.len()).step_by(7) {
|
||||
// truncated: opens as fewer frames, a still or an error, never a panic
|
||||
let _ = open_bytes("cut", whole[..cut].to_vec().into(), &[]);
|
||||
@@ -166,6 +277,8 @@ fn frame_delays_have_browser_minimums_and_never_overflow() {
|
||||
assert_eq!(Format::Apng.frame_ticks(d(40, 1)), ms(40));
|
||||
assert_eq!(Format::Apng.frame_ticks(d(1000, 3)), Tick(TICKS_PER_SECOND / 3));
|
||||
assert!(Format::Apng.frame_ticks(d(u32::MAX, 1)).0 > 0);
|
||||
assert_eq!(Format::WebP.frame_ticks(d(0, 1)), ms(10));
|
||||
assert_eq!(Format::WebP.frame_ticks(d(40, 1)), ms(40));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -203,7 +203,7 @@ pub fn open_bytes(name: &str, bytes: Arc<[u8]>, extra: &[Opener]) -> Result<Shar
|
||||
if wav::sniff(&bytes) {
|
||||
return Ok(Arc::new(wav::WavSource::parse(name, bytes)?));
|
||||
}
|
||||
// APNG and animated GIF play as movies (#402); single-frame files fall through to the stills
|
||||
// APNG, animated GIF, and animated WebP play as movies (#402); single-frame files fall through to the stills
|
||||
if let Some(r) = animated::open(name, &bytes) {
|
||||
return Ok(Arc::new(r?));
|
||||
}
|
||||
|
||||
@@ -197,8 +197,17 @@ pub fn decode(name: &str, b: &[u8]) -> Result<VideoFrame> {
|
||||
if (b.len().saturating_sub(pos) as u64).saturating_mul(4096) < (w as u64).saturating_mul(h as u64) {
|
||||
return Err(err(name, "truncated pixel data"));
|
||||
}
|
||||
let mut px = vec![1f32; w * h * 4];
|
||||
// Validate every encoded scanline before allocating the complete float image. A short HDR
|
||||
// header can otherwise request up to a GiB of zero-initialized pixels and only then fail on
|
||||
// its first missing or malformed scanline.
|
||||
let pixel_data = pos;
|
||||
let mut scan = vec![[0u8; 4]; n2];
|
||||
for _ in 0..n1 {
|
||||
scanline(name, b, &mut pos, &mut scan)?;
|
||||
}
|
||||
|
||||
let mut px = vec![1f32; w * h * 4];
|
||||
pos = pixel_data;
|
||||
for i in 0..n1 {
|
||||
scanline(name, b, &mut pos, &mut scan)?;
|
||||
for (j, &p) in scan.iter().enumerate() {
|
||||
|
||||
@@ -112,6 +112,9 @@ fn hostile_input_is_an_error_not_a_panic() {
|
||||
}
|
||||
assert!(decode("a.hdr", &file("-Y 2 +X 2", &[0; 8])).is_err());
|
||||
assert!(decode("a.hdr", &file("-Y 99999999 +X 99999999", &[0; 16])).is_err());
|
||||
// Pass the coarse compressed-size check but fail scanline validation. This must be rejected
|
||||
// before a 1 GiB float image is initialized.
|
||||
assert!(decode("a.hdr", &file("-Y 67108864 +X 1", &[0; 16_384])).is_err());
|
||||
assert!(decode("a.hdr", &file("-Y 0 +X 4", &[])).is_err());
|
||||
assert!(decode("a.hdr", &file("-Y 2 -Y 2", &[0; 16])).is_err());
|
||||
assert!(decode("a.hdr", &file("junk", &[0; 16])).is_err());
|
||||
|
||||
@@ -44,6 +44,9 @@ use headers::{EXTENSION, GROUP_START, PICTURE_START, SEQUENCE_END, SEQUENCE_HEAD
|
||||
use mc::Frame;
|
||||
use slice::{PicParams, Target, decode_slice, slice_row};
|
||||
|
||||
/// One access unit must not retain an unbounded number of cropped decoded pictures.
|
||||
const MAX_OUTPUT_PICTURES_PER_ACCESS_UNIT: usize = 8;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum Error {
|
||||
#[error("invalid MPEG video: {0}")]
|
||||
@@ -423,6 +426,7 @@ pub struct Decoder {
|
||||
gray: Option<Arc<Frame>>,
|
||||
pending: Option<Pending>,
|
||||
out: Vec<Picture>,
|
||||
output_overflow: bool,
|
||||
errors: usize,
|
||||
last_error: Option<&'static str>,
|
||||
threads: bool,
|
||||
@@ -447,6 +451,7 @@ impl Decoder {
|
||||
gray: None,
|
||||
pending: None,
|
||||
out: Vec::new(),
|
||||
output_overflow: false,
|
||||
errors: 0,
|
||||
last_error: None,
|
||||
threads: cfg!(feature = "threads"),
|
||||
@@ -476,6 +481,7 @@ impl Decoder {
|
||||
/// Decode a chunk of the elementary stream holding whole pictures (an access unit) tagged
|
||||
/// with `pts`. Returns the pictures that became displayable, in display order.
|
||||
pub fn decode(&mut self, data: &[u8], pts: i64) -> Result<Vec<Picture>> {
|
||||
self.output_overflow = false;
|
||||
let codes = start_codes(data);
|
||||
let mut err = None;
|
||||
for (k, &(pos, code)) in codes.iter().enumerate() {
|
||||
@@ -554,6 +560,10 @@ impl Decoder {
|
||||
}
|
||||
self.apply_sequence();
|
||||
self.finish_picture(data, pts);
|
||||
if self.output_overflow {
|
||||
self.out.clear();
|
||||
return Err(Error::Invalid("access unit produces too many decoded pictures".into()));
|
||||
}
|
||||
let out = std::mem::take(&mut self.out);
|
||||
match err {
|
||||
Some(e) if out.is_empty() && self.seq.is_none() => Err(e),
|
||||
@@ -571,7 +581,7 @@ impl Decoder {
|
||||
{
|
||||
a.shown = true;
|
||||
let p = to_picture(&a.frame, &a.meta);
|
||||
self.out.push(p);
|
||||
self.push_output(p);
|
||||
}
|
||||
std::mem::take(&mut self.out)
|
||||
}
|
||||
@@ -584,6 +594,7 @@ impl Decoder {
|
||||
self.building = None;
|
||||
self.pending = None;
|
||||
self.out.clear();
|
||||
self.output_overflow = false;
|
||||
self.closed_gop = false;
|
||||
}
|
||||
|
||||
@@ -607,6 +618,11 @@ impl Decoder {
|
||||
|
||||
fn finish_picture(&mut self, data: &[u8], pts: i64) {
|
||||
let Some(pd) = self.pending.take() else { return };
|
||||
if pd.slices.is_empty() {
|
||||
self.errors += 1;
|
||||
self.last_error = Some("picture has no slices");
|
||||
return;
|
||||
}
|
||||
let Some(seq) = self.seq.clone() else {
|
||||
self.errors += 1;
|
||||
return;
|
||||
@@ -706,13 +722,21 @@ impl Decoder {
|
||||
if let Some(mut prev) = self.bwd.take() {
|
||||
if !prev.shown {
|
||||
prev.shown = true;
|
||||
self.out.push(to_picture(&prev.frame, &prev.meta));
|
||||
self.push_output(to_picture(&prev.frame, &prev.meta));
|
||||
}
|
||||
self.fwd = Some(prev);
|
||||
}
|
||||
self.bwd = Some(Anchor { frame: Arc::new(frame), meta, shown: false });
|
||||
} else {
|
||||
self.out.push(to_picture(&frame, &meta));
|
||||
self.push_output(to_picture(&frame, &meta));
|
||||
}
|
||||
}
|
||||
|
||||
fn push_output(&mut self, picture: Picture) {
|
||||
if self.out.len() >= MAX_OUTPUT_PICTURES_PER_ACCESS_UNIT {
|
||||
self.output_overflow = true;
|
||||
} else {
|
||||
self.out.push(picture);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -652,11 +652,14 @@ impl Splitter {
|
||||
match next {
|
||||
Some(d) => {
|
||||
self.skipped += d as u64;
|
||||
self.trim(self.buf.len() - (i + 1 + d));
|
||||
// Keep a cursor into the current window rather than draining its
|
||||
// prefix for every false sync candidate.
|
||||
self.next_frame = Some(at + 1 + d as u64);
|
||||
}
|
||||
None => {
|
||||
self.skipped += (self.buf.len() - i - 1) as u64;
|
||||
self.trim(0);
|
||||
// A split header can begin in the final few bytes; compact once.
|
||||
self.trim(7);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -327,12 +327,13 @@ fn walk(src: &(impl ByteSource + ?Sized)) -> Result<WalkResult> {
|
||||
let mut win = Window { src, off: 0, buf: Vec::new() };
|
||||
let mut pos = run_in;
|
||||
let mut cur_meta: Option<usize> = None;
|
||||
let mut resync_budget = 64 << 20;
|
||||
while pos + 17 <= len {
|
||||
let hdr = win.get(pos, 25)?.to_vec();
|
||||
let key = Ul(hdr[..16].try_into().unwrap_or([0; 16]));
|
||||
if !key.is_smpte() {
|
||||
// Lost sync (corrupt data): look for the next partition pack.
|
||||
match resync(src, pos + 1, len)? {
|
||||
match resync(src, pos + 1, len, &mut resync_budget)? {
|
||||
Some(p) => {
|
||||
w.warnings.push(format!("lost KLV sync at byte {pos}; resumed at {p}"));
|
||||
pos = p;
|
||||
@@ -418,12 +419,14 @@ fn is_essence_item(k: &Ul) -> bool {
|
||||
matches!(k.0[12], 0x05 | 0x06 | 0x07 | 0x15 | 0x16 | 0x17 | 0x18)
|
||||
}
|
||||
|
||||
/// The next partition pack key at or after `from` (scans at most 16 MiB).
|
||||
fn resync(src: &(impl ByteSource + ?Sized), from: u64, len: u64) -> Result<Option<u64>> {
|
||||
/// The next partition pack key at or after `from` (scans at most 16 MiB and shares `budget`
|
||||
/// with every other recovery attempt in this file).
|
||||
fn resync(src: &(impl ByteSource + ?Sized), from: u64, len: u64, budget: &mut usize) -> Result<Option<u64>> {
|
||||
let mut at = from;
|
||||
let end = len.min(from + (16 << 20));
|
||||
while at < end {
|
||||
let chunk = read_upto(src, at, 1 << 20)?;
|
||||
while at < end && *budget > 0 {
|
||||
let chunk = read_upto(src, at, (1 << 20).min(*budget))?;
|
||||
*budget = budget.saturating_sub(chunk.len());
|
||||
if chunk.len() < 16 {
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -541,6 +541,16 @@ impl<W: Write + Seek> MxfWriter<W> {
|
||||
if !cfg.edit_rate.is_valid() {
|
||||
return Err(io::Error::new(io::ErrorKind::InvalidInput, "MXF edit rate must be positive"));
|
||||
}
|
||||
// OP1a pads every sound track to a whole edit unit on flush. Bound that unit before a
|
||||
// low but technically positive edit rate can turn padding into a multi-gigabyte Vec.
|
||||
const MAX_PCM_EDIT_UNIT_BYTES: u128 = 64 << 20;
|
||||
for sound in &cfg.sound {
|
||||
let samples = (u128::from(sound.sample_rate) * cfg.edit_rate.den as u128).div_ceil(cfg.edit_rate.num as u128);
|
||||
let bytes = samples.saturating_mul(u128::from(sound.block_align()));
|
||||
if bytes > MAX_PCM_EDIT_UNIT_BYTES {
|
||||
return Err(io::Error::new(io::ErrorKind::InvalidInput, "MXF PCM edit unit exceeds the allocation budget"));
|
||||
}
|
||||
}
|
||||
let n_essence = cfg.picture.is_some() as usize + cfg.sound.len();
|
||||
if n_essence == 0 {
|
||||
return Err(io::Error::new(io::ErrorKind::InvalidInput, "MXF file without essence tracks"));
|
||||
|
||||
@@ -47,6 +47,15 @@ fn op1a_config(rate: Rational, coding: PictureCoding) -> WriterConfig {
|
||||
cfg
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn op1a_rejects_an_unbounded_pcm_edit_unit_before_padding() {
|
||||
let mut cfg = WriterConfig::new(Pattern::Op1a, Rational::new(1, 36_000), PackageIds::from_seed("low-rate", "Low Rate"));
|
||||
cfg.picture = Some(PictureDesc::new(PictureCoding::ProRes { profile: 3 }, 64, 36));
|
||||
cfg.sound = vec![SoundDesc { sample_rate: 48_000, channels: 2, bits: 16 }];
|
||||
let err = MxfWriter::new(Cursor::new(Vec::new()), cfg).err().unwrap();
|
||||
assert!(err.to_string().contains("PCM edit unit"));
|
||||
}
|
||||
|
||||
/// 10 ProRes-like pictures at 25 fps with stereo 16-bit and mono 24-bit sound pushed in uneven chunks.
|
||||
fn build_op1a() -> Vec<u8> {
|
||||
let cfg = op1a_config(Rational::new(25, 1), PictureCoding::ProRes { profile: 3 });
|
||||
|
||||
@@ -211,11 +211,12 @@ pub fn open(src: &(impl ByteSource + ?Sized)) -> Result<OggFile> {
|
||||
let mut warnings = Vec::new();
|
||||
let mut pos = 0u64;
|
||||
let mut found_any = false;
|
||||
let mut recovery_budget = 64 << 20;
|
||||
let mut hdr = [0u8; 27 + 255];
|
||||
while pos + 27 <= len {
|
||||
src.read_at(pos, &mut hdr[..27])?;
|
||||
if &hdr[..4] != b"OggS" || hdr[4] != 0 {
|
||||
match find_capture(src, pos + 1, len)? {
|
||||
match find_capture(src, pos + 1, len, &mut recovery_budget)? {
|
||||
Some(p) => {
|
||||
if found_any {
|
||||
warnings.push(format!("lost sync at byte {pos}; resumed at {p}"));
|
||||
@@ -328,12 +329,13 @@ pub fn open(src: &(impl ByteSource + ?Sized)) -> Result<OggFile> {
|
||||
Ok(OggFile { streams, warnings })
|
||||
}
|
||||
|
||||
fn find_capture(src: &(impl ByteSource + ?Sized), from: u64, len: u64) -> Result<Option<u64>> {
|
||||
fn find_capture(src: &(impl ByteSource + ?Sized), from: u64, len: u64, budget: &mut usize) -> Result<Option<u64>> {
|
||||
let mut at = from;
|
||||
while at + 4 <= len {
|
||||
let n = ((len - at) as usize).min(1 << 20);
|
||||
while at + 4 <= len && *budget > 0 {
|
||||
let n = ((len - at) as usize).min(1 << 20).min(*budget);
|
||||
let mut buf = vec![0u8; n];
|
||||
src.read_at(at, &mut buf)?;
|
||||
*budget = budget.saturating_sub(n);
|
||||
if let Some(i) = buf.windows(4).position(|w| w == b"OggS") {
|
||||
return Ok(Some(at + i as u64));
|
||||
}
|
||||
|
||||
@@ -2,6 +2,15 @@
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn recovery_scan_stops_when_its_shared_budget_is_spent() {
|
||||
let source = b"junkOggS".to_vec();
|
||||
let mut budget = 3;
|
||||
assert_eq!(find_capture(&source, 0, source.len() as u64, &mut budget).unwrap(), None);
|
||||
let mut budget = source.len();
|
||||
assert_eq!(find_capture(&source, 0, source.len() as u64, &mut budget).unwrap(), Some(4));
|
||||
}
|
||||
|
||||
/// Write one page holding `segments` (lacing already computed by `lace`).
|
||||
fn page(out: &mut Vec<u8>, flags: u8, granule: i64, serial: u32, seq: u32, lacing: &[u8], body: &[u8]) {
|
||||
let start = out.len();
|
||||
|
||||
@@ -333,7 +333,7 @@ impl<A: Accel> Front<A> {
|
||||
let fpoc = if mmco5 { 0 } else { p.poc.frame() };
|
||||
let meta = Arc::new(output_meta(&p.sps, p.pts, p.first.idr));
|
||||
let mut outs = Vec::new();
|
||||
self.dpb.store_picture(&p.first, surface, fpoc, p.sps.max_frame_num(), p.sps.max_num_ref_frames as usize, meta, &mut outs);
|
||||
self.dpb.store_picture(&p.first, surface, fpoc, p.sps.max_frame_num(), p.sps.max_num_ref_frames as usize, meta, &mut outs).map_err(decode_err)?;
|
||||
self.read_out(outs, frames)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -239,25 +239,36 @@ pub fn plain_text(s: &str) -> String {
|
||||
while let Some(c) = chars.next() {
|
||||
match c {
|
||||
'<' => {
|
||||
// a tag runs to the next '>' (if none, keep the '<' literally)
|
||||
let rest: String = chars.clone().collect();
|
||||
if let Some(end) = rest.find('>') {
|
||||
for _ in 0..rest[..=end].chars().count() {
|
||||
chars.next();
|
||||
// A tag runs to the next '>'. Scan forward once; preserving an unterminated
|
||||
// tag as literal text must not rescan every later opening bracket.
|
||||
let mut literal = String::new();
|
||||
let mut closed = false;
|
||||
for next in chars.by_ref() {
|
||||
if next == '>' {
|
||||
closed = true;
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
literal.push(next);
|
||||
}
|
||||
if !closed {
|
||||
out.push('<');
|
||||
out.push_str(&literal);
|
||||
}
|
||||
}
|
||||
'{' if chars.peek() == Some(&'\\') => {
|
||||
// SSA-style override blocks some SRT files carry: {\an8}
|
||||
let rest: String = chars.clone().collect();
|
||||
if let Some(end) = rest.find('}') {
|
||||
for _ in 0..rest[..=end].chars().count() {
|
||||
chars.next();
|
||||
let mut literal = String::new();
|
||||
let mut closed = false;
|
||||
for next in chars.by_ref() {
|
||||
if next == '}' {
|
||||
closed = true;
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
literal.push(next);
|
||||
}
|
||||
if !closed {
|
||||
out.push('{');
|
||||
out.push_str(&literal);
|
||||
}
|
||||
}
|
||||
'&' => {
|
||||
@@ -300,6 +311,14 @@ mod tests {
|
||||
assert_eq!(plain_text("<v Bob>Hi</v>"), "Hi");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_caption_markup_is_preserved_without_repeated_suffix_scans() {
|
||||
let angle = "<".repeat(10_000);
|
||||
let ssa = format!("{{\\{}", "{".repeat(10_000));
|
||||
assert_eq!(plain_text(&angle), angle);
|
||||
assert_eq!(plain_text(&ssa), ssa);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn caption_at() {
|
||||
let mut t = CaptionTrack::new(TrackId(1), "Subtitle".into(), CaptionFormat::Subtitle);
|
||||
|
||||
@@ -1324,6 +1324,9 @@ impl ProjectView {
|
||||
}
|
||||
|
||||
/// A LUT imported into the project (`lut.import`). Lumetri refers to it as `lib:<id>`.
|
||||
/// Largest embedded LUT source accepted from a project or import (8 MiB).
|
||||
pub const MAX_LUT_TEXT_BYTES: usize = 8 << 20;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ProjectLut {
|
||||
pub id: String,
|
||||
|
||||
+30
-1
@@ -1,6 +1,6 @@
|
||||
# filmcraft-prores
|
||||
|
||||
Clean-room Apple ProRes decoder and encoder in pure Rust, written from the public SMPTE RDD 36
|
||||
Clean-room Apple ProRes decoder and encoder in pure Rust, written from the public [SMPTE RDD 36:2022](https://pub.smpte.org/doc/rdd36/20220909-pub/rdd36-2022.pdf)
|
||||
bitstream description. Layer L0: depends only on `filmcraft-bitstream`, `thiserror` and (optional,
|
||||
feature `threads`, default on) `rayon`. Builds for `wasm32-unknown-unknown`; no `unsafe`.
|
||||
|
||||
@@ -95,6 +95,35 @@ decodes them with `-xerror`, with no errors, and agrees with this decoder within
|
||||
69.9/69.2 dB (Y/C) on mandelbrot at 100% of the target rate. Busy content lands at 99.9–100.4%
|
||||
of the target.
|
||||
|
||||
## Alpha bitstream conformance
|
||||
|
||||
Every alpha difference is followed by a run code, including a final singleton
|
||||
(RDD 36:2022 §5.3.3, Tables 12–14). Omitting the final run produces a truncated
|
||||
alpha payload: FFmpeg may accept it, while macOS native HD decoding fails with AVFoundation `-11821` / underlying `-12137`. This affected
|
||||
both 4444 and 4444 XQ exports, including EffectCraft's alpha output
|
||||
([effectcraft#293](https://github.com/storytold/effectcraft/issues/293)).
|
||||
|
||||
The encoder tests check exact singleton codewords at 8 and 16 bits, run-length
|
||||
boundaries through 2048 and split runs, final-value bit alignments, and complete
|
||||
encoded slices for both profiles and all four slice widths. A separate,
|
||||
bounds-checked parser follows the published syntax and requires every run and
|
||||
zero alignment bit; round-tripping through a permissive decoder alone cannot
|
||||
catch this defect.
|
||||
|
||||
Native validation on Apple M5 / macOS 27.0.1 used 14 synthetic 1920×1080 clips:
|
||||
opaque, transparent, gradient, changing last pixel, moving block, noisy alpha,
|
||||
and RGB-only, for each 4444 profile. Before the fix, the gradient, last-pixel and
|
||||
noise cases failed in AVAssetReader (six clips); afterwards all 14 decoded all
|
||||
six frames. FFmpeg's decoded frame hashes are unchanged by the fix. Small
|
||||
128×32 clips passed even before the fix. Resolve itself and the reporter's
|
||||
M1 Pro / macOS 15.8 environment were not available for this check.
|
||||
|
||||
EffectCraft 0.16.0's complete export pipeline was also tested using an animated
|
||||
synthetic PNG with varying alpha: 4444 / XQ × straight / premultiplied × motion
|
||||
blur off / on (eight six-frame HD movies). All eight failed native decoding with
|
||||
the pinned encoder, and all eight passed with this codec patched in. FFmpeg's
|
||||
decoded frame hashes remained identical for every before/after pair.
|
||||
|
||||
## Performance
|
||||
|
||||
Measured with `cargo test --release -p filmcraft-prores --test it perf:: -- --ignored --nocapture` on
|
||||
|
||||
+118
-3
@@ -472,9 +472,9 @@ pub(crate) fn encode_alpha(w: &mut Writer, vals: &[u32], bits: u32) {
|
||||
}
|
||||
prev = v;
|
||||
idx += 1;
|
||||
if idx >= n {
|
||||
break;
|
||||
}
|
||||
// RDD 36:2022 §5.3.3 requires a run after every alpha difference,
|
||||
// including a singleton at the end of the slice. Do not replace that
|
||||
// final `1` with byte-alignment zeros: native decoders reject it.
|
||||
let mut run = 0;
|
||||
while idx + run < n && vals[idx + run] == prev && run < 2047 {
|
||||
run += 1;
|
||||
@@ -493,3 +493,118 @@ pub(crate) fn encode_alpha(w: &mut Writer, vals: &[u32], bits: u32) {
|
||||
idx += run;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod alpha_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn alpha_final_singleton_has_a_run_code() {
|
||||
// RDD 36:2022 §5.3.3: every difference is followed by a run, including
|
||||
// the final one. Tables 13/14 encode -1; Table 12 encodes run 1 as `1`.
|
||||
// With 16-bit alpha the difference occupies exactly one byte, so the
|
||||
// required final run must start another byte, followed by zero padding.
|
||||
for (bits, expected) in [(16, vec![0x01, 0x80]), (8, vec![0x0c])] {
|
||||
let mut w = Writer::new();
|
||||
encode_alpha(&mut w, &[(1 << bits) - 2], bits);
|
||||
assert_eq!(w.finish(), expected, "{bits}-bit final singleton");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod alpha_conformance_tests {
|
||||
use super::*;
|
||||
use filmcraft_bitstream::BitReader;
|
||||
|
||||
/// RDD 36:2022 §5.3.3 and Tables 12–14, using a bounds-checked reader.
|
||||
/// Unlike the production decoder's zero-padded hot path, this checks every
|
||||
/// final run and all alignment bits, so a truncated run cannot be accepted.
|
||||
fn read_alpha(data: &[u8], bits: u32, count: usize) -> Vec<u32> {
|
||||
let mut r = BitReader::new(data);
|
||||
let mask = (1u32 << bits) - 1;
|
||||
let mut prev = mask;
|
||||
let mut out = Vec::new();
|
||||
while out.len() < count {
|
||||
let diff = if r.read_bits(1).unwrap() == 1 {
|
||||
r.read_bits(bits).unwrap()
|
||||
} else {
|
||||
let code = r.read_bits(if bits == 16 { 7 } else { 4 }).unwrap();
|
||||
let magnitude = (code >> 1) + 1;
|
||||
if code & 1 == 0 { magnitude } else { magnitude.wrapping_neg() }
|
||||
};
|
||||
prev = prev.wrapping_add(diff) & mask;
|
||||
let run = if r.read_bits(1).unwrap() == 1 {
|
||||
1
|
||||
} else {
|
||||
let short = r.read_bits(4).unwrap();
|
||||
if short == 0 { r.read_bits(11).unwrap() + 1 } else { short + 1 }
|
||||
} as usize;
|
||||
assert!(run <= count - out.len(), "alpha run exceeds the slice");
|
||||
out.extend(std::iter::repeat_n(prev, run));
|
||||
}
|
||||
assert!(r.bits_left() < 8, "only byte-alignment padding may remain");
|
||||
while r.bits_left() > 0 {
|
||||
assert_eq!(r.read_bits(1).unwrap(), 0, "alignment padding must be zero");
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alpha_runs_cover_boundaries_and_final_values() {
|
||||
for bits in [8, 16] {
|
||||
let mask = (1 << bits) - 1;
|
||||
for run in [1, 2, 15, 16, 17, 2047, 2048, 2049, 4096] {
|
||||
for tail in 0..=8 {
|
||||
let mut values = vec![mask - 1; run];
|
||||
// Short negative/positive deltas, modulo transitions and
|
||||
// escaped full-width deltas, ending on every bit alignment.
|
||||
values.extend([0, mask, 1, 7, 8, 64, 65, mask / 2].into_iter().take(tail));
|
||||
let mut w = Writer::new();
|
||||
encode_alpha(&mut w, &values, bits);
|
||||
assert_eq!(read_alpha(&w.finish(), bits, values.len()), values, "bits={bits}, run={run}, tail={tail}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encoded_alpha_slices_have_complete_final_runs() {
|
||||
for profile in [Profile::P4444, Profile::P4444Xq] {
|
||||
for log2_slice_mbs in 0..=3 {
|
||||
let (width, height) = (128, 32);
|
||||
let mut frame = Frame::new(width, height, ChromaFormat::Yuv444, 16, true);
|
||||
frame.y.fill(32768);
|
||||
frame.cb.fill(32768);
|
||||
frame.cr.fill(32768);
|
||||
let alpha = frame.alpha.as_mut().unwrap();
|
||||
for (i, a) in alpha.iter_mut().enumerate() {
|
||||
*a = (i as u16).wrapping_mul(257);
|
||||
}
|
||||
let mut cfg = EncoderConfig::new(profile, width, height);
|
||||
cfg.log2_slice_mbs = log2_slice_mbs;
|
||||
let bytes = Encoder::with_config(cfg).encode(&frame).unwrap();
|
||||
let (_, start, _) = FrameHeader::parse(&bytes).unwrap();
|
||||
let picture = &bytes[start..];
|
||||
let header = PictureHeader::parse(picture).unwrap();
|
||||
let table = &picture[header.header_size..][..2 * header.num_slices];
|
||||
let mut offset = header.header_size + table.len();
|
||||
let sw = 16 << log2_slice_mbs;
|
||||
for (i, size) in table.as_chunks::<2>().0.iter().enumerate() {
|
||||
let size = u16::from_be_bytes([size[0], size[1]]) as usize;
|
||||
let slice = &picture[offset..offset + size];
|
||||
offset += size;
|
||||
let color_bytes: usize = slice[2..8].as_chunks::<2>().0.iter().map(|b| u16::from_be_bytes([b[0], b[1]]) as usize).sum();
|
||||
let payload = &slice[(slice[0] >> 3) as usize + color_bytes..];
|
||||
let actual = read_alpha(payload, 16, sw * 16);
|
||||
let x0 = i % (width as usize / sw) * sw;
|
||||
let y0 = i / (width as usize / sw) * 16;
|
||||
let alpha = frame.alpha.as_ref().unwrap();
|
||||
let expected: Vec<u32> = (y0..y0 + 16).flat_map(|y| (x0..x0 + sw).map(move |x| alpha[y * width as usize + x] as u32)).collect();
|
||||
assert_eq!(actual, expected, "{profile:?}, slice {i}, log2={log2_slice_mbs}");
|
||||
}
|
||||
assert_eq!(offset, picture.len());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,17 @@ use rayon::prelude::*;
|
||||
|
||||
use crate::image::Image;
|
||||
|
||||
/// Effect text is metadata-controlled; do not let its measured bounds allocate an unbounded mask.
|
||||
const MAX_BURN_TEXT_PIXELS: usize = 16 << 20;
|
||||
|
||||
fn bounded_raster_size(w: f32, h: f32) -> Option<(usize, usize)> {
|
||||
if !w.is_finite() || !h.is_finite() || w <= 0.0 || h <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
let (w, h) = (w.ceil() as usize, h.ceil() as usize);
|
||||
(w.checked_mul(h)? <= MAX_BURN_TEXT_PIXELS).then_some((w, h))
|
||||
}
|
||||
|
||||
/// sRGB-encoded straight colour (0..1) → premultiplied linear.
|
||||
pub fn premul_linear(c: [f32; 4]) -> [f32; 4] {
|
||||
let a = c[3].clamp(0.0, 1.0);
|
||||
@@ -59,7 +70,7 @@ pub fn paint_mask(img: &mut Image, m: &Mask, x0: i32, y0: i32, color: [f32; 4],
|
||||
/// Draw `text` centred on `pos` (layer pixels) at `px` pixels in white over a black box of
|
||||
/// `box_alpha` (the Timecode and Clip Name effects).
|
||||
pub fn burn_text(img: &mut Image, text: &str, family: &str, pos: Vec2, px: f32, box_alpha: f32) {
|
||||
if text.is_empty() || pos.x.is_nan() {
|
||||
if text.is_empty() || !pos.x.is_finite() || !pos.y.is_finite() || !px.is_finite() || px <= 0.0 {
|
||||
return;
|
||||
}
|
||||
let st = TextStyle { family: family.into(), style: "Regular".into(), size: px, ..Default::default() };
|
||||
@@ -72,10 +83,27 @@ pub fn burn_text(img: &mut Image, text: &str, family: &str, pos: Vec2, px: f32,
|
||||
let boxp = filmcraft_text::Path::rect(bx + b[0] - pad, by - vm.cap_height - pad, bx + b[2] + pad, by + vm.descent * 0.6 + pad);
|
||||
if let Some(bb) = boxp.bounds() {
|
||||
let (x0, y0) = (bb.0.floor() as i32, bb.1.floor() as i32);
|
||||
let m =
|
||||
filmcraft_text::raster::fill(&boxp, (bb.2.ceil() as i32 - x0).max(1) as usize, (bb.3.ceil() as i32 - y0).max(1) as usize, -x0 as f32, -y0 as f32);
|
||||
paint_mask(img, &m, x0, y0, [0.0, 0.0, 0.0, 1.0], box_alpha);
|
||||
if let Some((w, h)) = bounded_raster_size(bb.2 - x0 as f32, bb.3 - y0 as f32) {
|
||||
let m = filmcraft_text::raster::fill(&boxp, w, h, -x0 as f32, -y0 as f32);
|
||||
paint_mask(img, &m, x0, y0, [0.0, 0.0, 0.0, 1.0], box_alpha);
|
||||
}
|
||||
}
|
||||
let bounds = l.bounds;
|
||||
if bounded_raster_size(bounds[2] - bounds[0], bounds[3] - bounds[1]).is_none() {
|
||||
return;
|
||||
}
|
||||
let (m, x0, y0) = render::rasterize(&l, &render::at(bx, by.round()));
|
||||
paint_mask(img, &m, x0, y0, [1.0, 1.0, 1.0, 1.0], 1.0);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn burn_in_raster_budget_rejects_huge_and_non_finite_bounds() {
|
||||
assert_eq!(bounded_raster_size(4096.0, 4096.0), Some((4096, 4096)));
|
||||
assert_eq!(bounded_raster_size(4097.0, 4096.0), None);
|
||||
assert_eq!(bounded_raster_size(f32::INFINITY, 1.0), None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,4 +197,20 @@ impl Image {
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
/// [`Self::over_black_rgba8`] with each pixel's alpha in the fourth byte: the color bytes are
|
||||
/// the same (the sRGB of the premultiplied linear color), so the picture still shows over
|
||||
/// black, and the alpha says where it is transparent.
|
||||
pub fn over_black_with_alpha_rgba8(&self) -> Vec<u8> {
|
||||
let mut out = vec![0u8; self.w * self.h * 4];
|
||||
out.par_chunks_mut(self.w * 4).zip(self.px.par_chunks(self.w * 4)).for_each(|(o, s)| {
|
||||
for (o, s) in o.as_chunks_mut::<4>().0.iter_mut().zip(s.as_chunks::<4>().0) {
|
||||
o[0] = filmcraft_color::linear_to_srgb_u8(s[0]);
|
||||
o[1] = filmcraft_color::linear_to_srgb_u8(s[1]);
|
||||
o[2] = filmcraft_color::linear_to_srgb_u8(s[2]);
|
||||
o[3] = (s[3].clamp(0.0, 1.0) * 255.0).round() as u8;
|
||||
}
|
||||
});
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
+63
-16
@@ -10,10 +10,11 @@
|
||||
//! Parsed LUTs are cached by content, so a library LUT is parsed once however many clips use it.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
|
||||
use filmcraft_color::{ColorSpace, Lut, Lut3d, LutFormat, Range};
|
||||
use filmcraft_project::Project;
|
||||
use filmcraft_project::{MAX_LUT_TEXT_BYTES, Project};
|
||||
|
||||
/// A built-in LUT: id, label, and how it is made.
|
||||
pub struct Builtin {
|
||||
@@ -80,12 +81,38 @@ impl Builtin {
|
||||
}
|
||||
}
|
||||
|
||||
fn cache() -> &'static Mutex<HashMap<String, Arc<Lut>>> {
|
||||
static C: OnceLock<Mutex<HashMap<String, Arc<Lut>>>> = OnceLock::new();
|
||||
/// Identity of immutable project LUT text. Pointer equality is intentional: an `Arc<str>` changes
|
||||
/// identity whenever a project edit replaces its text, while clones of an unchanged project share
|
||||
/// it. Keeping the Arc in the cache also prevents allocator-address reuse from aliasing old LUTs.
|
||||
#[derive(Clone, Debug)]
|
||||
struct LutText(Arc<str>);
|
||||
|
||||
impl PartialEq for LutText {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
Arc::ptr_eq(&self.0, &other.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl Eq for LutText {}
|
||||
|
||||
impl Hash for LutText {
|
||||
fn hash<H: Hasher>(&self, state: &mut H) {
|
||||
(Arc::as_ptr(&self.0) as *const ()).hash(state);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
|
||||
enum CacheKey {
|
||||
Builtin(String),
|
||||
Library { text: LutText, format: String },
|
||||
}
|
||||
|
||||
fn cache() -> &'static Mutex<HashMap<CacheKey, Arc<Lut>>> {
|
||||
static C: OnceLock<Mutex<HashMap<CacheKey, Arc<Lut>>>> = OnceLock::new();
|
||||
C.get_or_init(Default::default)
|
||||
}
|
||||
|
||||
fn cached(key: String, make: impl FnOnce() -> Option<Lut>) -> Option<Arc<Lut>> {
|
||||
fn cached(key: CacheKey, make: impl FnOnce() -> Option<Lut>) -> Option<Arc<Lut>> {
|
||||
if let Some(l) = cache().lock().unwrap_or_else(|e| e.into_inner()).get(&key) {
|
||||
return Some(l.clone());
|
||||
}
|
||||
@@ -103,25 +130,18 @@ pub fn resolve(project: Option<&Project>, spec: &str) -> Option<Arc<Lut>> {
|
||||
let spec = spec.trim();
|
||||
if let Some(id) = spec.strip_prefix("builtin:") {
|
||||
let b = builtins().iter().find(|b| b.id == id)?;
|
||||
return cached(format!("builtin:{id}"), || Some(b.generate()));
|
||||
return cached(CacheKey::Builtin(id.to_string()), || Some(b.generate()));
|
||||
}
|
||||
let id = spec.strip_prefix("lib:")?;
|
||||
let entry = project?.luts.iter().find(|l| l.id == id)?;
|
||||
let key = format!("lib:{:016x}", hash(&entry.text));
|
||||
if entry.text.len() > MAX_LUT_TEXT_BYTES {
|
||||
return None;
|
||||
}
|
||||
let key = CacheKey::Library { text: LutText(entry.text.clone()), format: entry.format.clone() };
|
||||
let fmt = LutFormat::from_path(&format!("x.{}", entry.format));
|
||||
cached(key, || Lut::parse(&entry.text, fmt).ok())
|
||||
}
|
||||
|
||||
fn hash(s: &str) -> u64 {
|
||||
// FNV-1a
|
||||
let mut h = 0xcbf2_9ce4_8422_2325u64;
|
||||
for b in s.as_bytes() {
|
||||
h ^= *b as u64;
|
||||
h = h.wrapping_mul(0x100_0000_01b3);
|
||||
}
|
||||
h ^ s.len() as u64
|
||||
}
|
||||
|
||||
/// Human-readable name of a LUT reference (for panels).
|
||||
pub fn label(project: Option<&Project>, spec: &str) -> String {
|
||||
if spec.is_empty() {
|
||||
@@ -169,4 +189,31 @@ mod tests {
|
||||
assert_eq!(label(Some(&p), "lib:lut1"), "Swap");
|
||||
assert_eq!(label(Some(&p), "lib:x"), "x (missing)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn library_cache_uses_retained_text_identity_without_rehashing_source() {
|
||||
let text: Arc<str> = Lut::from_cube(Lut3d::from_fn(3, |c| c)).to_cube().into();
|
||||
let a = CacheKey::Library { text: LutText(text.clone()), format: "cube".into() };
|
||||
let b = CacheKey::Library { text: LutText(text), format: "cube".into() };
|
||||
let c = CacheKey::Library { text: LutText(Arc::<str>::from(a_match_source_but_not_identity())), format: "cube".into() };
|
||||
assert_eq!(a, b, "unchanged project snapshots share their LUT identity");
|
||||
assert_ne!(a, c, "replacement text gets a new cache identity even when bytes match");
|
||||
}
|
||||
|
||||
fn a_match_source_but_not_identity() -> String {
|
||||
Lut::from_cube(Lut3d::from_fn(3, |c| c)).to_cube()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_embedded_lut_is_not_parsed() {
|
||||
let mut p = Project::new("t");
|
||||
p.luts.push(filmcraft_project::ProjectLut {
|
||||
id: "too-big".into(),
|
||||
name: "too-big".into(),
|
||||
source_path: None,
|
||||
format: "cube".into(),
|
||||
text: "#".repeat(MAX_LUT_TEXT_BYTES + 1).into(),
|
||||
});
|
||||
assert!(resolve(Some(&p), "lib:too-big").is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,6 +79,19 @@ fn half_resolution_matches_full_downsampled() {
|
||||
}
|
||||
}
|
||||
|
||||
/// The monitor conversion with alpha has the color of the opaque one and the image's alpha.
|
||||
#[test]
|
||||
fn over_black_with_alpha_keeps_the_color_and_the_alpha() {
|
||||
let img = Image { w: 3, h: 1, px: vec![1.0, 0.5, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.5, 0.25, 0.0, 0.5] };
|
||||
let opaque = img.over_black_rgba8();
|
||||
let with_alpha = img.over_black_with_alpha_rgba8();
|
||||
for i in 0..3 {
|
||||
assert_eq!(with_alpha[i * 4..i * 4 + 3], opaque[i * 4..i * 4 + 3], "pixel {i}");
|
||||
}
|
||||
assert_eq!([with_alpha[3], with_alpha[7], with_alpha[11]], [255, 0, 128]);
|
||||
assert_eq!([opaque[3], opaque[7], opaque[11]], [255, 255, 255]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn motion_scale_and_position() {
|
||||
let (mut p, red, _o, seq, map) = setup();
|
||||
|
||||
@@ -14,7 +14,7 @@ default = []
|
||||
# Whisper inference on the CPU via candle (pure Rust). Off by default (and in wasm builds).
|
||||
whisper = ["dep:candle-core", "dep:candle-nn"]
|
||||
# Model downloads over HTTPS (pure-Rust TLS: rustls + RustCrypto, OS certificate verification).
|
||||
download = ["dep:ureq", "dep:rustls", "dep:rustls-rustcrypto", "dep:sha2"]
|
||||
download = ["dep:ureq", "dep:rustls", "dep:rustls-rustcrypto"]
|
||||
|
||||
[dependencies]
|
||||
filmcraft-time = { workspace = true }
|
||||
@@ -28,8 +28,11 @@ rayon = { workspace = true }
|
||||
ureq = { version = "3", default-features = false, features = ["rustls-no-provider", "platform-verifier"], optional = true }
|
||||
rustls = { version = "0.23", default-features = false, optional = true }
|
||||
rustls-rustcrypto = { version = "0.0.2-alpha", optional = true }
|
||||
sha2 = { version = "0.10", optional = true }
|
||||
sha2 = "0.10"
|
||||
|
||||
[[example]]
|
||||
name = "transcribe"
|
||||
required-features = ["whisper"]
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3.27"
|
||||
|
||||
@@ -46,6 +46,9 @@ impl Default for Params {
|
||||
const N_MELS: usize = 40;
|
||||
const N_MFCC: usize = 20;
|
||||
const FRAME_TICKS: i64 = TICKS_PER_SAMPLE * crate::mel::HOP as i64;
|
||||
/// Agglomerative linkage is cubic in the number of turns; beyond this, retain a safe single-speaker
|
||||
/// transcript instead of making a document-controlled amount of work.
|
||||
const MAX_CLUSTER_CHUNKS: usize = 256;
|
||||
|
||||
/// Word index ranges of the chunks.
|
||||
pub fn chunks(t: &Transcript, p: &Params) -> Vec<std::ops::Range<usize>> {
|
||||
@@ -125,6 +128,9 @@ pub fn diarize(audio: &[f32], t: &mut Transcript, p: &Params) -> usize {
|
||||
if ch.len() < 2 {
|
||||
return one(t);
|
||||
}
|
||||
if ch.len() > MAX_CLUSTER_CHUNKS {
|
||||
return one(t);
|
||||
}
|
||||
let (frames, energy, mfcc) = mfcc_frames(audio);
|
||||
// chunk vectors: mean and std of MFCC 1..N over voiced frames
|
||||
let dim = 2 * (N_MFCC - 1);
|
||||
@@ -329,4 +335,12 @@ mod tests {
|
||||
let t = Transcript { words: vec![w(0.0, 0.5), w(0.5, 1.0), w(2.0, 2.2), w(3.0, 3.6), w(3.6, 4.4)], ..Default::default() };
|
||||
assert_eq!(chunks(&t, &Params::default()), vec![0..3, 3..5]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn excessive_turns_fall_back_without_clustering() {
|
||||
let words = (0..=MAX_CLUSTER_CHUNKS).map(|i| Word::new("x", seconds_tick(i as f64 * 2.0), seconds_tick(i as f64 * 2.0 + 1.0))).collect();
|
||||
let mut t = Transcript { words, ..Default::default() };
|
||||
assert_eq!(diarize(&[], &mut t, &Params::default()), 1);
|
||||
assert!(t.words.iter().all(|word| word.speaker == Some(0)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::Serialize;
|
||||
use sha2::Digest;
|
||||
|
||||
/// One file of a model.
|
||||
#[derive(Clone, Copy, Debug, Serialize)]
|
||||
@@ -195,16 +196,36 @@ pub fn model_dir(models_dir: &Path, m: &ModelInfo) -> PathBuf {
|
||||
models_dir.join(m.id)
|
||||
}
|
||||
|
||||
/// All files present with their catalogue sizes (contents were verified when downloaded).
|
||||
fn valid_name(name: &str) -> bool {
|
||||
!name.is_empty()
|
||||
&& !name.contains(['/', '\\'])
|
||||
&& !matches!(name, "." | "..")
|
||||
&& Path::new(name).components().count() == 1
|
||||
&& Path::new(name).file_name().is_some_and(|part| part == name)
|
||||
}
|
||||
|
||||
fn valid_file(path: &Path, file: &ModelFile) -> bool {
|
||||
if !valid_name(file.name) || !std::fs::metadata(path).is_ok_and(|metadata| metadata.is_file() && metadata.len() == file.size) {
|
||||
return false;
|
||||
}
|
||||
let Ok(mut input) = std::fs::File::open(path) else { return false };
|
||||
let mut hasher = sha2::Sha256::new();
|
||||
if std::io::copy(&mut input, &mut hasher).is_err() {
|
||||
return false;
|
||||
}
|
||||
format!("{:x}", hasher.finalize()).eq_ignore_ascii_case(file.sha256)
|
||||
}
|
||||
|
||||
/// All files present and matching their pinned size and SHA-256.
|
||||
pub fn installed(models_dir: &Path, m: &ModelInfo) -> bool {
|
||||
let d = model_dir(models_dir, m);
|
||||
m.files.iter().all(|f| std::fs::metadata(d.join(f.name)).is_ok_and(|md| md.len() == f.size))
|
||||
m.files.iter().all(|f| valid_file(&d.join(f.name), f))
|
||||
}
|
||||
|
||||
/// Bytes still to download for `m` (files already present are skipped).
|
||||
pub fn missing_bytes(models_dir: &Path, m: &ModelInfo) -> u64 {
|
||||
let d = model_dir(models_dir, m);
|
||||
m.files.iter().filter(|f| !std::fs::metadata(d.join(f.name)).is_ok_and(|md| md.len() == f.size)).map(|f| f.size).sum()
|
||||
m.files.iter().filter(|f| !valid_file(&d.join(f.name), f)).map(|f| f.size).sum()
|
||||
}
|
||||
|
||||
/// The tokenizer shared by the multilingual models has one checksum.
|
||||
@@ -231,15 +252,17 @@ pub fn download(models_dir: &Path, m: &ModelInfo, progress: DownloadProgress) ->
|
||||
#[cfg(feature = "download")]
|
||||
pub fn download_files(dir: &Path, files: &[ModelFile], progress: DownloadProgress) -> Result<(), crate::SpeechError> {
|
||||
use crate::SpeechError;
|
||||
use sha2::Digest;
|
||||
use std::io::{Read, Write};
|
||||
if let Some(file) = files.iter().find(|file| !valid_name(file.name)) {
|
||||
return Err(SpeechError::Download(format!("invalid model artifact name: {:?}", file.name)));
|
||||
}
|
||||
std::fs::create_dir_all(dir)?;
|
||||
let total: u64 = files.iter().filter(|f| !std::fs::metadata(dir.join(f.name)).is_ok_and(|md| md.len() == f.size)).map(|f| f.size).sum();
|
||||
let total: u64 = files.iter().filter(|f| !valid_file(&dir.join(f.name), f)).map(|f| f.size).sum();
|
||||
let agent = agent();
|
||||
let mut done = 0u64;
|
||||
for f in files {
|
||||
let dest = dir.join(f.name);
|
||||
if std::fs::metadata(&dest).is_ok_and(|md| md.len() == f.size) {
|
||||
if valid_file(&dest, f) {
|
||||
continue;
|
||||
}
|
||||
let part = dir.join(format!("{}.part", f.name));
|
||||
@@ -322,4 +345,41 @@ mod tests {
|
||||
assert_eq!(missing_bytes(&dir, m), m.size());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn artifact_names_stay_inside_the_model_directory() {
|
||||
for name in ["../escape", "/tmp/escape", r"..\escape", r"C:\escape", "", ".", ".."] {
|
||||
assert!(!valid_name(name), "accepted {name:?}");
|
||||
}
|
||||
assert!(valid_name("model.safetensors"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_length_corruption_is_not_installed() {
|
||||
static FILES: &[ModelFile] = &[ModelFile {
|
||||
name: "tiny.bin",
|
||||
url: "https://example.invalid/tiny.bin",
|
||||
sha256: "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad",
|
||||
size: 3,
|
||||
}];
|
||||
let model = ModelInfo {
|
||||
id: "integrity-test",
|
||||
name: "integrity test",
|
||||
multilingual: false,
|
||||
description: "",
|
||||
license: "",
|
||||
license_url: "",
|
||||
author: "",
|
||||
source: "",
|
||||
files: FILES,
|
||||
};
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let directory = model_dir(root.path(), &model);
|
||||
std::fs::create_dir_all(&directory).unwrap();
|
||||
std::fs::write(directory.join("tiny.bin"), b"bad").unwrap();
|
||||
assert!(!installed(root.path(), &model));
|
||||
assert_eq!(missing_bytes(root.path(), &model), 3);
|
||||
std::fs::write(directory.join("tiny.bin"), b"abc").unwrap();
|
||||
assert!(installed(root.path(), &model));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,8 @@ const N_FRAMES: usize = 3000;
|
||||
const FRAMES_PER_TS: usize = 2;
|
||||
const SAMPLES_PER_FRAME: usize = crate::mel::HOP;
|
||||
const MAX_TOKENS: usize = 224;
|
||||
/// More than the total alignment heads in any published Whisper architecture.
|
||||
const MAX_ALIGNMENT_HEADS: usize = 4_096;
|
||||
|
||||
fn merr(e: candle_core::Error) -> SpeechError {
|
||||
SpeechError::Model(e.to_string())
|
||||
@@ -66,11 +68,7 @@ impl Whisper {
|
||||
let gen_cfg: serde_json::Value = serde_json::from_slice(&read("generation_config.json")?).unwrap_or_default();
|
||||
let tok = Tokenizer::from_json(&String::from_utf8_lossy(&read("tokenizer.json")?))?;
|
||||
let weights = read("model.safetensors")?;
|
||||
let alignment_heads = gen_cfg["alignment_heads"]
|
||||
.as_array()
|
||||
.map(|a| a.iter().filter_map(|p| Some((p[0].as_u64()? as usize, p[1].as_u64()? as usize))).collect::<Vec<_>>())
|
||||
.filter(|v| !v.is_empty())
|
||||
.unwrap_or_else(|| (cfg.decoder_layers / 2..cfg.decoder_layers).flat_map(|l| (0..cfg.decoder_attention_heads).map(move |h| (l, h))).collect());
|
||||
let alignment_heads = alignment_heads(&cfg, &gen_cfg)?;
|
||||
let mut suppress: Vec<u32> =
|
||||
gen_cfg["suppress_tokens"].as_array().map(|a| a.iter().filter_map(|x| x.as_u64().map(|v| v as u32)).collect()).unwrap_or_default();
|
||||
suppress.extend(tok.non_text_specials());
|
||||
@@ -193,6 +191,34 @@ impl Whisper {
|
||||
}
|
||||
}
|
||||
|
||||
fn alignment_heads(cfg: &Config, generation: &serde_json::Value) -> Result<Vec<(usize, usize)>, SpeechError> {
|
||||
let bad = |message: &str| SpeechError::Model(format!("generation_config.json: {message}"));
|
||||
if let Some(values) = generation["alignment_heads"].as_array().filter(|values| !values.is_empty()) {
|
||||
if values.len() > MAX_ALIGNMENT_HEADS {
|
||||
return Err(bad("too many alignment heads"));
|
||||
}
|
||||
return values
|
||||
.iter()
|
||||
.map(|pair| {
|
||||
let layer =
|
||||
pair.get(0).and_then(serde_json::Value::as_u64).and_then(|v| usize::try_from(v).ok()).ok_or_else(|| bad("invalid alignment head"))?;
|
||||
let head =
|
||||
pair.get(1).and_then(serde_json::Value::as_u64).and_then(|v| usize::try_from(v).ok()).ok_or_else(|| bad("invalid alignment head"))?;
|
||||
if layer >= cfg.decoder_layers || head >= cfg.decoder_attention_heads {
|
||||
return Err(bad("alignment head is outside the decoder architecture"));
|
||||
}
|
||||
Ok((layer, head))
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
let first_layer = cfg.decoder_layers / 2;
|
||||
let count = cfg.decoder_layers.saturating_sub(first_layer).checked_mul(cfg.decoder_attention_heads).ok_or_else(|| bad("alignment-head count overflow"))?;
|
||||
if count > MAX_ALIGNMENT_HEADS {
|
||||
return Err(bad("alignment-head fallback is too large"));
|
||||
}
|
||||
Ok((first_layer..cfg.decoder_layers).flat_map(|layer| (0..cfg.decoder_attention_heads).map(move |head| (layer, head))).collect())
|
||||
}
|
||||
|
||||
fn softmax_at(x: &[f32], i: usize) -> f32 {
|
||||
let m = x.iter().copied().fold(f32::NEG_INFINITY, f32::max);
|
||||
let s: f32 = x.iter().map(|v| (v - m).exp()).sum();
|
||||
@@ -335,6 +361,27 @@ impl Transcriber for Whisper {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn config(layers: usize, heads: usize) -> Config {
|
||||
Config {
|
||||
num_mel_bins: 80,
|
||||
d_model: 384,
|
||||
encoder_layers: 4,
|
||||
encoder_attention_heads: 6,
|
||||
decoder_layers: layers,
|
||||
decoder_attention_heads: heads,
|
||||
max_source_positions: 1_500,
|
||||
max_target_positions: 448,
|
||||
vocab_size: 51_865,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fallback_alignment_heads_are_bounded_before_collection() {
|
||||
let error = alignment_heads(&config(usize::MAX, usize::MAX), &serde_json::Value::Null).unwrap_err();
|
||||
assert!(error.to_string().contains("alignment-head"));
|
||||
assert_eq!(alignment_heads(&config(4, 6), &serde_json::Value::Null).unwrap().len(), 12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repetition_guard() {
|
||||
let tb = 1000;
|
||||
|
||||
@@ -9,6 +9,9 @@ use std::collections::HashMap;
|
||||
|
||||
use crate::SpeechError;
|
||||
|
||||
/// Generous ceiling above every published Whisper vocabulary, before allocating a dense id table.
|
||||
const MAX_VOCAB_ID: u64 = 1_000_000;
|
||||
|
||||
pub struct Tokenizer {
|
||||
/// Bytes of each regular token, by id.
|
||||
bytes: Vec<Vec<u8>>,
|
||||
@@ -48,7 +51,11 @@ impl Tokenizer {
|
||||
let v: serde_json::Value = serde_json::from_str(json).map_err(|e| bad(&e.to_string()))?;
|
||||
let vocab = v["model"]["vocab"].as_object().ok_or_else(|| bad("no model.vocab"))?;
|
||||
let table = unicode_to_byte();
|
||||
let max = vocab.values().filter_map(|x| x.as_u64()).max().unwrap_or(0) as usize;
|
||||
let max = vocab.values().filter_map(|x| x.as_u64()).max().unwrap_or(0);
|
||||
if max > MAX_VOCAB_ID {
|
||||
return Err(bad("vocab id is too large"));
|
||||
}
|
||||
let max = usize::try_from(max).map_err(|_| bad("vocab id is too large"))?;
|
||||
let mut bytes = vec![Vec::new(); max + 1];
|
||||
for (tok, id) in vocab {
|
||||
let id = id.as_u64().ok_or_else(|| bad("vocab id"))? as usize;
|
||||
@@ -118,6 +125,21 @@ impl Tokenizer {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn sparse_huge_vocab_id_is_rejected_before_allocation() {
|
||||
let json = format!(r#"{{"model":{{"vocab":{{"x":{}}}}},"added_tokens":[]}}"#, MAX_VOCAB_ID + 1);
|
||||
let error = match Tokenizer::from_json(&json) {
|
||||
Err(error) => error,
|
||||
Ok(_) => panic!("huge sparse vocabulary was accepted"),
|
||||
};
|
||||
assert!(error.to_string().contains("vocab id is too large"));
|
||||
}
|
||||
}
|
||||
|
||||
/// Group text tokens into words: a token whose text starts with a space starts a new word;
|
||||
/// punctuation-only tokens join the word before them (opening quotes/brackets the word after).
|
||||
/// Returns (word text, token index range).
|
||||
|
||||
@@ -15,3 +15,7 @@ workspace = true
|
||||
[dependencies]
|
||||
png = "0.18"
|
||||
chrono = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3.27"
|
||||
|
||||
|
||||
@@ -122,8 +122,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn generate_is_cached_and_atomic() {
|
||||
let dir = std::env::temp_dir().join(format!("filmcraft-testkit-gen-{}", std::process::id()));
|
||||
let out = dir.join("f.bin");
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let out = dir.path().join("f.bin");
|
||||
let _ = std::fs::remove_file(&out);
|
||||
assert!(generate(&out, |_| false).is_none());
|
||||
assert!(!out.exists());
|
||||
|
||||
@@ -348,8 +348,8 @@ mod tests {
|
||||
if blessing() {
|
||||
return;
|
||||
}
|
||||
let dir = std::env::temp_dir().join(format!("filmcraft-testkit-golden-{}", std::process::id()));
|
||||
let path = dir.join("ramp.png");
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("ramp.png");
|
||||
let img = ramp(32, 16, 255);
|
||||
write_png(&path, &img).unwrap();
|
||||
assert!(check_golden(&path, &img, Tolerance::EXACT, "t", "unit test").is_ok());
|
||||
@@ -360,7 +360,7 @@ mod tests {
|
||||
far.px.iter_mut().step_by(4).for_each(|v| *v = 255 - *v);
|
||||
let e = check_golden(&path, &far, Tolerance::RENDER, "t", "unit test").unwrap_err();
|
||||
assert!(e.contains("PSNR"), "{e}");
|
||||
assert!(check_golden(&dir.join("missing.png"), &img, Tolerance::RENDER, "t", "unit test").unwrap_err().contains("FILMCRAFT_BLESS"));
|
||||
assert!(check_golden(&dir.path().join("missing.png"), &img, Tolerance::RENDER, "t", "unit test").unwrap_err().contains("FILMCRAFT_BLESS"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -202,10 +202,8 @@ mod tests {
|
||||
}
|
||||
use std::ffi::OsString;
|
||||
|
||||
fn tmpdir(tag: &str) -> PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("filmcraft-testkit-{tag}-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
fn tmpdir(_tag: &str) -> PathBuf {
|
||||
tempfile::tempdir().unwrap().keep()
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -12,7 +12,7 @@ workspace = true
|
||||
[features]
|
||||
default = []
|
||||
# Kokoro-82M neural voices on the CPU via candle (pure Rust). Off by default and in wasm builds.
|
||||
kokoro = ["dep:candle-core", "dep:candle-nn", "dep:zip", "dep:serde_json", "dep:filmcraft-tts-text", "dep:filmcraft-audio-dsp"]
|
||||
kokoro = ["dep:candle-core", "dep:candle-nn", "dep:zip", "dep:serde_json", "dep:sha2", "dep:filmcraft-tts-text", "dep:filmcraft-audio-dsp"]
|
||||
|
||||
[dependencies]
|
||||
filmcraft-tts-text = { workspace = true, optional = true }
|
||||
@@ -23,6 +23,7 @@ candle-core = { version = "0.9", default-features = false, optional = true }
|
||||
candle-nn = { version = "0.9", default-features = false, optional = true }
|
||||
zip = { version = "7", default-features = false, optional = true }
|
||||
serde_json = { workspace = true, optional = true }
|
||||
sha2 = { version = "0.10", optional = true }
|
||||
|
||||
[[example]]
|
||||
name = "kokoro_say"
|
||||
|
||||
@@ -12,6 +12,7 @@ use std::io::Read;
|
||||
use std::path::Path;
|
||||
|
||||
use candle_core::{DType, Device, Tensor};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::TtsError;
|
||||
|
||||
@@ -83,6 +84,7 @@ const PARTS: [&str; 5] = ["bert", "bert_encoder", "predictor", "text_encoder", "
|
||||
|
||||
impl Weights {
|
||||
pub(crate) fn load(path: &Path) -> Result<Weights, TtsError> {
|
||||
verify_checkpoint(path)?;
|
||||
let mut raw: HashMap<String, Tensor> = HashMap::new();
|
||||
for part in PARTS {
|
||||
let tensors = candle_core::pickle::read_all_with_key(path, Some(part)).map_err(|e| err(format!("{}: {e}", path.display())))?;
|
||||
@@ -126,6 +128,34 @@ impl Weights {
|
||||
}
|
||||
}
|
||||
|
||||
/// Candle's pickle reader is used only for the fixed, catalogued checkpoint.
|
||||
/// Verify its immutable size and digest before any pickle object is expanded.
|
||||
fn verify_checkpoint(path: &Path) -> Result<(), TtsError> {
|
||||
let file = crate::catalog::FILES
|
||||
.iter()
|
||||
.find(|file| file.name == "kokoro-v1_0.pth")
|
||||
.ok_or_else(|| err("Kokoro checkpoint is missing from the package catalogue"))?;
|
||||
let metadata = std::fs::metadata(path).map_err(|e| err(format!("{}: {e}", path.display())))?;
|
||||
if metadata.len() != file.size {
|
||||
return Err(err(format!("{}: checkpoint size does not match the official package", path.display())));
|
||||
}
|
||||
let mut reader = std::fs::File::open(path).map_err(|e| err(format!("{}: {e}", path.display())))?;
|
||||
let mut hasher = Sha256::new();
|
||||
let mut buf = [0u8; 64 * 1024];
|
||||
loop {
|
||||
let n = reader.read(&mut buf).map_err(|e| err(format!("{}: {e}", path.display())))?;
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
hasher.update(&buf[..n]);
|
||||
}
|
||||
let actual = format!("{:x}", hasher.finalize());
|
||||
if actual != file.sha256 {
|
||||
return Err(err(format!("{}: checkpoint digest does not match the official package", path.display())));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Number of style vectors in a voice pack (one per input length 1..=510).
|
||||
pub(crate) const PACK_LEN: usize = 510;
|
||||
pub(crate) const STYLE: usize = 256;
|
||||
|
||||
@@ -3,6 +3,16 @@
|
||||
The egui frontend draws panels and dialogs, and dispatches project changes through the engine.
|
||||
Interactive controls keep stable automation ids regardless of the interface language.
|
||||
|
||||
## Control input limits
|
||||
|
||||
The control channel bounds `ui.click` to 10 clicks and `ui.drag` to 1024 movement steps (#2784).
|
||||
The MCP bridge sends null for omitted counts/steps, so the validator treats null like an absent
|
||||
field: one click or 12 drag steps. Negative, fractional, non-numeric and over-limit values still
|
||||
return errors before queuing any input. Zero clicks emit no button events; 0–1 drag steps use 2.
|
||||
`control::input_bounds_tests` covers defaults, rejection, unchanged pending input and valid
|
||||
gestures; `keyboard_ui` verifies that rejected requests leave the UI usable. See the
|
||||
[control protocol](../../docs/control-protocol.md).
|
||||
|
||||
## Transition drop previews
|
||||
|
||||
Dragging a video or audio transition over a compatible clip shows its actual time span, the
|
||||
|
||||
@@ -28,8 +28,15 @@ use serde_json::{Value, json};
|
||||
const MAX_CLICK_COUNT: u64 = 10;
|
||||
const MAX_DRAG_STEPS: u64 = 1_024;
|
||||
|
||||
/// Read a pointer repetition count, using `default` when `key` is absent or null.
|
||||
///
|
||||
/// Reject non-integer, negative and above-`maximum` values; raise accepted counts to `minimum`.
|
||||
fn bounded_repetitions(params: &Value, key: &str, default: u64, minimum: u64, maximum: u64) -> Result<u64, String> {
|
||||
let value = params.get(key).map(|value| value.as_u64().ok_or_else(|| format!("`{key}` must be a non-negative integer"))).transpose()?.unwrap_or(default);
|
||||
// The MCP bridge serializes omitted optional counts as null.
|
||||
let value = match params.get(key) {
|
||||
None | Some(Value::Null) => default,
|
||||
Some(value) => value.as_u64().ok_or_else(|| format!("`{key}` must be a non-negative integer"))?,
|
||||
};
|
||||
if value > maximum {
|
||||
return Err(format!("`{key}` must not exceed {maximum}"));
|
||||
}
|
||||
@@ -642,6 +649,113 @@ pub fn encode_png(rgba: &[u8], w: u32, h: u32) -> Result<Vec<u8>, String> {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod input_bounds_tests {
|
||||
use super::*;
|
||||
|
||||
/// Create an empty editor session for inspecting queued synthetic input without a window.
|
||||
fn app() -> FilmcraftApp {
|
||||
FilmcraftApp::new(filmcraft_engine::Session::default())
|
||||
}
|
||||
|
||||
/// Dispatch a control request and fail the test if hostile parameters cause a panic.
|
||||
fn request(app: &mut FilmcraftApp, method: &str, params: Value) -> Outcome {
|
||||
let (req, _) = ControlRequest::new(method, params);
|
||||
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| handle(app, &egui::Context::default(), &req)))
|
||||
.expect("hostile control parameters must not panic")
|
||||
}
|
||||
|
||||
/// Require an immediate error naming `field`, with all previously queued input unchanged.
|
||||
fn assert_rejected(app: &mut FilmcraftApp, method: &str, params: Value, field: &str) {
|
||||
let before = app.synthetic.clone();
|
||||
let Outcome::Done(reply) = request(app, method, params) else { panic!("invalid input must return an error immediately") };
|
||||
assert_eq!(reply["ok"], false);
|
||||
assert!(reply["error"].as_str().unwrap().contains(field), "{reply}");
|
||||
assert_eq!(app.synthetic, before, "rejection must not queue partial input or change existing input");
|
||||
}
|
||||
|
||||
/// Check the first rejected click count without allocating an enormous event queue.
|
||||
#[test]
|
||||
fn click_count_above_limit_is_rejected() {
|
||||
// A small boundary value reproduces the missing guard without exhausting memory.
|
||||
assert_rejected(&mut app(), "ui.click", json!({"x": 10, "y": 20, "count": 11}), "count");
|
||||
}
|
||||
|
||||
/// Check that one step above the drag limit returns an error before queuing input.
|
||||
#[test]
|
||||
fn drag_steps_above_limit_are_rejected() {
|
||||
assert_rejected(&mut app(), "ui.drag", json!({"from": {"x": 0, "y": 0}, "to": {"x": 10, "y": 20}, "steps": 1025}), "steps");
|
||||
}
|
||||
|
||||
/// Reject extreme counts and malformed types while preserving pending and subsequent input.
|
||||
#[test]
|
||||
fn hostile_counts_and_steps_leave_pending_input_unchanged() {
|
||||
let mut app = app();
|
||||
app.synthetic.push(egui::Event::PointerGone);
|
||||
for value in [json!(u32::MAX), json!(u64::MAX), json!(-1), json!(1.5), json!("2"), json!(true), json!([]), json!({})] {
|
||||
assert_rejected(&mut app, "ui.click", json!({"x": 10, "y": 20, "count": value}), "count");
|
||||
assert_rejected(&mut app, "ui.drag", json!({"from": {"x": 0, "y": 0}, "to": {"x": 10, "y": 20}, "steps": value}), "steps");
|
||||
}
|
||||
// A rejected request must not prevent subsequent valid input.
|
||||
assert!(matches!(request(&mut app, "ui.click", json!({"x": 10, "y": 20})), Outcome::AfterInput));
|
||||
}
|
||||
|
||||
/// Verify missing/null defaults, accepted click bounds, button pairs and modifier resets.
|
||||
#[test]
|
||||
fn valid_click_counts_preserve_button_pairs_and_modifiers() {
|
||||
let mut app = app();
|
||||
let pos = egui::pos2(10.0, 20.0);
|
||||
let modifiers = egui::Modifiers { shift: true, ..Default::default() };
|
||||
for (value, count) in [(None, 1), (Some(Value::Null), 1), (Some(json!(0)), 0), (Some(json!(1)), 1), (Some(json!(2)), 2), (Some(json!(10)), 10)] {
|
||||
app.synthetic.clear();
|
||||
let mut params = json!({"x": 10, "y": 20, "button": "right", "modifiers": {"shift": true}});
|
||||
if let Some(v) = value {
|
||||
params["count"] = v;
|
||||
}
|
||||
assert!(matches!(request(&mut app, "ui.click", params), Outcome::AfterInput));
|
||||
let mut expected = vec![egui::Event::ModifiersChanged(modifiers), egui::Event::PointerMoved(pos)];
|
||||
for _ in 0..count {
|
||||
for pressed in [true, false] {
|
||||
expected.push(egui::Event::PointerButton { pos, button: egui::PointerButton::Secondary, pressed, modifiers });
|
||||
}
|
||||
}
|
||||
expected.push(egui::Event::ModifiersChanged(egui::Modifiers::NONE));
|
||||
assert_eq!(app.synthetic, expected);
|
||||
}
|
||||
app.synthetic.clear();
|
||||
assert!(matches!(request(&mut app, "ui.move", json!({"x": 10, "y": 20, "count": u64::MAX})), Outcome::AfterInput));
|
||||
assert_eq!(app.synthetic.len(), 3, "ui.move does not use count");
|
||||
}
|
||||
|
||||
/// Verify missing/null defaults and accepted drag bounds retain endpoints and button release.
|
||||
#[test]
|
||||
fn valid_drag_steps_preserve_defaults_endpoints_and_release() {
|
||||
let mut app = app();
|
||||
for (value, steps) in [(None, 12), (Some(Value::Null), 12), (Some(json!(0)), 2), (Some(json!(1)), 2), (Some(json!(2)), 2), (Some(json!(1024)), 1024)] {
|
||||
app.synthetic.clear();
|
||||
let mut params = json!({"from": {"x": 0, "y": 0}, "to": {"x": 10, "y": 20}, "modifiers": {"shift": true}});
|
||||
if let Some(v) = value {
|
||||
params["steps"] = v;
|
||||
}
|
||||
assert!(matches!(request(&mut app, "ui.drag", params), Outcome::AfterInput));
|
||||
assert_eq!(app.synthetic.len(), steps + 5);
|
||||
let modifiers = egui::Modifiers { shift: true, ..Default::default() };
|
||||
assert_eq!(app.synthetic[0], egui::Event::ModifiersChanged(modifiers));
|
||||
assert_eq!(app.synthetic[1], egui::Event::PointerMoved(egui::pos2(0.0, 0.0)));
|
||||
assert_eq!(
|
||||
app.synthetic[2],
|
||||
egui::Event::PointerButton { pos: egui::pos2(0.0, 0.0), button: egui::PointerButton::Primary, pressed: true, modifiers }
|
||||
);
|
||||
assert_eq!(app.synthetic[steps + 2], egui::Event::PointerMoved(egui::pos2(10.0, 20.0)));
|
||||
assert_eq!(
|
||||
app.synthetic[steps + 3],
|
||||
egui::Event::PointerButton { pos: egui::pos2(10.0, 20.0), button: egui::PointerButton::Primary, pressed: false, modifiers }
|
||||
);
|
||||
assert_eq!(app.synthetic[steps + 4], egui::Event::ModifiersChanged(egui::Modifiers::NONE));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod base64_tests {
|
||||
use serde_json::json;
|
||||
|
||||
@@ -33,6 +33,10 @@ pub enum Target {
|
||||
MulticamGrid(ItemId, u8, u16),
|
||||
/// One angle of a multi-camera source (Edit Cameras thumbnails).
|
||||
MulticamAngle(ItemId, u32),
|
||||
/// Composite of a sequence rendered live, never from a render preview: previews are
|
||||
/// flattened over black, and the monitors' Transparency Grid and Alpha display mode need the
|
||||
/// frame's alpha.
|
||||
SequenceLive(ItemId),
|
||||
}
|
||||
|
||||
/// Item jobs at this priority or a larger number are background work: thumbnails of the Project
|
||||
@@ -71,12 +75,61 @@ pub fn draft_playback(playing: bool, scale: f32, enabled: bool) -> bool {
|
||||
enabled && playing && scale <= 0.5
|
||||
}
|
||||
|
||||
/// A rendered frame as sRGB RGBA8: the color is the picture over black and the fourth byte is
|
||||
/// its alpha ([`filmcraft_render::Image::over_black_with_alpha_rgba8`]).
|
||||
pub struct Rgba {
|
||||
pub w: usize,
|
||||
pub h: usize,
|
||||
pub px: Vec<u8>,
|
||||
}
|
||||
|
||||
impl Rgba {
|
||||
/// The frame as a texture image: opaque (the picture over black), or with its alpha, so what
|
||||
/// is painted under it shows through its transparent areas.
|
||||
pub fn color_image(&self, alpha: bool) -> egui::ColorImage {
|
||||
let pixels = if alpha {
|
||||
self.px.as_chunks::<4>().0.iter().map(|p| gamma_premultiplied(*p)).collect()
|
||||
} else {
|
||||
self.px.as_chunks::<4>().0.iter().map(|p| egui::Color32::from_rgb(p[0], p[1], p[2])).collect()
|
||||
};
|
||||
egui::ColorImage::new([self.w, self.h], pixels)
|
||||
}
|
||||
}
|
||||
|
||||
/// A frame pixel (sRGB of the linear color premultiplied by alpha) as egui's color, which is the
|
||||
/// sRGB of the straight color multiplied by alpha.
|
||||
fn gamma_premultiplied([r, g, b, a]: [u8; 4]) -> egui::Color32 {
|
||||
match a {
|
||||
255 => egui::Color32::from_rgb(r, g, b),
|
||||
0 => egui::Color32::TRANSPARENT,
|
||||
_ => {
|
||||
let straight = |v: u8| straight_srgb(a, v);
|
||||
egui::Color32::from_rgba_unmultiplied(straight(r), straight(g), straight(b), a)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The sRGB of the straight color whose premultiplied value is sRGB `v` at alpha `a`, from a table
|
||||
/// made on first use: a frame with partial alpha would otherwise take two `powf` per channel per
|
||||
/// pixel on every upload.
|
||||
fn straight_srgb(a: u8, v: u8) -> u8 {
|
||||
static TABLE: std::sync::OnceLock<Vec<u8>> = std::sync::OnceLock::new();
|
||||
let table = TABLE.get_or_init(|| {
|
||||
use egui::ecolor::{gamma_u8_from_linear_f32, linear_f32_from_gamma_u8};
|
||||
let mut t = vec![0u8; 256 * 256];
|
||||
for a in 1..=255u8 {
|
||||
let k = f32::from(a) / 255.0;
|
||||
for v in 0..=255u8 {
|
||||
if let Some(x) = t.get_mut(usize::from(a) * 256 + usize::from(v)) {
|
||||
*x = gamma_u8_from_linear_f32((linear_f32_from_gamma_u8(v) / k).min(1.0));
|
||||
}
|
||||
}
|
||||
}
|
||||
t
|
||||
});
|
||||
table.get(usize::from(a) * 256 + usize::from(v)).copied().unwrap_or(v)
|
||||
}
|
||||
|
||||
struct Job {
|
||||
key: FrameKey,
|
||||
queued: Instant,
|
||||
@@ -951,12 +1004,14 @@ fn render_job(job: &Job, pool: &Arc<MediaPool>, services: &Arc<dyn Services>, pr
|
||||
o.composite_onto(&mut img.px, w, h);
|
||||
}
|
||||
}
|
||||
return (Rgba { w: img.w, h: img.h, px: img.over_black_rgba8() }, true);
|
||||
return (Rgba { w: img.w, h: img.h, px: img.over_black_with_alpha_rgba8() }, true);
|
||||
}
|
||||
let provider = provider(job, pool, services);
|
||||
let opts = filmcraft_render::RenderOptions { scale: job.scale, captions: true, ..Default::default() };
|
||||
let img = match job.key.target {
|
||||
Target::Sequence(seq) | Target::SequencePlan(seq) => Some(filmcraft_render::render_sequence(&job.project, seq, job.time, opts, &provider)),
|
||||
Target::Sequence(seq) | Target::SequencePlan(seq) | Target::SequenceLive(seq) => {
|
||||
Some(filmcraft_render::render_sequence(&job.project, seq, job.time, opts, &provider))
|
||||
}
|
||||
Target::Item(item) => filmcraft_render::render_item(&job.project, item, job.time, job.scale, &provider),
|
||||
Target::MulticamGrid(item, side, page) => {
|
||||
let side = (side > 0).then_some(side as usize);
|
||||
@@ -967,7 +1022,7 @@ fn render_job(job: &Job, pool: &Arc<MediaPool>, services: &Arc<dyn Services>, pr
|
||||
}
|
||||
};
|
||||
let rgba = match img {
|
||||
Some(img) => Rgba { w: img.w, h: img.h, px: img.over_black_rgba8() },
|
||||
Some(img) => Rgba { w: img.w, h: img.h, px: img.over_black_with_alpha_rgba8() },
|
||||
None => Rgba { w: 1, h: 1, px: vec![0, 0, 0, 255] },
|
||||
};
|
||||
(rgba, false)
|
||||
@@ -991,6 +1046,27 @@ mod tests {
|
||||
assert!(!background_work(50, Target::SequencePlan(ItemId(1))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_straight_color_table_matches_the_formula() {
|
||||
use egui::ecolor::{gamma_u8_from_linear_f32, linear_f32_from_gamma_u8};
|
||||
for a in 1..=255u8 {
|
||||
for v in 0..=255u8 {
|
||||
let want = gamma_u8_from_linear_f32((linear_f32_from_gamma_u8(v) / (f32::from(a) / 255.0)).min(1.0));
|
||||
assert_eq!(super::straight_srgb(a, v), want, "a {a} v {v}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_frame_goes_up_opaque_or_with_its_alpha() {
|
||||
use egui::Color32;
|
||||
// opaque red, transparent, and half-transparent white: linear 0.5 over black is sRGB 188
|
||||
let img = super::Rgba { w: 3, h: 1, px: vec![255, 0, 0, 255, 0, 0, 0, 0, 188, 188, 188, 128] };
|
||||
assert_eq!(img.color_image(false).pixels, [Color32::from_rgb(255, 0, 0), Color32::BLACK, Color32::from_rgb(188, 188, 188)]);
|
||||
// egui's colors are the sRGB of the straight color times alpha: white at half is 128
|
||||
assert_eq!(img.color_image(true).pixels, [Color32::from_rgb(255, 0, 0), Color32::TRANSPARENT, Color32::from_rgba_premultiplied(128, 128, 128, 128)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn draft_decoding_only_for_reduced_resolution_playback() {
|
||||
use super::draft_playback;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user