Merge origin/main into #1389 (Italian beside Korean: 12 languages)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Brandon ThomasandClaude Opus 5.5 committed 2026-10-13 02:40:02 -04:00
commit 48cae3e5a3
45 files changed
+5256 -117

No files matched your search

+1
View File
@@ -5,3 +5,4 @@ plan/
/log
.DS_Store
.claude/worktrees/
__pycache__/
+1
View File
@@ -11,6 +11,7 @@ iconography, images or other Adobe assets; open licences only; every asset attri
|---|---|---|---|
| `crates/ui-egui/src/i18n/it.tsv` | FilmCraft contributors | Original work: clean-room Italian interface translations | MIT OR Apache-2.0 |
| `crates/ui-egui/src/i18n/id.tsv` | FilmCraft contributors | Original work: clean-room Indonesian interface translations | MIT OR Apache-2.0 |
| `crates/ui-egui/src/i18n/ko.tsv` | FilmCraft contributors | Original work: clean-room Korean interface translations | MIT OR Apache-2.0 |
| `crates/ui-egui/src/i18n/fr.tsv` | FilmCraft contributors | Original work: clean-room French interface translations | MIT OR Apache-2.0 |
| `crates/ui-egui/src/i18n/pt-br.tsv` | FilmCraft contributors | Original work: clean-room Brazilian Portuguese interface translations | MIT OR Apache-2.0 |
| `crates/ui-egui/src/i18n/ru.tsv` | FilmCraft contributors | Original work: clean-room Russian interface translations | MIT OR Apache-2.0 |
+1 -1
View File
@@ -85,9 +85,9 @@ Premiere ships 10 interface languages. Measured from our catalogs (3,498 strings
| Portuguese (Brazil) | partial (whole interface; native review welcome) | ~100% |
| Indonesian | partial (whole interface; native review welcome) | ~100% |
| Italian | partial (whole interface; native review welcome) | ~100% |
| Korean | partial (whole interface; needs an installed Hangul font; native review welcome) | ~100% |
| Japanese | partial (whole interface) | 99.7% |
| German | menus only (native-reviewed) | 10.6% |
| Korean | none | 0% |
| Vietnamese | none | 0% |
Also shipped: Ukrainian (menus only, 10%). Detail: [docs/localization-parity.md](docs/localization-parity.md).
+2
View File
@@ -133,6 +133,8 @@ pub fn install(app: &FilmcraftApp, ctx: egui::Context) -> (Receiver<String>, Men
filmcraft_ui_egui::i18n::Language::Id
} else if checked("app.language.italian") {
filmcraft_ui_egui::i18n::Language::It
} else if checked("app.language.korean") {
filmcraft_ui_egui::i18n::Language::Ko
} else {
filmcraft_ui_egui::i18n::Language::En
};
+6 -2
View File
@@ -16,6 +16,7 @@
//! - [`AviSource`]: AVI (OpenDML included): Motion JPEG, H.264, HEVC and uncompressed video; PCM,
//! MP3 / MP2 and AC-3 audio.
//! - [`AudioFileSource`]: standalone compressed audio files (MP3, FLAC, AIFF, …).
//! - [`Y4mSource`]: uncompressed YUV4MPEG2 (`.y4m`) video, frames read on demand.
//! - [`openers`]: the openers to register with the engine's media pool.
#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable))]
@@ -34,6 +35,7 @@ pub mod ogg;
mod stream_color;
pub mod video;
mod webm_alpha;
pub mod y4m;
use std::sync::{Arc, RwLock};
@@ -47,6 +49,7 @@ pub use mpeg::MpegSource;
pub use mxf::MxfSource;
pub use ogg::OggSource;
pub use video::{DecodedFrame, VideoDecoder, VideoDecoderFactory};
pub use y4m::Y4mSource;
#[derive(Debug, thiserror::Error)]
pub enum CodecError {
@@ -128,9 +131,9 @@ pub fn software_video_decoder(entry: &filmcraft_isobmff::SampleEntry) -> Result<
}
/// Openers for the engine's media pool (MP4/MOV, Matroska/WebM, MXF, Ogg Opus/Vorbis, MPEG TS/PS and
/// MPEG-1/2 video elementary streams, APV raw bitstreams, standalone audio).
/// MPEG-1/2 video elementary streams, APV raw bitstreams, YUV4MPEG2, standalone audio).
pub fn openers() -> Vec<filmcraft_media::Opener> {
vec![mp4::opener, mkv::opener, mxf::opener, ogg::opener, mpeg::opener, apv::opener, avi::opener, audio::opener]
vec![mp4::opener, mkv::opener, mxf::opener, ogg::opener, mpeg::opener, apv::opener, avi::opener, y4m::opener, audio::opener]
}
fn reader_registry() -> &'static RwLock<Vec<filmcraft_media::ReaderOpener>> {
@@ -143,6 +146,7 @@ fn reader_registry() -> &'static RwLock<Vec<filmcraft_media::ReaderOpener>> {
ogg::reader_opener,
mpeg::reader_opener,
apv::reader_opener,
y4m::reader_opener,
avi::reader_opener,
])
})
+299
View File
@@ -0,0 +1,299 @@
//! YUV4MPEG2 (`.y4m`) files: uncompressed planar video, as described by the format's public
//! documentation (the `yuv4mpeg(5)` manual page of mjpegtools, and the `C` colour space names
//! other tools added for high bit depths).
//!
//! The file starts with `YUV4MPEG2` and space-separated tagged parameters up to a newline: `W` and
//! `H` (size), `F` (frame rate, `n:d`), `A` (pixel aspect, `n:d`, `0:0` unknown), `I` (interlacing),
//! `C` (colour space; `420jpeg` when absent) and `X` (extensions; `XCOLORRANGE=FULL` is read). Each
//! frame is a `FRAME` line (with optional parameters of its own) followed by the Y, Cb and Cr planes
//! (and an alpha plane for `444alpha`); samples above 8 bits are 16-bit little-endian.
//!
//! Frames are found once at open (their header lines only) and read on demand, so a large file
//! is never loaded whole. Every number comes from the file and is checked before use.
use std::sync::Arc;
use filmcraft_color::{ColorInfo, Range};
use filmcraft_frame::{AudioBuffer, Chroma, PixelData, VideoFrame};
use filmcraft_media::{FrameRequest, MediaError, MediaInfo, MediaKind, MediaSource, SharedSource, VideoStreamInfo};
use filmcraft_time::{FrameRate, Tick};
use crate::CodecError;
const SIGNATURE: &[u8] = b"YUV4MPEG2";
/// Longest stream header or `FRAME` line read.
const MAX_LINE: usize = 64 * 1024;
/// Largest picture (luma samples) accepted.
const MAX_PIXELS: u64 = 1 << 28;
pub fn sniff(head: &[u8]) -> bool {
head.starts_with(SIGNATURE) && matches!(head.get(SIGNATURE.len()), Some(b' ' | b'\n'))
}
/// The sample layout a `C` parameter names.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct Layout {
/// `None` for luma only (`mono`).
chroma: Option<Chroma>,
bits: u32,
alpha: bool,
}
impl Layout {
fn parse(c: &str) -> Option<Layout> {
let (base, bits) = match c.split_once('p') {
Some((base, bits)) if !bits.is_empty() && bits.bytes().all(|b| b.is_ascii_digit()) => (base, bits.parse().ok()?),
_ => (c, 8),
};
let (base, bits) = match base.strip_prefix("mono") {
Some("") => ("mono", bits),
Some(digits) if bits == 8 => ("mono", digits.parse().ok()?),
_ => (base, bits),
};
if !(8..=16).contains(&bits) {
return None;
}
let (chroma, alpha) = match base {
"420" | "420jpeg" | "420paldv" | "420mpeg2" => (Some(Chroma::C420), false),
"422" => (Some(Chroma::C422), false),
"444" => (Some(Chroma::C444), false),
"444alpha" if bits == 8 => (Some(Chroma::C444), true),
"mono" => (None, false),
_ => return None,
};
Some(Layout { chroma, bits, alpha })
}
fn label(self) -> String {
let sub = match (self.chroma, self.alpha) {
(None, _) => "Monochrome",
(Some(_), true) => "YUVA 4:4:4:4",
(Some(c), false) => return format!("YUV {} {}-bit", c.label(), self.bits),
};
format!("{sub} {}-bit", self.bits)
}
}
/// Sizes of one frame's planes in samples: luma, each chroma plane (0 for mono), alpha.
fn plane_samples(w: u32, h: u32, l: Layout) -> (usize, usize, usize) {
let luma = w as usize * h as usize;
let chroma = l.chroma.map_or(0, |c| {
let (sx, sy) = c.shifts();
w.div_ceil(1 << sx) as usize * h.div_ceil(1 << sy) as usize
});
(luma, chroma, if l.alpha { luma } else { 0 })
}
pub struct Y4mSource {
info: MediaInfo,
reader: filmcraft_media::SharedReader,
/// File offset of each frame's planes.
frames: Vec<u64>,
frame_bytes: usize,
layout: Layout,
rate: FrameRate,
}
fn parse_ratio(s: &str) -> Option<(i64, i64)> {
let (n, d) = s.split_once(':')?;
Some((n.parse().ok()?, d.parse().ok()?))
}
/// The line starting at `pos` (without its newline) and the offset after it.
fn line_at(reader: &dyn filmcraft_media::reader::ByteReader, pos: u64) -> Option<(Vec<u8>, u64)> {
let mut want = 128usize;
loop {
let len = (reader.len().saturating_sub(pos)).min(want as u64) as usize;
let buf = filmcraft_media::reader::read_range(reader, pos, len).ok()?;
if let Some(end) = buf.iter().position(|&b| b == b'\n') {
let mut line = buf;
line.truncate(end);
return Some((line, pos + end as u64 + 1));
}
if len < want || want >= MAX_LINE {
return None;
}
want = (want * 8).min(MAX_LINE);
}
}
impl Y4mSource {
pub fn open(name: &str, bytes: Arc<[u8]>) -> crate::Result<Self> {
Self::open_reader(name, Arc::new(filmcraft_media::reader::MemReader(bytes)))
}
pub fn open_reader(name: &str, reader: filmcraft_media::SharedReader) -> crate::Result<Self> {
let bad = |what: &str| CodecError::Container(format!("{name}: Y4M: {what}"));
let (header, mut pos) = line_at(&*reader, 0).ok_or_else(|| bad("no stream header"))?;
let header = String::from_utf8_lossy(&header).into_owned();
let mut params = header.split(' ');
if params.next() != Some("YUV4MPEG2") {
return Err(bad("not a YUV4MPEG2 file"));
}
let (mut w, mut h, mut rate, mut par, mut colour, mut full) = (0u32, 0u32, None, (1u32, 1u32), "420jpeg".to_string(), false);
for p in params.filter(|p| !p.is_empty()) {
let (tag, v) = p.split_at(1);
match tag {
"W" => w = v.parse().map_err(|_| bad("invalid width"))?,
"H" => h = v.parse().map_err(|_| bad("invalid height"))?,
"F" => match parse_ratio(v) {
Some((n, d)) if n > 0 && d > 0 => rate = Some(FrameRate::new(n, d)),
_ => return Err(bad("invalid frame rate")),
},
"A" => {
if let Some((n, d)) = parse_ratio(v).filter(|&(n, d)| n > 0 && d > 0)
&& let (Ok(n), Ok(d)) = (u32::try_from(n), u32::try_from(d))
{
par = (n, d);
}
}
"C" => colour = v.to_string(),
"X" => full |= v.eq_ignore_ascii_case("COLORRANGE=FULL"),
_ => {}
}
}
if w == 0 || h == 0 {
return Err(bad("missing or zero size"));
}
if u64::from(w) * u64::from(h) > MAX_PIXELS {
return Err(CodecError::Unsupported(format!("{name}: Y4M frames larger than 268 million pixels")));
}
let layout = Layout::parse(&colour).ok_or_else(|| CodecError::Unsupported(format!("{name}: Y4M colour space {colour}")))?;
// ffmpeg's default when the file names no rate
let rate = rate.unwrap_or(FrameRate::FPS_25);
let (luma, chroma, alpha) = plane_samples(w, h, layout);
let per_sample = if layout.bits > 8 { 2 } else { 1 };
let frame_bytes = (luma + 2 * chroma + alpha) * per_sample;
let len = reader.len();
let mut frames = Vec::new();
while pos < len {
let Some((line, data)) = line_at(&*reader, pos) else { break };
if !line.starts_with(b"FRAME") {
break;
}
let Some(end) = data.checked_add(frame_bytes as u64).filter(|&e| e <= len) else { break };
frames.push(data);
pos = end;
}
if frames.is_empty() {
return Err(bad("no complete frames"));
}
let color = ColorInfo { matrix: filmcraft_frame::default_matrix(w, h), range: if full { Range::Full } else { Range::Limited }, ..ColorInfo::REC709 };
let duration = Tick::from_rational(frames.len() as i64, rate.den, rate.num);
let bitrate = (frame_bytes as u64 * 8).checked_mul(rate.num as u64).map(|b| b / rate.den as u64);
let info = MediaInfo {
name: name.to_string(),
kind: MediaKind::Movie,
duration,
video: Some(VideoStreamInfo {
width: w,
height: h,
frame_rate: rate,
par,
codec: "Uncompressed YUV".into(),
pixel_format: layout.label(),
color,
has_alpha: layout.alpha,
bitrate,
hdr: None,
}),
audio_streams: Vec::new(),
container: "Y4M".into(),
start_timecode: None,
file_size: Some(len),
};
Ok(Self { info, reader, frames, frame_bytes, layout, rate })
}
fn frame(&self, idx: usize) -> crate::Result<VideoFrame> {
let (Some(&at), Some(v)) = (self.frames.get(idx), self.info.video.as_ref()) else {
return Err(CodecError::Container("Y4M: frame index out of range".into()));
};
let data = filmcraft_media::reader::read_range(&*self.reader, at, self.frame_bytes).map_err(|e| CodecError::Container(e.to_string()))?;
let (luma, chroma, alpha) = plane_samples(v.width, v.height, self.layout);
// mono: neutral chroma at 4:2:0
let mono = Layout { chroma: Some(Chroma::C420), ..self.layout };
let grey = plane_samples(v.width, v.height, mono).1;
let pixels = if self.layout.bits == 8 {
let plane = |i: usize, n: usize| Arc::new(data.get(i..i + n).map(<[u8]>::to_vec).unwrap_or_else(|| vec![0; n]));
let (y, cb, cr) = match self.layout.chroma {
Some(_) => (plane(0, luma), plane(luma, chroma), plane(luma + chroma, chroma)),
None => (plane(0, luma), Arc::new(vec![128; grey]), Arc::new(vec![128; grey])),
};
let a = self.layout.alpha.then(|| plane(luma + 2 * chroma, alpha));
PixelData::Yuv8 { planes: [y, cb, cr], chroma: self.layout.chroma.unwrap_or(Chroma::C420), alpha: a }
} else {
let max = (1u32 << self.layout.bits) - 1;
let plane = |i: usize, n: usize| {
Arc::new(match data.get(i * 2..(i + n) * 2) {
Some(b) => b.as_chunks::<2>().0.iter().map(|&s| u32::from(u16::from_le_bytes(s)).min(max) as u16).collect(),
None => vec![0; n],
})
};
let mid = 1u16 << (self.layout.bits - 1);
let (y, cb, cr) = match self.layout.chroma {
Some(_) => (plane(0, luma), plane(luma, chroma), plane(luma + chroma, chroma)),
None => (plane(0, luma), Arc::new(vec![mid; grey]), Arc::new(vec![mid; grey])),
};
PixelData::Yuv16 { planes: [y, cb, cr], chroma: self.layout.chroma.unwrap_or(Chroma::C420), bits: self.layout.bits, alpha: None }
};
Ok(VideoFrame {
width: v.width,
height: v.height,
data: pixels,
color: v.color,
par: v.par,
pts: Tick::from_rational(idx as i64, self.rate.den, self.rate.num),
})
}
}
impl MediaSource for Y4mSource {
fn info(&self) -> &MediaInfo {
&self.info
}
fn video_frame(&self, req: FrameRequest) -> Result<Arc<VideoFrame>, MediaError> {
let last = self.frames.len().saturating_sub(1);
let idx = usize::try_from(self.rate.frame_at(req.time.max(Tick::ZERO))).unwrap_or(last).min(last);
Ok(Arc::new(self.frame(idx)?))
}
fn audio(&self, _start: i64, _frames: usize, _sample_rate: u32) -> Result<AudioBuffer, MediaError> {
Err(MediaError::NoStream("audio"))
}
}
pub fn opener(name: &str, bytes: Arc<[u8]>) -> Option<Result<SharedSource, MediaError>> {
if !sniff(&bytes) {
return None;
}
Some(Y4mSource::open(name, bytes).map(|s| Arc::new(s) as SharedSource).map_err(Into::into))
}
pub fn reader_opener(name: &str, head: &[u8], reader: &filmcraft_media::SharedReader) -> Option<Result<SharedSource, MediaError>> {
if !sniff(head) {
return None;
}
Some(Y4mSource::open_reader(name, reader.clone()).map(|s| Arc::new(s) as SharedSource).map_err(Into::into))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn colour_space_names() {
let l = |c: Option<Chroma>, bits, alpha| Some(Layout { chroma: c, bits, alpha });
assert_eq!(Layout::parse("420jpeg"), l(Some(Chroma::C420), 8, false));
assert_eq!(Layout::parse("420p10"), l(Some(Chroma::C420), 10, false));
assert_eq!(Layout::parse("422p12"), l(Some(Chroma::C422), 12, false));
assert_eq!(Layout::parse("444alpha"), l(Some(Chroma::C444), 8, true));
assert_eq!(Layout::parse("mono"), l(None, 8, false));
assert_eq!(Layout::parse("mono16"), l(None, 16, false));
for bad in ["411", "420p", "420p7", "420p17", "mono10p12", "444alphap10", "", "p10"] {
assert_eq!(Layout::parse(bad), None, "{bad}");
}
}
}
+171
View File
@@ -0,0 +1,171 @@
//! YUV4MPEG2 (`.y4m`) files (#1292): File ▸ Import and the Media Browser offer them, so they must
//! open. Files are built here: a stream header of tagged parameters, then `FRAME` lines each
//! followed by the planes (Y, Cb, Cr, 16-bit little-endian above 8 bits).
use std::sync::Arc;
use filmcraft_color::Range;
use filmcraft_frame::{Chroma, PixelData};
use filmcraft_media::{FrameRequest, MediaKind, SharedSource};
use filmcraft_time::{FrameRate, Tick};
/// A Y4M file: `header` after the signature, `frames` as raw plane bytes, each after `frame_line`.
fn y4m(header: &str, frame_line: &str, frames: &[Vec<u8>]) -> Vec<u8> {
let mut out = format!("YUV4MPEG2 {header}\n").into_bytes();
for f in frames {
out.extend_from_slice(frame_line.as_bytes());
out.push(b'\n');
out.extend_from_slice(f);
}
out
}
/// One 8-bit frame: every Y sample `y`, every Cb `cb`, every Cr `cr`.
fn planes8(luma: usize, chroma: usize, y: u8, cb: u8, cr: u8) -> Vec<u8> {
[vec![y; luma], vec![cb; chroma], vec![cr; chroma]].concat()
}
fn open(name: &str, bytes: Vec<u8>) -> SharedSource {
filmcraft_codecs::open_bytes(name, Arc::from(bytes)).unwrap()
}
/// The middle of frame `k` at `fps`.
fn at(k: i64, fps: i64) -> FrameRequest {
FrameRequest::full(Tick::from_rational(2 * k + 1, 1, 2 * fps))
}
#[test]
fn y4m_420_opens_as_a_movie_frame_by_frame() {
// 6 × 4, 4:2:0 (chroma 3 × 2), three frames at 25 fps.
let frames: Vec<Vec<u8>> = (0..3).map(|k| planes8(24, 6, 16 + 50 * k, 100 + k, 200 - k)).collect();
let src = open("clip.y4m", y4m("W6 H4 F25:1 Ip A1:1 C420jpeg", "FRAME", &frames));
let info = src.info();
assert_eq!(info.kind, MediaKind::Movie);
let v = info.video.as_ref().unwrap();
assert_eq!((v.width, v.height, v.frame_rate), (6, 4, FrameRate::new(25, 1)));
assert_eq!(info.duration, Tick::from_units(3, 25));
for k in 0..3u8 {
let f = src.video_frame(at(i64::from(k), 25)).unwrap();
let PixelData::Yuv8 { planes, chroma, .. } = &f.data else { panic!("{:?}", f.data) };
assert_eq!(*chroma, Chroma::C420);
assert_eq!((planes[0].len(), planes[1].len(), planes[2].len()), (24, 6, 6));
assert!(planes[0].iter().all(|&s| s == 16 + 50 * k) && planes[1].iter().all(|&s| s == 100 + k) && planes[2].iter().all(|&s| s == 200 - k));
}
}
#[test]
fn y4m_high_bit_depth_frame_parameters_aspect_and_range() {
// 4 × 2, 4:2:2 10-bit (little-endian), 30000:1001, 4:3 pixels, full range, and frame lines
// carrying their own parameters.
let mut frame = Vec::new();
for (n, v) in [(8, 940u16), (4, 512), (4, 64)] {
for _ in 0..n {
frame.extend(v.to_le_bytes());
}
}
let src = open("ten.y4m", y4m("W4 H2 F30000:1001 It A4:3 C422p10 XCOLORRANGE=FULL", "FRAME Ip XFOO=1", &[frame.clone(), frame]));
let v = src.info().video.clone().unwrap();
assert_eq!((v.frame_rate, v.par, v.color.range), (FrameRate::new(30000, 1001), (4, 3), Range::Full));
let f = src.video_frame(at(1, 30)).unwrap();
let PixelData::Yuv16 { planes, chroma, bits, .. } = &f.data else { panic!("{:?}", f.data) };
assert_eq!((*chroma, *bits), (Chroma::C422, 10));
assert!(planes[0].iter().all(|&s| s == 940) && planes[1].iter().all(|&s| s == 512) && planes[2].iter().all(|&s| s == 64));
}
#[test]
fn y4m_mono_has_neutral_chroma() {
let src = open("grey.y4m", y4m("W2 H2 F24:1 Cmono", "FRAME", &[vec![77; 4]]));
let f = src.video_frame(at(0, 24)).unwrap();
let PixelData::Yuv8 { planes, .. } = &f.data else { panic!("{:?}", f.data) };
assert!(planes[0].iter().all(|&s| s == 77) && planes[1].iter().chain(planes[2].iter()).all(|&s| s == 128));
}
#[test]
fn large_y4m_files_open_from_a_reader() {
let bytes: Arc<[u8]> = Arc::from(y4m("W2 H2 F25:1 C444", "FRAME", &[planes8(4, 4, 50, 60, 70), planes8(4, 4, 90, 60, 70)]));
let reader: filmcraft_media::SharedReader = Arc::new(filmcraft_media::reader::MemReader(bytes));
let src = filmcraft_media::reader::open_reader("big.y4m", reader, &filmcraft_codecs::reader_openers(), &filmcraft_codecs::openers()).unwrap();
let f = src.video_frame(at(1, 25)).unwrap();
let PixelData::Yuv8 { planes, chroma, .. } = &f.data else { panic!("{:?}", f.data) };
assert_eq!((*chroma, planes[0][0]), (Chroma::C444, 90));
}
#[test]
fn damaged_and_hostile_y4m_files_fail_without_panicking() {
let good = y4m("W6 H4 F25:1 C420jpeg", "FRAME", &[planes8(24, 6, 1, 2, 3), planes8(24, 6, 4, 5, 6)]);
let mut cases: Vec<Vec<u8>> = (0..good.len()).map(|n| good[..n].to_vec()).collect();
for i in 0..good.len() {
let mut b = good.clone();
b[i] ^= 0x41;
cases.push(b);
}
for header in [
"W99999999 H99999999 F25:1",
"W0 H4 F25:1",
"W6 H4 F0:0",
"W6 H4 F25:0",
"W6 H4 F25:1 C411",
"W6 H4 F25:1 Cbogus",
"W6 H4 A0:0 F-5:1",
"H4 F25:1",
"W6 H4 F1:9223372036854775807",
] {
cases.push(y4m(header, "FRAME", &[vec![0; 36]]));
}
for (i, b) in cases.into_iter().enumerate() {
let r = std::panic::catch_unwind(|| {
filmcraft_codecs::open_bytes("damaged.y4m", Arc::from(b)).map(|s| (0..3).filter(|&k| s.video_frame(at(k, 25)).is_ok()).count())
});
assert!(r.is_ok(), "case {i} panicked");
}
}
/// ffmpeg (an external oracle) writes `.y4m` files; every frame we read must equal, byte for byte,
/// the planes ffmpeg's own rawvideo output gives for the same file.
#[test]
fn ffmpeg_y4m_files_match_ffmpeg_rawvideo() {
let Some(ff) = filmcraft_testkit::oracle::ffmpeg_or_skip("Y4M oracle") else { return };
let dir = filmcraft_testkit::fixtures::fixtures_dir("codecs/y4m");
for (pix_fmt, bytes_per_sample) in [("yuv420p", 1usize), ("yuv422p10le", 2)] {
let y4m = dir.join(format!("testsrc2_{pix_fmt}.y4m"));
let made = filmcraft_testkit::fixtures::generate(&y4m, |tmp| {
std::process::Command::new(&ff)
.args([
"-y",
"-v",
"error",
"-f",
"lavfi",
"-i",
"testsrc2=size=94x62:rate=25",
"-frames:v",
"5",
"-pix_fmt",
pix_fmt,
"-strict",
"-1",
"-f",
"yuv4mpegpipe",
])
.arg(tmp)
.status()
.is_ok_and(|s| s.success())
});
let Some(y4m) = made else { continue };
let raw =
std::process::Command::new(&ff).args(["-v", "error", "-i"]).arg(&y4m).args(["-f", "rawvideo", "-pix_fmt", pix_fmt, "-"]).output().unwrap().stdout;
let src = open("oracle.y4m", std::fs::read(&y4m).unwrap());
assert_eq!(src.info().duration, Tick::from_units(5, 25), "{pix_fmt}");
let frame_len = raw.len() / 5;
for k in 0..5 {
let f = src.video_frame(at(k as i64, 25)).unwrap();
let ours: Vec<u8> = match &f.data {
PixelData::Yuv8 { planes, .. } => planes.iter().flat_map(|p| p.iter().copied()).collect(),
PixelData::Yuv16 { planes, .. } => planes.iter().flat_map(|p| p.iter().flat_map(|s| s.to_le_bytes())).collect(),
other => panic!("{other:?}"),
};
assert_eq!(ours.len(), frame_len, "{pix_fmt} frame {k}");
assert!(ours == raw[k * frame_len..(k + 1) * frame_len], "{pix_fmt} frame {k} differs ({bytes_per_sample} bytes/sample)");
}
}
}
+14 -7
View File
@@ -559,7 +559,8 @@ pub fn copy_items(seq: &mut Sequence, moves: &[(ClipId, TrackId, Tick)], insert_
it.start = (*start).max(Tick::ZERO);
placed.push((*dest, it));
}
// worked out on the original ids, then moved over to the copies
// worked out on the original ids, then moved over to the copies (only transitions whose every
// clip is copied travel; one whose partner stays with the originals is not duplicated)
let carried = carried_transitions(&work, &placed);
let mut ids = HashMap::new();
let mut links = HashMap::new();
@@ -596,9 +597,10 @@ pub fn copy_items(seq: &mut Sequence, moves: &[(ClipId, TrackId, Tick)], insert_
Ok(out)
}
/// The transitions that travel with a move (#227, #374): those whose clips all move, onto one
/// track and by one offset (a fade at a moved clip's edge, or the cut between two clips that move
/// together), shifted to where those clips land.
/// The transitions that travel with a move (#227, #374): only those whose every clip moves, onto
/// one track and by one offset (a fade at a moved clip's edge, or the cut between two clips that
/// move together), shifted to where those clips land. A transition whose partner stays behind is
/// dropped, as in Premiere (the drag preview leaves it where it is until the release).
fn carried_transitions(seq: &Sequence, placed: &[(TrackId, TrackItem)]) -> Vec<(TrackId, Transition)> {
let landing = |c: ClipId| {
let (dest, it) = placed.iter().find(|(_, it)| it.id == c)?;
@@ -607,9 +609,14 @@ fn carried_transitions(seq: &Sequence, placed: &[(TrackId, TrackItem)]) -> Vec<(
};
let mut carried = Vec::new();
for tr in seq.all_tracks().filter(|t| !t.locked).flat_map(|t| t.transitions.iter()) {
let lands: Option<Vec<(TrackId, Tick)>> = [tr.from, tr.to].into_iter().flatten().map(landing).collect();
let Some((&(dest, delta), rest)) = lands.as_deref().and_then(<[_]>::split_first) else { continue };
if rest.iter().all(|l| *l == (dest, delta)) {
let endpoints: Vec<ClipId> = [tr.from, tr.to].into_iter().flatten().collect();
let lands: Vec<(TrackId, Tick)> = endpoints.iter().filter_map(|c| landing(*c)).collect();
// every clip it sits on must move, onto one track by one offset, or the transition is
// dropped with the delete below (a partner that stayed behind ends the move)
if lands.len() == endpoints.len()
&& let Some(&(dest, delta)) = lands.first()
&& lands.iter().skip(1).all(|l| *l == (dest, delta))
{
carried.push((dest, Transition { start: tr.start + delta, ..tr.clone() }));
}
}
+1 -1
View File
@@ -554,7 +554,7 @@ fn transitions_travel_with_moved_clips() {
// a cut whose two clips move together keeps its transition, in an insert move as well
move_items(&mut fx.seq, &[(b, v1, f(60)), (c, v1, f(70))], true, &mut Fx::ctx(&mut n)).unwrap();
assert_eq!(at(&fx, v1), vec![(2, 68), (3, 76)]);
// a crossfade whose partner stays behind is dropped; the other clip's fade stays with it
// a crossfade whose partner stays behind is dropped; the moving clip's own fade stays with it
move_items(&mut fx.seq, &[(c, v1, f(90))], false, &mut Fx::ctx(&mut n)).unwrap();
assert_eq!(at(&fx, v1), vec![(3, 96)]);
fx.seq.check().unwrap();
+52
View File
@@ -1677,6 +1677,58 @@ fn build() -> Vec<CommandSpec> {
})?;
Ok(Value::Null)
}),
cmd!("edit.rippleDeleteHead", "Ripple Delete Head Frame", ["Edit"], None, r#"{"clips":[id]?,"clip":id?}"#, has_selection, |s, p| {
// #1357: remove the head — from each selected clip's in point through the playhead —
// and ripple the sequence closed. Linked clips ripple as one group (the engine's
// group trim moves every member's in point by one delta and shifts the synced
// tracks once). The playhead past every member's out point deletes the group whole.
let ph = s.playhead();
let clips = with_links(s, &clips_p(s, p));
// groups keyed by link id (unlinked clips: their own id)
let mut groups: Vec<(u64, Vec<ClipId>)> = Vec::new();
{
let q = s.active_sequence().ok_or(EngineError::NoSequence)?;
for c in &clips {
let (_, item) = q.find_item(*c).ok_or_else(|| EngineError::Other("clip not in the active sequence".into()))?;
if (ph - item.start) <= Tick::ZERO {
return Err(EngineError::Other("the playhead is at or before the clip's in point".into()));
}
let key = item.link.unwrap_or(c.0);
match groups.iter_mut().find(|(k, _)| *k == key) {
Some((_, members)) => members.push(*c),
None => groups.push((key, vec![*c])),
}
}
}
s.edit_sequence("Ripple Delete Head Frame", |q, ctx, st| {
for (_, members) in &groups {
let live: Vec<(ClipId, Tick, Tick)> = members.iter().filter_map(|c| q.find_item(*c).map(|(_, i)| (*c, i.start, i.duration))).collect();
if live.is_empty() {
continue;
}
let head = live.iter().map(|(_, s, _)| (ph - *s).max(Tick::ZERO)).max().unwrap_or(Tick::ZERO);
let all_whole = live.iter().all(|(_, _, d)| head >= *d);
let ids: Vec<ClipId> = live.iter().map(|(c, _, _)| *c).collect();
if all_whole {
let spans = edit::ripple_delete_items(q, &ids)?;
if st.ripple_sequence_markers {
for r in spans.iter().rev() {
crate::sequence_tools::ripple_markers(&mut q.markers, r.end(), -r.duration);
}
}
continue;
}
let start = live.iter().map(|(_, s, _)| *s).min().unwrap_or(ph);
edit::ripple_trim_group(q, &ids, Edge::In, head, ctx)?;
if st.ripple_sequence_markers {
// the head [start, start + head) closes: later markers move back by it
crate::sequence_tools::ripple_markers(&mut q.markers, start + head, Tick::ZERO - head);
}
}
Ok(())
})?;
Ok(Value::Null)
}),
cmd!("edit.selectAll", "Select All", ["Edit"], Some("Cmd+A"), "{}", has_seq, |s, _| {
let all: Vec<ClipId> = s.active_sequence().map(|q| q.all_tracks().flat_map(|t| t.items.iter().map(|i| i.id)).collect()).unwrap_or_default();
s.state.selection = all;
+2
View File
@@ -1288,6 +1288,8 @@ mod remix_tests;
#[cfg(test)]
mod ripple_delete_tests;
#[cfg(test)]
mod ripple_head_tests;
#[cfg(test)]
mod roll_linked_tests;
#[cfg(test)]
mod scopes_tests;
+3 -1
View File
@@ -73,7 +73,9 @@ pub const ALL_COLUMNS: [&str; 10] =
["Name", "Media Type", "Size", "Date Modified", "Frame Rate", "Media Duration", "Video Info", "Audio Info", "Video Codec", "Audio Codec"];
pub const DEFAULT_COLUMNS: [&str; 5] = ["Name", "Frame Rate", "Media Duration", "Video Info", "Size"];
const PROJECT_EXTENSIONS: [&str; 6] = ["fcproj", "edl", "xml", "fcpxml", "otio", "ale"];
/// Project and interchange files the browser can import. Avid Log Exchange (`.ale`) is left out:
/// FilmCraft writes it (File ▸ Export) but, like Premiere, does not read it (#1292).
const PROJECT_EXTENSIONS: [&str; 5] = ["fcproj", "edl", "xml", "fcpxml", "otio"];
const CAPTION_EXTENSIONS: [&str; 3] = ["srt", "vtt", "scc"];
const RECENT_MAX: usize = 10;
+9
View File
@@ -318,3 +318,12 @@ fn probing_large_unstreamable_files_does_not_read_them_whole() {
assert_eq!(r["items"].as_array().unwrap().len(), 1);
let _ = std::fs::remove_dir_all(&dir);
}
/// #1292: an Avid Log Exchange file is something FilmCraft writes (File ▸ Export), not a project
/// it can import, so the browser doesn't list it with the project files.
#[test]
fn ale_logs_are_not_offered_for_import() {
assert_eq!(media_browser::kind_of("clips.ale"), None);
assert!(!media_browser::extensions().contains(&"ale"));
assert_eq!(media_browser::kind_of("cut.edl"), Some("project"));
}
+108
View File
@@ -0,0 +1,108 @@
//! #1357: `edit.rippleDeleteHead` removes the portion of the selected clip from its in point
//! through the playhead and ripples the sequence closed.
use crate::Session;
use filmcraft_project::ClipId;
use filmcraft_time::Tick;
use serde_json::json;
fn f(s: &Session, n: i64) -> Tick {
s.sequence_rate().tick_of(n)
}
fn range(s: &Session, c: ClipId) -> (Tick, Tick) {
s.active_sequence().unwrap().find_item(c).map(|(_, i)| (i.start, i.end())).unwrap()
}
/// Four 48-frame clips in a row, pictures on V1 and their linked sounds on A1: [(picture, sound); 4].
fn four_clips(s: &mut Session) -> [(ClipId, ClipId); 4] {
s.execute("file.openDemoProject", json!({})).unwrap();
s.execute("file.newSequence", json!({"name": "Head", "video": 1, "audio": 1})).unwrap();
let rate = s.sequence_rate();
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 n in 0..4 {
s.execute("timeline.place", json!({"item": item.0, "frame": 48 * n, "sourceIn": rate.tick_of(48).0, "duration": rate.tick_of(48).0})).unwrap();
}
let q = s.active_sequence().unwrap();
[0, 1, 2, 3].map(|n| (q.video_tracks[0].items[n].id, q.audio_tracks[0].items[n].id))
}
/// The head (in point .. playhead) goes and the rest of the sequence closes up: the clip keeps its
/// tail at the left of its old range, later clips shift left.
#[test]
fn ripple_delete_head_closes_the_head_and_shifts_later_clips() {
let mut s = Session::default();
let [(_, _), (pic2, snd2), (pic3, snd3), (pic4, snd4)] = four_clips(&mut s);
s.set_playhead(f(&s, 60));
s.state.selection = vec![pic2];
s.execute("edit.rippleDeleteHead", json!({})).unwrap();
let q = s.active_sequence().unwrap();
q.check().unwrap();
assert_eq!(range(&s, pic2), (f(&s, 48), f(&s, 84)), "the head is gone and the tail slid left");
assert_eq!(range(&s, snd2), (f(&s, 48), f(&s, 84)), "the linked sound follows");
assert_eq!(range(&s, pic3), (f(&s, 84), f(&s, 132)), "the next picture closes up by the head length");
assert_eq!((range(&s, snd3), range(&s, pic4), range(&s, snd4)), ((f(&s, 84), f(&s, 132)), (f(&s, 132), f(&s, 180)), (f(&s, 132), f(&s, 180))));
assert_eq!(q.duration(), f(&s, 180), "the sequence is 12 frames shorter");
}
/// The trimmed clip's media in point advances by the head length (the head frames are dropped,
/// not hidden).
#[test]
fn ripple_delete_head_advances_the_source_in_point() {
let mut s = Session::default();
let [(pic1, _), ..] = four_clips(&mut s);
let in_before = s.active_sequence().unwrap().find_item(pic1).unwrap().1.source_in;
s.set_playhead(f(&s, 12));
s.state.selection = vec![pic1];
s.execute("edit.rippleDeleteHead", json!({})).unwrap();
let item = s.active_sequence().unwrap().find_item(pic1).unwrap().1;
assert_eq!(item.source_in, in_before + f(&s, 12), "the head frames are dropped from the source");
assert_eq!((item.start, item.end()), (Tick::ZERO, f(&s, 36)), "the clip starts at zero, 36 frames long");
}
/// The playhead at or before the in point has no head to remove.
#[test]
fn ripple_delete_head_needs_the_playhead_past_the_in_point() {
let mut s = Session::default();
let [(pic1, _), ..] = four_clips(&mut s);
s.set_playhead(Tick::ZERO);
s.state.selection = vec![pic1];
s.execute("edit.rippleDeleteHead", json!({})).unwrap_err();
assert_eq!(range(&s, pic1), (Tick::ZERO, s.sequence_rate().tick_of(48)), "nothing moved");
}
/// The playhead past the clip's out point deletes the whole clip (the head covers all of it).
#[test]
fn ripple_delete_head_past_the_end_removes_the_whole_clip() {
let mut s = Session::default();
let [(_, _), (pic2, snd2), (pic3, snd3), ..] = four_clips(&mut s);
s.set_playhead(f(&s, 120)); // past clip 2's out point at 96
s.state.selection = vec![pic2];
s.execute("edit.rippleDeleteHead", json!({})).unwrap();
let q = s.active_sequence().unwrap();
q.check().unwrap();
assert!(q.find_item(pic2).is_none() && q.find_item(snd2).is_none(), "the whole clip went");
assert_eq!(range(&s, pic3), (f(&s, 48), f(&s, 96)), "later clips closed the whole 48-frame gap");
assert_eq!(range(&s, snd3), (f(&s, 48), f(&s, 96)));
}
/// With Ripple Sequence Markers on, markers after the removed head move back with the clips, and
/// markers inside the head go with it.
#[test]
fn ripple_delete_head_ripples_sequence_markers() {
let mut s = Session::default();
let [(_, _), (pic2, _), (_, _), (_, _)] = four_clips(&mut s);
s.state.ripple_sequence_markers = true;
let at = |s: &Session, n: i64| s.sequence_rate().tick_of(n).0;
for (frame, name) in [(30, "before"), (52, "inside"), (120, "after")] {
s.execute("markers.add", json!({"time": at(&s, frame), "name": name})).unwrap();
}
s.set_playhead(f(&s, 60));
s.state.selection = vec![pic2];
s.execute("edit.rippleDeleteHead", json!({})).unwrap();
let q = s.active_sequence().unwrap();
let markers: Vec<(String, Tick)> = q.markers.iter().map(|m| (m.name.clone(), m.start)).collect();
assert_eq!(markers, vec![("before".to_string(), f(&s, 30)), ("after".to_string(), f(&s, 108))], "the 12-frame head closes");
}
+1
View File
@@ -1008,6 +1008,7 @@ static CATEGORIES: &[Category] = &[
("fr", "Français"),
("id", "Bahasa Indonesia"),
("it", "Italiano"),
("ko", "한국어"),
]),
true,
),
+23 -9
View File
@@ -569,8 +569,9 @@ impl Pps {
let sps = spss.get(sps_id as usize).and_then(|s| s.as_ref()).ok_or_else(|| crate::Error::MissingParameterSet(format!("SPS {sps_id} for PPS {id}")))?;
let entropy_coding_mode = r.read_flag()?;
let bottom_field_pic_order_in_frame_present = r.read_flag()?;
let num_slice_groups = r.read_ue()? + 1;
ensure!(num_slice_groups <= 8, "num_slice_groups out of range");
let num_slice_groups_minus1 = r.read_ue()?;
ensure!(num_slice_groups_minus1 < 8, "num_slice_groups out of range");
let num_slice_groups = num_slice_groups_minus1 + 1;
let mut slice_group_map_type = 0;
if num_slice_groups > 1 {
// Parsed for completeness; FMO decoding is unsupported.
@@ -592,7 +593,10 @@ impl Pps {
r.read_ue()?;
}
6 => {
let n = r.read_ue()? + 1;
let n_minus1 = r.read_ue()?;
// pic_size_in_map_units_minus1: one slice_group_id per map unit of the picture.
ensure!(n_minus1 < sps.pic_width_in_mbs.saturating_mul(sps.pic_height_in_map_units), "pic_size_in_map_units out of range");
let n = n_minus1 + 1;
let bits = 32 - (num_slice_groups - 1).leading_zeros();
for _ in 0..n {
r.read_bits(bits)?;
@@ -601,13 +605,23 @@ impl Pps {
_ => {}
}
}
let num_ref_idx_l0_default_active = r.read_ue()? + 1;
let num_ref_idx_l1_default_active = r.read_ue()? + 1;
ensure!(num_ref_idx_l0_default_active <= 32 && num_ref_idx_l1_default_active <= 32, "num_ref_idx_default_active out of range");
let num_ref_idx_l0_minus1 = r.read_ue()?;
let num_ref_idx_l1_minus1 = r.read_ue()?;
ensure!(num_ref_idx_l0_minus1 < 32 && num_ref_idx_l1_minus1 < 32, "num_ref_idx_default_active out of range");
let num_ref_idx_l0_default_active = num_ref_idx_l0_minus1 + 1;
let num_ref_idx_l1_default_active = num_ref_idx_l1_minus1 + 1;
let weighted_pred = r.read_flag()?;
let weighted_bipred_idc = r.read_bits(2)?;
let pic_init_qp = 26 + r.read_se()?;
let pic_init_qs = 26 + r.read_se()?;
// 7.4.2.2.1 fall-back: a value of 3 shall be considered equal to 2.
let weighted_bipred_idc = (r.read_bits(2)?).min(2);
// 7.4.2.2: pic_init_qp_minus26 in -(26 + QpBdOffsetY)..=25, pic_init_qs_minus26 in -26..=25.
let qp_bd_offset_y = 6 * (sps.bit_depth_luma.saturating_sub(8)) as i32;
let qp_min = -(26 + qp_bd_offset_y);
let pic_init_qp_minus26 = r.read_se()?;
ensure!((qp_min..=25).contains(&pic_init_qp_minus26), "pic_init_qp_minus26 out of range");
let pic_init_qs_minus26 = r.read_se()?;
ensure!((-26..=25).contains(&pic_init_qs_minus26), "pic_init_qs_minus26 out of range");
let pic_init_qp = 26 + pic_init_qp_minus26;
let pic_init_qs = 26 + pic_init_qs_minus26;
let chroma_qp_index_offset = r.read_se()?;
ensure!((-12..=12).contains(&chroma_qp_index_offset), "chroma_qp_index_offset out of range");
let deblocking_filter_control_present = r.read_flag()?;
+142
View File
@@ -0,0 +1,142 @@
//! #1441: malformed PPS fields must return `Error::Invalid`, not panic.
use filmcraft_h264::Decoder;
/// Valid, synthetic Baseline SPS (176x144, 8-bit).
const SPS: [u8; 12] = [0x00, 0x00, 0x00, 0x01, 0x67, 0x42, 0x00, 0x1e, 0xda, 0x0b, 0x13, 0x90];
fn decode_pps(pps: &[u8]) -> Result<(), filmcraft_h264::Error> {
let mut decoder = Decoder::with_threads(1);
decoder.decode(&SPS, 0).expect("sps decodes");
decoder.decode(pps, 0).map(|_| ())
}
/// The reporter's literal: PPS with `num_ref_idx_l0_default_active_minus1 = u32::MAX`.
#[test]
fn hostile_pps_returns_an_error_instead_of_panicking() {
let pps = [0x00, 0x00, 0x00, 0x01, 0x68, 0xc8, 0x00, 0x00, 0x03, 0x00, 0x04, 0x00, 0x00, 0x03, 0x00, 0x02, 0x3c, 0x80];
let outcome = std::panic::catch_unwind(|| {
let mut decoder = Decoder::with_threads(1);
decoder.decode(&SPS, 0).unwrap();
decoder.decode(&pps, 0)
});
assert!(outcome.is_ok_and(|result| result.is_err()));
}
/// Write ue(v) as bits (helper for building synthetic PPS payloads).
fn push_ue(bits: &mut Vec<bool>, v: u32) {
let x = v as u64 + 1;
let len = 64 - x.leading_zeros();
for _ in 1..len {
bits.push(false);
}
for i in (0..len).rev() {
bits.push(x & (1 << i) != 0);
}
}
fn push_bits(bits: &mut Vec<bool>, v: u32, n: u32) {
for i in (0..n).rev() {
bits.push(v & (1 << i) != 0);
}
}
fn to_bytes(bits: &[bool]) -> Vec<u8> {
let mut out = Vec::new();
for chunk in bits.chunks(8) {
let mut b = 0u8;
for (i, v) in chunk.iter().enumerate() {
b |= (*v as u8) << (7 - i);
}
out.push(b);
}
out
}
/// Build a PPS NAL (header byte 0x68 + payload) with `num_slice_groups_minus1` = u32::MAX.
#[test]
fn hostile_num_slice_groups_is_rejected() {
let mut bits = vec![true, true, false, false];
push_ue(&mut bits, u32::MAX); // num_slice_groups_minus1
bits.extend(std::iter::repeat_n(false, 64));
let payload = to_bytes(&bits);
bits.extend(std::iter::repeat_n(false, 64));
let mut nal = vec![0x00, 0x00, 0x00, 0x01, 0x68];
nal.extend_from_slice(&payload);
assert!(std::panic::catch_unwind(|| decode_pps(&nal)).is_ok_and(|r| r.is_err()));
}
/// `num_ref_idx_l1_default_active_minus1 = u32::MAX` (l0 valid) must error, not panic.
#[test]
fn hostile_num_ref_idx_l1_is_rejected() {
let mut bits = vec![true, true, false, false];
push_ue(&mut bits, 0); // num_slice_groups_minus1
push_ue(&mut bits, 1); // num_ref_idx_l0_minus1
push_ue(&mut bits, u32::MAX); // num_ref_idx_l1_minus1
bits.extend(std::iter::repeat_n(false, 64));
let mut nal = vec![0x00, 0x00, 0x00, 0x01, 0x68];
nal.extend_from_slice(&to_bytes(&bits));
assert!(std::panic::catch_unwind(|| decode_pps(&nal)).is_ok_and(|r| r.is_err()));
}
/// `pic_init_qp_minus26 = i32::MAX` must error, not overflow.
#[test]
fn hostile_pic_init_qp_is_rejected() {
let mut bits = vec![true, true, false, false];
push_ue(&mut bits, 0); // num_slice_groups_minus1
push_ue(&mut bits, 0); // num_ref_idx_l0_minus1
push_ue(&mut bits, 0); // num_ref_idx_l1_minus1
bits.push(false); // weighted_pred
push_bits(&mut bits, 0, 2); // weighted_bipred_idc
// se(i32::MAX): codeNum = 2*MAX-1 = u32::MAX-2... encode via ue of huge codeNum
push_ue(&mut bits, u32::MAX - 2); // codeNum for +2^31-1... any out-of-range codeNum works
bits.extend(std::iter::repeat_n(false, 64));
let mut nal = vec![0x00, 0x00, 0x00, 0x01, 0x68];
nal.extend_from_slice(&to_bytes(&bits));
assert!(std::panic::catch_unwind(|| decode_pps(&nal)).is_ok_and(|r| r.is_err()));
}
/// `weighted_bipred_idc = 3` is concealed to 2 per 7.4.2.2.1 (not rejected, not a panic).
#[test]
fn reserved_weighted_bipred_idc_is_concealed() {
let mut bits = vec![true, true, false, false];
push_ue(&mut bits, 0);
push_ue(&mut bits, 0);
push_ue(&mut bits, 0);
bits.push(false); // weighted_pred
push_bits(&mut bits, 3, 2); // weighted_bipred_idc = 3 (reserved)
push_ue(&mut bits, 0); // pic_init_qp_minus26 = 0
push_ue(&mut bits, 0); // pic_init_qs_minus26 = 0
push_ue(&mut bits, 0); // chroma_qp_index_offset = 0
bits.push(false); // deblocking_filter_control_present
bits.push(false); // constrained_intra_pred
bits.push(false); // redundant_pic_cnt_present
bits.extend(std::iter::repeat_n(false, 64));
let mut nal = vec![0x00, 0x00, 0x00, 0x01, 0x68];
nal.extend_from_slice(&to_bytes(&bits));
// parses fine; the decoder treats the field as 2
decode_pps(&nal).expect("weighted_bipred_idc=3 concealed to 2");
}
/// A well-formed minimal PPS still parses (no regression).
#[test]
fn minimal_valid_pps_parses() {
let mut bits = vec![true, true, false, false];
push_ue(&mut bits, 0);
push_ue(&mut bits, 0);
push_ue(&mut bits, 0);
bits.push(false); // weighted_pred
push_bits(&mut bits, 0, 2); // weighted_bipred_idc
push_ue(&mut bits, 0); // pic_init_qp_minus26
push_ue(&mut bits, 0); // pic_init_qs_minus26
push_ue(&mut bits, 0); // chroma_qp_index_offset
bits.push(true); // deblocking_filter_control_present
bits.push(false); // constrained_intra_pred
bits.push(false); // redundant_pic_cnt_present
bits.extend(std::iter::repeat_n(false, 64));
let mut nal = vec![0x00, 0x00, 0x00, 0x01, 0x68];
nal.extend_from_slice(&to_bytes(&bits));
if let Err(e) = decode_pps(&nal) {
panic!("minimal PPS rejected: {e}");
}
}
+52 -2
View File
@@ -1,5 +1,6 @@
//! OpenEXR stills (and the frames of EXR image sequences), read from the public OpenEXR file layout:
//! single-part scan-line files with no, RLE, ZIPS (one line per chunk) or ZIP (16 lines) compression,
//! single-part scan-line files with no, RLE, ZIPS (one line per chunk), ZIP or PXR24 (16 lines)
//! compression,
//! half, float or uint channels. R, G, B and A become the frame's channels (missing colour channels
//! are 0, missing alpha is opaque); a luminance-only file (Y) is shown grey. EXR values are
//! scene-linear with premultiplied alpha, which is exactly the compositor's working format, so the
@@ -7,7 +8,7 @@
//!
//! The display window is the frame; pixels of the data window outside it are cut, and the parts of
//! it the data window does not cover are transparent. Tiled, deep and multi-part files, chroma
//! subsampled channels and the PIZ, PXR24, B44 and DWA compressions are refused with a clear error.
//! subsampled channels and the PIZ, B44 and DWA compressions are refused with a clear error.
mod inflate;
@@ -134,12 +135,15 @@ enum Compression {
None,
Rle,
Zip,
/// Lossy 24-bit floats: deflated byte planes of per-line differences.
Pxr24,
}
impl Compression {
fn lines_per_chunk(self, code: u8) -> usize {
match self {
Compression::Zip if code == 3 => 16,
Compression::Pxr24 => 16,
_ => 1,
}
}
@@ -227,6 +231,7 @@ fn header(name: &str, b: &[u8]) -> Result<Header> {
0 => Compression::None,
1 => Compression::Rle,
2 | 3 => Compression::Zip,
5 => Compression::Pxr24,
_ => {
let what = ["PIZ", "PXR24", "B44", "B44A", "DWAA", "DWAB"].get(usize::from(code).wrapping_sub(4)).copied().unwrap_or("unknown");
return Err(MediaError::Unsupported(format!("{name}: {what} compressed OpenEXR is not supported yet")));
@@ -329,6 +334,45 @@ fn unrle(src: &[u8], expected: usize) -> Option<Vec<u8>> {
(out.len() == expected).then_some(out)
}
/// Bytes of one sample in PXR24's byte planes (a float keeps its top 24 bits only).
fn plane_count(ty: PixelType) -> usize {
match ty {
PixelType::Half => 2,
PixelType::Float => 3,
PixelType::Uint => 4,
}
}
/// Undo PXR24: per scan line and channel, the samples' differences (to the previous sample of the
/// line, modulo 2^16 / 2^24 / 2^32) are stored as byte planes, most significant first. Returns the
/// lines in the plain layout (little-endian samples).
fn unpxr24(planes: &[u8], channels: &[Channel], width: usize, lines: usize) -> Option<Vec<u8>> {
let mut out = Vec::with_capacity(planes.len().checked_mul(2)?);
let mut at = 0usize;
for _ in 0..lines {
for c in channels {
let n = plane_count(c.ty);
let len = n.checked_mul(width)?;
let plane = planes.get(at..at.checked_add(len)?)?;
at += len;
let mut acc = 0u32;
for x in 0..width {
let mut d = 0u32;
for k in 0..n {
d = d << 8 | u32::from(*plane.get(k * width + x)?);
}
acc = acc.wrapping_add(d);
match c.ty {
PixelType::Half => out.extend_from_slice(&(acc as u16).to_le_bytes()),
PixelType::Float => out.extend_from_slice(&((acc & 0xFF_FFFF) << 8).to_le_bytes()),
PixelType::Uint => out.extend_from_slice(&acc.to_le_bytes()),
}
}
}
}
(at == planes.len()).then_some(out)
}
/// Decode an EXR file into premultiplied linear RGBA floats.
pub fn decode(name: &str, b: &[u8]) -> Result<Decoded> {
let h = header(name, b)?;
@@ -403,6 +447,12 @@ pub fn decode(name: &str, b: &[u8]) -> Result<Decoded> {
Compression::None => return Err(bad(format!("chunk {i} has the wrong size"))),
Compression::Rle => unpredict(unrle(data, expected).ok_or_else(|| bad(format!("chunk {i}: bad RLE data")))?).into(),
Compression::Zip => unpredict(inflate::zlib(data, expected).map_err(|e| bad(format!("chunk {i}: {e}")))?).into(),
Compression::Pxr24 => {
let per_line = h.channels.iter().try_fold(0usize, |t, c| t.checked_add(plane_count(c.ty).checked_mul(dw)?));
let planes = per_line.and_then(|l| l.checked_mul(n)).ok_or_else(|| bad("chunk size".into()))?;
let inflated = inflate::zlib(data, planes).map_err(|e| bad(format!("chunk {i}: {e}")))?;
unpxr24(&inflated, &h.channels, dw, n).ok_or_else(|| bad(format!("chunk {i}: bad PXR24 data")))?.into()
}
}
};
for (l, out) in rows.chunks_exact_mut(row).enumerate() {
+74 -2
View File
@@ -25,6 +25,35 @@ fn predict(raw: &[u8]) -> Vec<u8> {
t
}
/// The test-side PXR24 packing of the `lines` of `raw` (plain layout): per line and channel, the
/// differences to the previous sample as byte planes, floats cut to their top 24 bits.
fn pxr24(raw: &[u8], chans: &[Chan], width: usize, lines: usize) -> Vec<u8> {
let mut at = 0;
let mut out = Vec::new();
for _ in 0..lines {
for (_, t) in chans {
let size = if *t == 1 { 2 } else { 4 };
let mut prev = 0u32;
let mut planes = vec![Vec::new(); size];
for x in 0..width {
let b = &raw[at + x * size..at + (x + 1) * size];
let v = match *t {
1 => u32::from(u16::from_le_bytes([b[0], b[1]])),
2 => u32::from_le_bytes([b[0], b[1], b[2], b[3]]) >> 8,
_ => u32::from_le_bytes([b[0], b[1], b[2], b[3]]),
};
let d = v.wrapping_sub(prev);
prev = v;
let n = if *t == 2 { 3 } else { size };
planes[0..n].iter_mut().enumerate().for_each(|(k, p)| p.push((d >> (8 * (n - 1 - k))) as u8));
}
at += width * size;
out.extend(planes.concat());
}
}
out
}
/// OpenEXR RLE: runs of 3+ equal bytes as runs, the rest as literals.
fn rle(t: &[u8]) -> Vec<u8> {
let mut out = Vec::new();
@@ -105,7 +134,7 @@ fn exr_file(chans: &[Chan], data: [i32; 4], display: [i32; 4], comp: u8, value:
attr(&mut f, "lineOrder", "lineOrder", &[0]);
attr(&mut f, "pixelAspectRatio", "float", &1f32.to_le_bytes());
f.push(0);
let lpc = if comp == 3 { 16 } else { 1 };
let lpc = if comp == 3 || comp == 5 { 16 } else { 1 };
let ys: Vec<i32> = (data[1]..=data[3]).step_by(lpc).collect();
let table = f.len();
f.resize(table + ys.len() * 8, 0);
@@ -121,6 +150,10 @@ fn exr_file(chans: &[Chan], data: [i32; 4], display: [i32; 4], comp: u8, value:
let packed = match comp {
0 => raw,
1 => rle(&predict(&raw)),
5 => {
let lines = (data[3] - y0 + 1).min(16) as usize;
zlib_stored(&pxr24(&raw, chans, (data[2] - data[0] + 1) as usize, lines))
}
_ => zlib_stored(&predict(&raw)),
};
let off = f.len() as u64;
@@ -178,7 +211,7 @@ fn rgba_half_every_compression() {
// pixel (3, 2): R G B A
let p = &px[(2 * 7 + 3) * 4..][..4];
assert_eq!(p, &[pattern(3, 3, 2), pattern(2, 3, 2), pattern(1, 3, 2), pattern(0, 3, 2)]);
for comp in [1, 2, 3] {
for comp in [1, 2, 3, 5] {
let f = exr_file(RGBA_HALF, [0, 0, w - 1, h - 1], [0, 0, w - 1, h - 1], comp, pattern);
assert_ne!(f, want, "compression {comp} packs differently");
let d = decode("a.exr", &f).unwrap_or_else(|e| panic!("compression {comp}: {e}"));
@@ -186,6 +219,43 @@ fn rgba_half_every_compression() {
}
}
#[test]
fn pxr24_keeps_half_and_uint_exactly_and_floats_to_24_bits() {
// 40 wide, 35 tall: three chunks, the last of 3 lines; one channel of every type
let chans: &[Chan] = &[("B", 2), ("G", 1), ("R", 2), ("Z", 0)];
let value = |c: usize, x: i32, y: i32| match c {
0 => 1.0 / 3.0 + x as f32 * 0.37 - y as f32 * 1.5, // not exact in 24 bits
1 => x as f32 * 0.0625 + y as f32,
2 => 6000.7 - (x * y) as f32 * 1.25,
_ => (x * 1000 + y) as f32,
};
let f = exr_file(chans, [0, 0, 39, 34], [0, 0, 39, 34], 5, value);
let d = decode("p.exr", &f).unwrap();
assert_eq!((d.frame.width, d.frame.height), (40, 35));
assert_eq!(d.pixel_format, "RGB 32-bit float");
let px = floats(&d);
for (x, y) in [(0, 0), (1, 0), (39, 0), (0, 16), (7, 17), (39, 34), (13, 33)] {
let p = &px[(y * 40 + x) * 4..][..4];
let (x, y) = (x as i32, y as i32);
let cut = |v: f32| f32::from_bits(v.to_bits() & 0xFFFF_FF00);
assert_eq!(p, &[cut(value(2, x, y)), half(f16(value(1, x, y))), cut(value(0, x, y)), 1.0], "pixel ({x}, {y})");
}
}
#[test]
fn damaged_pxr24_is_an_error() {
let mut f = exr_file(RGBA_HALF, [0, 0, 9, 19], [0, 0, 9, 19], 5, pattern);
assert!(decode("p.exr", &f).is_ok());
// cut the file inside the last chunk
f.truncate(f.len() - 20);
assert!(decode("p.exr", &f).is_err());
// a chunk whose deflate data is garbage
let mut g = exr_file(RGBA_HALF, [0, 0, 9, 19], [0, 0, 9, 19], 5, pattern);
let at = g.windows(2).rposition(|w| w == [0x78, 0x01]).unwrap();
g[at] = 0;
assert!(decode("p.exr", &g).is_err());
}
#[test]
fn luminance_only_is_grey_and_opaque() {
let f = exr_file(&[("Y", 2)], [0, 0, 3, 1], [0, 0, 3, 1], 2, |_, x, y| x as f32 * 0.25 + y as f32 * 4.0);
@@ -242,6 +312,8 @@ fn unsupported_files_say_why() {
assert!(matches!(decode("t.exr", &tiled), Err(MediaError::Unsupported(m)) if m.contains("tiled")));
let piz = exr_file(RGBA_HALF, [0, 0, 1, 1], [0, 0, 1, 1], 4, pattern);
assert!(matches!(decode("p.exr", &piz), Err(MediaError::Unsupported(m)) if m.contains("PIZ")));
let b44 = exr_file(RGBA_HALF, [0, 0, 1, 1], [0, 0, 1, 1], 6, pattern);
assert!(matches!(decode("b.exr", &b44), Err(MediaError::Unsupported(m)) if m.contains("B44")));
let dwab = exr_file(RGBA_HALF, [0, 0, 1, 1], [0, 0, 1, 1], 9, pattern);
assert!(matches!(decode("d.exr", &dwab), Err(MediaError::Unsupported(m)) if m.contains("DWAB")));
let ry = exr_file(&[("BY", 1), ("RY", 1), ("Y", 1)], [0, 0, 1, 1], [0, 0, 1, 1], 0, pattern);
+48
View File
@@ -141,3 +141,51 @@ fn exr_sequence_plays_linear_float_frames() {
assert!(max_diff(&ours, &want) == 0.0, "frame {i}");
}
}
/// PXR24 (lossy 24-bit floats; ffmpeg's encoder cannot write it): OpenImageIO writes the files and
/// re-writes them uncompressed (its OpenEXR reader undoes the compression), and our decode of the
/// PXR24 file must give exactly the floats of that uncompressed copy.
#[test]
fn exr_pxr24_matches_openexr() {
let tool = |args: &[&str]| std::process::Command::new("oiiotool").args(args).status().is_ok_and(|s| s.success());
if !tool(&["--version"]) {
eprintln!("SKIPPED: oiiotool is not installed");
return;
}
for (name, depth, channels, size) in [("f4", "float", 4, "95x63"), ("h4", "half", 4, "40x35"), ("f3", "float", 3, "17x16"), ("h3", "half", 3, "16x17")] {
let packed = dir().join(format!("pxr24_{name}.exr"));
let plain = dir().join(format!("pxr24_{name}_plain.exr"));
let ok = |p: &Path| {
filmcraft_testkit::fixtures::generate(p, |tmp| {
let t = tmp.to_string_lossy().to_string();
if p == packed.as_path() {
tool(&[
"--pattern",
"fill:topleft=1,0,0,0.5:topright=0,1,0,0.5:bottomleft=0,0,1,1:bottomright=1,1,1,1",
size,
&channels.to_string(),
"--noise:type=gaussian:mean=0:stddev=0.3",
"-d",
depth,
"--compression",
"pxr24",
"-o",
&t,
])
} else {
tool(&[packed.to_string_lossy().as_ref(), "-d", depth, "--compression", "none", "-o", &t])
}
})
};
let (Some(packed), Some(_)) = (ok(&packed), ok(&plain)) else {
eprintln!("SKIPPED: oiiotool cannot write {name}");
continue;
};
let plain = dir().join(format!("pxr24_{name}_plain.exr"));
let decode = |p: &Path| {
floats(&filmcraft_media::open_bytes("still.exr", std::fs::read(p).unwrap().into(), &[]).unwrap().video_frame(FrameRequest::full(Tick(0))).unwrap())
};
let (ours, want) = (decode(&packed), decode(&plain));
assert_eq!(max_diff(&ours, &want), 0.0, "{name}");
}
}
+1 -1
View File
@@ -18,7 +18,7 @@ the committed span and undo. Set `FILMCRAFT_UI_SNAPSHOT_DIR` to render screensho
## Localisation
Edit > Language offers English, Japanese, Spanish, Brazilian Portuguese, Ukrainian, Simplified
Chinese, German, Russian, French, Indonesian and Italian (both cover the whole interface). The language is stored in the engine's `general.interfaceLanguage` preference,
Chinese, German, Russian, French, Indonesian, Korean and Italian (which cover the whole interface). The language is stored in the engine's `general.interfaceLanguage` preference,
restored on startup, and also available in Settings > General.
Its default, System Language (`system`), follows the operating system: the first of the user's
preferred languages that the interface has (the host supplies them through
+6
View File
@@ -53,6 +53,12 @@ fn find_system_font(sample: &str, preferred: &[&str]) -> Option<Arc<FontData>> {
Some(Arc::new(FontData { font: std::borrow::Cow::Owned(face.data()?), index: face.info.index, tweak: Default::default() }))
}
/// Whether [`install`] has a Hangul face to add (an installed system face; none is bundled).
/// The Korean interface needs one.
pub(crate) fn korean_available() -> bool {
korean_font().is_some()
}
/// Whether [`install`] has a Chinese face to add: craft-fonts Hans/Hant faces, or an installed
/// system face (scanned once per process). The Simplified Chinese interface needs one.
pub(crate) fn available() -> bool {
+42 -38
View File
@@ -296,7 +296,7 @@
Save as Template… Guardar como plantilla…
Scale to Frame Size Escalar al tamaño del fotograma
Scene Edit Detection… Detección de cambios de escena…
Scratch Disks… Discos de memoria virtual…
Scratch Disks… Discos de trabajo…
Search Bin Bandeja de búsqueda
Select Seleccionar
Select All Seleccionar todo
@@ -320,7 +320,7 @@
Show All Marker Colors Mostrar todos los colores de marcador
Show Guides Mostrar guías
Show Rulers Mostrar reglas
Show Through Edits Mostrar ediciones pasantes
Show Through Edits Mostrar cortes continuos
Simplify Sequence… Simplificar secuencia…
Snap in Program Monitor Ajustar en el monitor de programa
Snap in Timeline Ajustar en la línea de tiempo
@@ -344,7 +344,7 @@
Transcribe… Transcribir…
Transcript Transcripción
Transparent Video… Video transparente…
Trim Edit Recortar edición
Trim Edit Edición de recorte
Trim… Recorte…
Undo Deshacer
Ungroup Desagrupar
@@ -546,8 +546,8 @@
Search effects Buscar efectos
Apply to Selected Clips Aplicar a los clips seleccionados
Metadata Display Visualización de metadatos
Save As New View Preset Guardar como nuevo ajuste de vista
Manage Saved View Presets Gestionar ajustes de vista guardados
Save As New View Preset Guardar como nuevo ajuste preestablecido de vista
Manage Saved View Presets Gestionar ajustes preestablecidos de vista guardados
Freeform View Options Opciones de la vista de forma libre
Save Arrangement Guardar disposición
Saves the view, columns, sort, thumbnail and font size. Guarda la vista, las columnas, el orden, la miniatura y el tamaño de fuente.
@@ -600,12 +600,12 @@
Out: {name} Salida: {name}
In: {name} Entrada: {name}
Play Around Edit (Space loops, Shift+K once) Reproducir alrededor de la edición (Espacio en bucle, Mayús+K una vez)
Trim Backward Many Recortar muchos hacia atrás
Trim Backward Many Recortar varios fotogramas hacia atrás
Trim Backward Recortar hacia atrás
Apply Default Transitions Aplicar transiciones predeterminadas
Apply Default Transitions to Selection (Shift+D) Aplicar transiciones predeterminadas a la selección (Mayús+D)
Trim Forward Recortar hacia delante
Trim Forward Many Recortar muchos hacia delante
Trim Forward Many Recortar varios fotogramas hacia delante
Exit Trim Mode Salir del modo de recorte
Voice-over record (right-click: Voice-Over Record Settings…) Grabar voz en off (clic derecho: ajustes de grabación de voz en off…)
Voice-Over Record Settings… Ajustes de grabación de voz en off…
@@ -680,7 +680,7 @@
Extract (') Extraer (')
Export Frame: use Export mode (M6) Exportar fotograma: usa el modo Exportar (M6)
Button Editor Editor de botones
More transport controls Más controles de reproducción
More transport controls Más controles de transporte
Multi-Camera Record on Grabación multicámara activada
Multi-Camera Record off Grabación multicámara desactivada
{first} - Merged {first} - Combinado
@@ -713,7 +713,7 @@
Enumerate Cameras Numerar cámaras
Camera Angle Metadata Metadatos de ángulo de cámara
Move source clips to Processed Clips bin Mover los clips de origen a la bandeja Clips procesados
Synchronized {n} clip group(s) Sincronizado(s) {n} grupo(s) de clips
Synchronized {n} clip group(s) {n} grupo(s) de clips sincronizado(s)
Merged clip created Clip combinado creado
Multi-camera source sequence with {n} camera(s) Secuencia de origen multicámara con {n} cámara(s)
No multi-camera clip at the playhead No hay ningún clip multicámara en el cabezal
@@ -725,7 +725,7 @@
Record Grabar
Edit Cameras Editar cámaras
Cameras shown in the Multi-Camera view (uncheck to hide). Cámaras mostradas en la vista multicámara (desmarca para ocultar).
Recorded {n} multi-camera cut(s) Grabado(s) {n} corte(s) multicámara
Recorded {n} multi-camera cut(s) {n} corte(s) multicámara grabado(s)
Clamp Signal Limitar señal
Presets Ajustes preestablecidos
Parade ({kind}) Desfile ({kind})
@@ -797,7 +797,7 @@
Offline Sin conexión
Missing No encontrado
Link Media Vincular medios
{n} clip(s) can't find their media. Locate them, search a folder, or leave them offline. {n} clip(s) no encuentran sus medios. Localízalos, busca en una carpeta o déjalos sin conexión.
{n} clip(s) can't find their media. Locate them, search a folder, or leave them offline. {n} clip(s) no encuentra(n) sus medios. Localízalo(s), busca en una carpeta o déjalo(s) sin conexión.
Clip Name Nombre del clip
File Name Nombre de archivo
File Path Ruta del archivo
@@ -1195,9 +1195,9 @@
Thumbnail controls for all pointing devices Controles de miniaturas para todos los dispositivos señaladores
Refresh Sort Order Actualizar el orden
Metadata Display… Visualización de metadatos…
Save Current View Preset Guardar el ajuste de vista actual
Restore View Preset Restaurar ajuste de vista
Project View Preset {n} Ajuste de vista del proyecto {n}
Save Current View Preset Guardar el ajuste preestablecido de vista actual
Restore View Preset Restaurar ajuste preestablecido de vista
Project View Preset {n} Ajuste preestablecido de vista del proyecto {n}
Freeform View Options… Opciones de la vista de forma libre…
Media missing Medio no encontrado
Unreadable Ilegible
@@ -1378,7 +1378,7 @@
Black and white Blanco y negro
Black and white with crushed blacks and strong contrast Blanco y negro con negros aplastados y contraste fuerte
Bleach Bypass Blanqueo omitido
Bleach-bypass style: desaturated with hard contrast Estilo blanqueo omitido: desaturado y con contraste duro
Bleach-bypass style: desaturated with hard contrast Estilo de blanqueo omitido: desaturado y con contraste duro
Blend Mezclar
Blend Mode Modo de fusión
Blend With Original Mezclar con el original
@@ -1421,7 +1421,7 @@
Bright Air Aire brillante
Bright Studio Estudio brillante
Brightness & Contrast Brillo y contraste
British Stack Pila británica
British Stack Stack británico
Brush Hardness Dureza del pincel
Brush Opacity Opacidad del pincel
Brush Position Posición del pincel
@@ -1435,14 +1435,14 @@
Burn Alpha Quemar alfa
Burn Chroma Quemar croma
Burn Color Quemar color
Butt cap Extremo cuadrado
Butt cap Extremo plano
Butterworth Butterworth
CEA-608 CEA-608
CEA-708 CEA-708
Cache analysis results for re-use Guardar en caché los resultados del análisis para reutilizarlos
Camera Angle Ángulo de cámara
Camera Blur Desenfoque de cámara
Camera Shake Movimiento de cámara
Camera Shake Vibración de cámara
Capitalisation Uso de mayúsculas
Cathedral Catedral
Cell Pattern Patrón de celdas
@@ -1727,7 +1727,7 @@
Flip Motion Movimiento de volteo
Flip Over Voltear
Float Coma flotante
Focus Foco
Focus Enfoque
Focus Blur Desenfoque de foco
Focus Point Punto de enfoque
Focus Shape Forma del foco
@@ -1835,6 +1835,8 @@
Height Altura
Hexagon Hexágono
Hide Back Face Ocultar la cara posterior
Hide FilmCraft Ocultar FilmCraft
Hide Others Ocultar otras
High Alta
High Cutoff Corte de agudos
High Frequency Cut Corte de altas frecuencias
@@ -1911,7 +1913,7 @@
Italian Italiano
Japanese Japonés
JetBrains Mono JetBrains Mono
Join Through Edits Unir ediciones pasantes
Join Through Edits Unir cortes continuos
Ripple Trim In Recorte de rizo de entrada
Ripple Trim Out Recorte de rizo de salida
Roll Edit Edición de rodillo
@@ -2050,7 +2052,7 @@
Median Mediana
Medium Room Sala mediana
Memory Memoria
Metadata & Timecode Burn-in Impresión de metadatos y código de tiempo
Metadata & Timecode Burn-in Incrustación de metadatos y código de tiempo
Method Método
Metrics Kerning Ajuste entre pares métrico
Mid Medios
@@ -2341,7 +2343,7 @@
Scan Lines Líneas de exploración
Scene Escena
Scientific Filter Filtro científico
Scratch disk location for Captured Audio Ubicación del disco de memoria virtual para el audio capturado
Scratch disk location for Captured Audio Ubicación del disco de trabajo para el audio capturado
Screen Trama
Seconds Segundos
Segments Segmentos
@@ -2351,6 +2353,7 @@
Set focus on the Timeline when performing Insert/Overwrite edits Enfocar la línea de tiempo al insertar o sobrescribir
Set to frame size Ajustar al tamaño del fotograma
Setting Ajuste
Settings Ajustes
Shadow Angle Ángulo de la sombra
Shadow Blue Balance Balance de azul en sombras
Shadow Blur Desenfoque de la sombra
@@ -2756,7 +2759,7 @@
All Todos
Any Cualquiera
Case Sensitive Distinguir mayúsculas y minúsculas
Scratch Disks Discos de memoria virtual
Scratch Disks Discos de trabajo
Ingest Settings Ajustes de ingesta
Captured and Generated: Capturado y generado:
Video Previews: Previsualizaciones de video:
@@ -2894,7 +2897,7 @@
The sequence has no caption track. La secuencia no tiene pista de subtítulos.
Export Options Opciones de exportación
Burn Captions Into Video Grabar los subtítulos en el video
Create Sidecar File Crear archivo adjunto
Create Sidecar File Crear archivo complementario
File Format Formato de archivo
SubRip Subtitle (.srt) Subtítulo SubRip (.srt)
WebVTT (.vtt) WebVTT (.vtt)
@@ -2961,7 +2964,7 @@
File Name & Location Nombre y ubicación del archivo
Quick Export: {path} Exportación rápida: {path}
Graphics problem: switched to software compositing Problema de gráficos: se ha cambiado a la composición por software
GPU acceleration is unavailable; preview renders on the CPU La aceleración por GPU no está disponible; la vista previa se renderiza en la CPU
GPU acceleration is unavailable; preview renders on the CPU La aceleración por GPU no está disponible; la vista previa se procesa en la CPU
Exporting Exportando
Click to select, or click in empty space and drag to marquee select. Use Shift, Opt, and Cmd for other options. Haz clic para seleccionar, o haz clic en un espacio vacío y arrastra para seleccionar con un marco. Usa Mayús, Opción y Cmd para otras opciones.
Click to select all clips to the right in all tracks. Shift-click for a single track. Haz clic para seleccionar todos los clips a la derecha en todas las pistas. Mayús+clic para una sola pista.
@@ -2981,7 +2984,7 @@
(empty) (vacío)
Step Back 1 Frame Retroceder 1 fotograma
Step Forward 1 Frame Avanzar 1 fotograma
Match Playhead Igualar cabezal
Match Playhead Igualar al cabezal
Gang to Program Vincular al programa
AAC Audio AAC Audio
AAC Audio 48 kHz 320 kbps AAC Audio 48 kHz 320 kbps
@@ -3178,14 +3181,14 @@
Nudge Clip Selection Right Five Frames Desplazar la selección de clips cinco fotogramas a la derecha
Nudge Clip Selection Right One Frame Desplazar la selección de clips un fotograma a la derecha
Nudge Clip Selection Up Desplazar la selección de clips hacia arriba
Nudge Selected Object down by five Desplazar el objeto seleccionado cinco hacia abajo
Nudge Selected Object down by one Desplazar el objeto seleccionado uno hacia abajo
Nudge Selected Object to left by five Desplazar el objeto seleccionado cinco a la izquierda
Nudge Selected Object to left by one Desplazar el objeto seleccionado uno a la izquierda
Nudge Selected Object to right by five Desplazar el objeto seleccionado cinco a la derecha
Nudge Selected Object to right by one Desplazar el objeto seleccionado uno a la derecha
Nudge Selected Object up by five Desplazar el objeto seleccionado cinco hacia arriba
Nudge Selected Object up by one Desplazar el objeto seleccionado uno hacia arriba
Nudge Selected Object down by five Desplazar el objeto seleccionado cinco píxeles hacia abajo
Nudge Selected Object down by one Desplazar el objeto seleccionado un píxel hacia abajo
Nudge Selected Object to left by five Desplazar el objeto seleccionado cinco píxeles a la izquierda
Nudge Selected Object to left by one Desplazar el objeto seleccionado un píxel a la izquierda
Nudge Selected Object to right by five Desplazar el objeto seleccionado cinco píxeles a la derecha
Nudge Selected Object to right by one Desplazar el objeto seleccionado un píxel a la derecha
Nudge Selected Object up by five Desplazar el objeto seleccionado cinco píxeles hacia arriba
Nudge Selected Object up by one Desplazar el objeto seleccionado un píxel hacia arriba
Nudge Volume +1dB Ajustar volumen +1 dB
Nudge Volume +3dB Ajustar volumen +3 dB
Nudge Volume -1dB Ajustar volumen -1 dB
@@ -3394,7 +3397,7 @@
Protect Highlights Proteger luces
Half Saturation Saturación al 50 %
Boost Vibrance Aumentar intensidad
Legal Range Squeeze Compresión al rango de vídeo
Legal Range Squeeze Compresión al rango legal
A boxed label with a round pointer; move the label and the pointer follows. Una etiqueta enmarcada con un indicador redondo; al mover la etiqueta, el indicador la sigue.
A rounded tag that fits its text, with a status dot. Una etiqueta redondeada que se ajusta al texto, con un punto de estado.
A title on a rounded card with a soft shadow. Un título en una tarjeta redondeada con una sombra suave.
@@ -3438,11 +3441,12 @@
in entrada
The language changed but could not be saved: {e} El idioma ha cambiado, pero no se ha podido guardar: {e}
no Chinese font is installed on this system; the interface stays in English no hay ninguna fuente china instalada en este sistema; la interfaz sigue en inglés
no Korean font is installed on this system; the interface stays in English No hay ninguna fuente coreana instalada en este sistema; la interfaz sigue en inglés
no Japanese font is installed on this system; the interface stays in English no hay ninguna fuente japonesa instalada en este sistema; la interfaz sigue en inglés
Show Mostrar
Sort Ordenar
Reverse the order Invertir el orden
Grab bag Mosaico
Grab bag Miscelánea
Table Tabla
No contributor data was built into this copy. Esta copia no incluye datos de colaboradores.
No model credits were built into this copy. Esta copia no incluye créditos de modelos.
@@ -3563,7 +3567,7 @@
Gradient Angle Ángulo del degradado
Linear Gradient Degradado lineal
Solid Sólido
GPU Rendering Renderizado por GPU
GPU Rendering Procesamiento por GPU
Composite on the GPU when available Componer en la GPU cuando esté disponible
Text to Speech Texto a voz
Edit Narration Editar narración
@@ -3591,7 +3595,7 @@
Use Default Graphics Usar gráficos predeterminados
The default graphics are used from the next start. Los gráficos predeterminados se usarán a partir del próximo inicio.
DirectX 12 only Solo DirectX 12
software rendering renderizado por software
software rendering procesamiento por software
Label Color Presets Ajustes preestablecidos de color de etiqueta
{name} (Modified) {name} (modificado)
Revert to the preset's colors and names Volver a los colores y nombres del ajuste preestablecido
Can't render this file because it contains an unexpected character in line 3 and column 95.
+1
View File
@@ -3420,6 +3420,7 @@
in entrée
The language changed but could not be saved: {e} La langue a été changée mais n'a pas pu être enregistrée : {e}
no Chinese font is installed on this system; the interface stays in English aucune police chinoise n'est installée sur ce système ; l'interface reste en anglais
no Korean font is installed on this system; the interface stays in English aucune police coréenne n'est installée sur ce système ; l'interface reste en anglais
no Japanese font is installed on this system; the interface stays in English aucune police japonaise n'est installée sur ce système ; l'interface reste en anglais
Show Afficher
Sort Trier
Can't render this file because it contains an unexpected character in line 5 and column 83.
+1
View File
@@ -3437,6 +3437,7 @@
in masuk
The language changed but could not be saved: {e} Bahasa telah diubah tetapi tidak dapat disimpan: {e}
no Chinese font is installed on this system; the interface stays in English tidak ada font Tionghoa yang terpasang di sistem ini; antarmuka tetap dalam bahasa Inggris
no Korean font is installed on this system; the interface stays in English tidak ada font Korea yang terpasang di sistem ini; antarmuka tetap dalam bahasa Inggris
no Japanese font is installed on this system; the interface stays in English tidak ada font Jepang yang terpasang di sistem ini; antarmuka tetap dalam bahasa Inggris
Show Tampilkan
Sort Urutkan
Can't render this file because it contains an unexpected character in line 6 and column 14.
+1
View File
@@ -3426,6 +3426,7 @@
in イン
The language changed but could not be saved: {e} 表示言語は変更されましたが、保存できませんでした: {e}
no Chinese font is installed on this system; the interface stays in English このシステムには中国語フォントがインストールされていないため、表示は英語のままです
no Korean font is installed on this system; the interface stays in English このシステムには韓国語フォントがインストールされていないため、表示は英語のままです
no Japanese font is installed on this system; the interface stays in English このシステムには日本語フォントがインストールされていないため、表示は英語のままです
Show 表示
Sort 並べ替え
Can't render this file because it contains an unexpected character in line 11 and column 59.
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,8 @@
asset: ko.tsv
title: Korean interface translation catalog
author: FilmCraft contributors
source: original work
license: MIT OR Apache-2.0
license-file: LICENSE-MIT and LICENSE-APACHE
added: 2026-10-13
notes: Original English-to-Korean interface translations using clean-room NLE vocabulary.
+44 -2
View File
@@ -47,6 +47,8 @@ pub enum Language {
Fr,
Id,
It,
/// Korean; Hangul needs an installed system font ([`korean_font_available`]).
Ko,
}
static JAPANESE: OnceLock<Catalog> = OnceLock::new();
@@ -57,11 +59,12 @@ static CHINESE: OnceLock<Catalog> = OnceLock::new();
static GERMAN: OnceLock<Catalog> = OnceLock::new();
static RUSSIAN: OnceLock<Catalog> = OnceLock::new();
static FRENCH: OnceLock<Catalog> = OnceLock::new();
static KOREAN: OnceLock<Catalog> = OnceLock::new();
static INDONESIAN: OnceLock<Catalog> = OnceLock::new();
static ITALIAN: OnceLock<Catalog> = OnceLock::new();
impl Language {
pub const ALL: [Self; 11] = [Self::En, Self::Ja, Self::Es, Self::PtBr, Self::Uk, Self::ZhCn, Self::De, Self::Ru, Self::Fr, Self::Id, Self::It];
pub const ALL: [Self; 12] = [Self::En, Self::Ja, Self::Es, Self::PtBr, Self::Uk, Self::ZhCn, Self::De, Self::Ru, Self::Fr, Self::Id, Self::Ko, Self::It];
pub fn name(self) -> &'static str {
match self {
@@ -76,6 +79,7 @@ impl Language {
Self::Fr => "Français",
Self::Id => "Bahasa Indonesia",
Self::It => "Italiano",
Self::Ko => "한국어",
}
}
@@ -92,6 +96,7 @@ impl Language {
"fr" => Some(Self::Fr),
"id" => Some(Self::Id),
"it" => Some(Self::It),
"ko" => Some(Self::Ko),
_ => None,
}
}
@@ -106,7 +111,7 @@ impl Language {
/// only for Simplified locales (`zh`, `zh-CN`, `zh-SG`, `zh-Hans…`); Traditional ones (`zh-TW`,
/// `zh-HK`, `zh-MO`, `zh-Hant…`) are skipped.
pub fn from_locales(tags: &[String]) -> Self {
const PRIMARY: [(&str, Language); 11] = [
const PRIMARY: [(&str, Language); 12] = [
("en", Language::En),
("ja", Language::Ja),
("es", Language::Es),
@@ -118,6 +123,7 @@ impl Language {
("id", Language::Id),
("in", Language::Id),
("it", Language::It),
("ko", Language::Ko),
];
tags.iter()
.find_map(|tag| {
@@ -149,6 +155,7 @@ impl Language {
Self::Fr => "fr",
Self::Id => "id",
Self::It => "it",
Self::Ko => "ko",
}
}
@@ -166,6 +173,7 @@ impl Language {
Self::Fr => Some(FRENCH.get_or_init(|| Catalog::parse(include_str!("fr.tsv")))),
Self::Id => Some(INDONESIAN.get_or_init(|| Catalog::parse(include_str!("id.tsv")))),
Self::It => Some(ITALIAN.get_or_init(|| Catalog::parse(include_str!("it.tsv")))),
Self::Ko => Some(KOREAN.get_or_init(|| Catalog::parse(include_str!("ko.tsv")))),
}
}
@@ -321,6 +329,13 @@ pub fn chinese_font_available() -> bool {
crate::cjk::available()
}
/// Whether the interface can show Korean: `theme::install` adds an installed system Hangul face
/// (Malgun Gothic, Apple SD Gothic Neo, Noto Sans CJK KR…, see `cjk.rs`) when there is one; none
/// is bundled. False on systems (and the web) without one.
pub fn korean_font_available() -> bool {
crate::cjk::korean_available()
}
#[cfg(test)]
mod tests {
use super::*;
@@ -338,6 +353,7 @@ mod tests {
("fr", include_str!("fr.tsv")),
("id", include_str!("id.tsv")),
("it", include_str!("it.tsv")),
("ko", include_str!("ko.tsv")),
] {
let (entries, errors) = catalog::parse_entries(text);
assert!(errors.is_empty(), "{code}: {errors:?}");
@@ -1138,6 +1154,32 @@ mod tests {
set_current(Language::En);
}
/// Korean covers the whole interface (the Spanish catalog's entries and the native macOS
/// Settings menu); like French, the tests require the core menus, and every entry must still
/// have a Spanish counterpart so a renamed or removed source can't leave a dead Korean entry.
#[test]
fn korean_covers_the_menus_and_has_no_dead_entries() {
let (entries, errors) = catalog::parse_entries(include_str!("ko.tsv"));
assert!(errors.is_empty(), "{errors:?}");
for (ctx, en, _) in &entries {
assert!(ctx.is_empty() && (Language::Es.has(en) || en == "Settings"), "Korean entry without a Spanish one: {ctx:?} {en:?}");
}
for en in core_menu_labels() {
assert!(Language::Ko.has(&en), "missing Korean menu label: {en}");
}
for en in crate::menus::MENUS.into_iter().chain(["Audio", "Settings"]) {
assert!(Language::Ko.has(en), "untranslated Korean menu: {en}");
}
for (en, ko) in [("File", "파일"), ("Edit", "편집"), ("Sequence", "시퀀스"), ("Settings", "설정")] {
assert_eq!(Language::Ko.tr(en), ko);
}
assert_eq!(Language::Ko.name(), "한국어");
assert_eq!(Language::parse("ko"), Some(Language::Ko));
for tag in ["ko", "ko-KR", "ko_KR.UTF-8"] {
assert_eq!(Language::from_locales(&[tag.to_string()]), Language::Ko, "{tag}");
}
}
#[test]
fn russian_entries_cover_the_original_menu_catalog() {
let app = crate::FilmcraftApp::new(filmcraft_engine::Session::default());
+1
View File
@@ -3434,6 +3434,7 @@
in entrada
The language changed but could not be saved: {e} O idioma mudou, mas não pôde ser salvo: {e}
no Chinese font is installed on this system; the interface stays in English nenhuma fonte chinesa está instalada neste sistema; a interface permanece em inglês
no Korean font is installed on this system; the interface stays in English nenhuma fonte coreana está instalada neste sistema; a interface permanece em inglês
no Japanese font is installed on this system; the interface stays in English nenhuma fonte japonesa está instalada neste sistema; a interface permanece em inglês
Show Mostrar
Sort Ordenar
Can't render this file because it contains an unexpected character in line 6 and column 4.
+1
View File
@@ -3420,6 +3420,7 @@
in 入
The language changed but could not be saved: {e} 语言已更改但无法保存:{e}
no Chinese font is installed on this system; the interface stays in English 此系统未安装中文字体;界面保持英文
no Korean font is installed on this system; the interface stays in English 此系统未安装韩文字体;界面保持英文
no Japanese font is installed on this system; the interface stays in English 此系统未安装日语字体;界面保持英文
Show 显示
Sort 排序
Can't render this file because it contains an unexpected character in line 3 and column 95.
+8 -2
View File
@@ -720,6 +720,9 @@ impl FilmcraftApp {
} else if language == i18n::Language::ZhCn && !i18n::chinese_font_available() {
self.ui.language = i18n::Language::En;
self.ui.status = tl!("no Chinese font is installed on this system; the interface stays in English").into();
} else if language == i18n::Language::Ko && !i18n::korean_font_available() {
self.ui.language = i18n::Language::En;
self.ui.status = tl!("no Korean font is installed on this system; the interface stays in English").into();
} else {
self.ui.language = language;
}
@@ -2042,9 +2045,12 @@ impl eframe::App for FilmcraftApp {
theme::install(ctx, &self.tokens);
// theme::install replaces the fonts: add the system Japanese font back (or fall back to
// English when a saved Japanese setting meets a system without one)
// (likewise Chinese without a Chinese face)
// (likewise Chinese without a Chinese face, Korean without a Hangul one)
let japanese_missing = self.ui.language == i18n::Language::Ja && !i18n::install_japanese_font(ctx);
if japanese_missing || (self.ui.language == i18n::Language::ZhCn && !i18n::chinese_font_available()) {
if japanese_missing
|| (self.ui.language == i18n::Language::ZhCn && !i18n::chinese_font_available())
|| (self.ui.language == i18n::Language::Ko && !i18n::korean_font_available())
{
self.ui.language = i18n::Language::En;
}
self.styled = true;
+8
View File
@@ -37,6 +37,7 @@ pub const UI_COMMANDS: &[UiCommand] = &[
uic!("app.language.french", "Français", ["Edit", "Language"], None),
uic!("app.language.indonesian", "Bahasa Indonesia", ["Edit", "Language"], None),
uic!("app.language.italian", "Italiano", ["Edit", "Language"], None),
uic!("app.language.korean", "한국어", ["Edit", "Language"], None),
uic!("source.playback.toggle", "Source Play/Stop", [], None),
uic!("source.playback.play", "Play Source", [], None),
uic!("source.playback.stop", "Stop Source", [], None),
@@ -197,6 +198,7 @@ pub fn invoke(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, mut params:
| "app.language.french"
| "app.language.indonesian"
| "app.language.italian"
| "app.language.korean"
) {
// Japanese needs the craft-fonts (built with CRAFT_FONTS_DIR) or a font installed on the system
if id == "app.language.japanese" && !crate::i18n::install_japanese_font(ctx) {
@@ -206,6 +208,10 @@ pub fn invoke(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, mut params:
if id == "app.language.chinese" && !crate::i18n::chinese_font_available() {
return Err("no Chinese font is installed on this system (for example Noto Sans CJK SC or Microsoft YaHei); the interface stays in English".into());
}
// Korean needs an installed Hangul font (none is bundled; see cjk.rs)
if id == "app.language.korean" && !crate::i18n::korean_font_available() {
return Err("no Korean font is installed on this system (for example Malgun Gothic or Noto Sans CJK KR); the interface stays in English".into());
}
let language = match id {
"app.language.japanese" => crate::i18n::Language::Ja,
"app.language.spanish" => crate::i18n::Language::Es,
@@ -217,6 +223,7 @@ pub fn invoke(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, mut params:
"app.language.french" => crate::i18n::Language::Fr,
"app.language.indonesian" => crate::i18n::Language::Id,
"app.language.italian" => crate::i18n::Language::It,
"app.language.korean" => crate::i18n::Language::Ko,
_ => crate::i18n::Language::En,
};
// The preference is updated in memory before it is written, so a failed write (read-only
@@ -689,6 +696,7 @@ pub fn menu_items(app: &FilmcraftApp) -> Vec<MenuItem> {
"app.language.french" => it.checked = Some(app.ui.language == crate::i18n::Language::Fr),
"app.language.indonesian" => it.checked = Some(app.ui.language == crate::i18n::Language::Id),
"app.language.italian" => it.checked = Some(app.ui.language == crate::i18n::Language::It),
"app.language.korean" => it.checked = Some(app.ui.language == crate::i18n::Language::Ko),
_ => {}
}
if it.id.starts_with("view.") {
+153 -37
View File
@@ -168,6 +168,7 @@ pub enum Drag {
start: Pos2,
before: Vec<ClipId>,
add: bool,
individual: bool,
},
Divider,
ZoomBar {
@@ -566,22 +567,47 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
// selected edit points (trim mode): red brackets for ripple, yellow for roll / regular trim
super::trim_monitor::paint_edit_points(app, &p, seq, tr, r, &layout);
// transitions (one being dragged where the drag puts it; the selected ones outlined)
// transitions (one being dragged where the drag puts it; the selected ones outlined). A
// clip drag also shows where the drop will carry its transitions (those whose clips all
// move to one track by one offset): a plain move draws them on their destination track
// right away, a copy adds a ghost beside the copies while the originals stay at home.
for trn in &tr.transitions {
let carried = move_transition_preview(app, seq, trn);
// the transition's home span (a transition dragged by an end still takes over)
let (start, end) = match &app.tl.drag {
Some(Drag::Transition { id, start, duration, .. }) if *id == trn.id => (*start, *start + *duration),
_ => (trn.start, trn.end()),
};
let x0 = layout.x_of(start);
let x1 = layout.x_of(end);
let tr_rect = Rect::from_min_max(pos2(x0, r.rect.min.y + 17.0), pos2(x1, r.rect.max.y - 1.0));
let selected = app.session.state.transition_selection.contains(&trn.id);
draw_transition(&p, tr_rect, trn, selected, &t);
app.auto.add(&format!("timeline.transition.{}", trn.id.0), tr_rect, &trn.effect.effect);
// its ends, for dragging the duration (the same zones as the hit test)
let zone = super::timeline_hit::EDGE_PX.min(tr_rect.width() / 3.0);
app.auto.add(&format!("timeline.transition.{}.in", trn.id.0), Rect::from_min_max(tr_rect.min, pos2(x0 + zone, tr_rect.max.y)), "start");
app.auto.add(&format!("timeline.transition.{}.out", trn.id.0), Rect::from_min_max(pos2(x1 - zone, tr_rect.min.y), tr_rect.max), "end");
// (start, end, is the copy's ghost)
let show: Vec<(Tick, Tick, bool)> = match (carried, copying) {
(Some((s, e, dest)), false) if dest == r.track => vec![(s, e, false)],
(Some((s, e, dest)), true) if dest == r.track => vec![(start, end, false), (s, e, true)],
(Some(_), false) => Vec::new(), // drawn on its destination row
_ => vec![(start, end, false)], // at home: not carried, or a copy's original
};
for (start, end, ghost) in show {
let x0 = layout.x_of(start);
let x1 = layout.x_of(end);
let tr_rect = Rect::from_min_max(pos2(x0, r.rect.min.y + 17.0), pos2(x1, r.rect.max.y - 1.0));
let selected = app.session.state.transition_selection.contains(&trn.id);
draw_transition(&p, tr_rect, trn, ghost || selected, &t);
let suffix = if ghost { ".copy" } else { "" };
app.auto.add(&format!("timeline.transition.{}{}", trn.id.0, suffix), tr_rect, &trn.effect.effect);
// its ends, for dragging the duration (the same zones as the hit test)
let zone = super::timeline_hit::EDGE_PX.min(tr_rect.width() / 3.0);
if !ghost {
app.auto.add(&format!("timeline.transition.{}.in", trn.id.0), Rect::from_min_max(tr_rect.min, pos2(x0 + zone, tr_rect.max.y)), "start");
app.auto.add(&format!("timeline.transition.{}.out", trn.id.0), Rect::from_min_max(pos2(x1 - zone, tr_rect.min.y), tr_rect.max), "end");
}
}
}
if tr.locked {
let color = Color32::from_rgba_unmultiplied(t.text.r(), t.text.g(), t.text.b(), 64);
let mut x = content.min.x - row.height();
while x < content.max.x {
p.line_segment([pos2(x, row.max.y), pos2(x + row.height(), row.min.y)], Stroke::new(1.0, color));
x += 8.0;
}
}
}
@@ -1858,6 +1884,38 @@ fn preview_drag(app: &FilmcraftApp, seq: &Sequence, out: &mut HashMap<ClipId, (T
}
}
/// The transition a clip drag will leave behind, by the same rule `edit::move_items` applies:
/// the transition travels when every clip it sits on moves — to one track, by the same (clamped)
/// offset — and is dropped when only some of its clips move (a partner that stays behind ends the
/// move, as in Premiere), shifted by that offset. The preview draws carried ones where they land,
/// so a dragged clip's transition follows it instead of lagging behind until the drop, and a cut
/// transition does not ride along on a clip whose partner stays. Returns the transition's
/// `(start, end, destination track)` when the drag carries it.
fn move_transition_preview(app: &FilmcraftApp, seq: &Sequence, trn: &filmcraft_project::Transition) -> Option<(Tick, Tick, TrackId)> {
let Some(Drag::Move { clips, offset, track_delta, .. }) = &app.tl.drag else { return None };
let endpoints: Vec<ClipId> = [trn.from, trn.to].into_iter().flatten().collect();
// where each clip it sits on that moves lands (the same track shift as the clip preview), and
// by how much: clamped like the drop clamps each clip's start
let lands: Vec<(TrackId, Tick)> = endpoints
.iter()
.filter_map(|c| {
if !clips.contains(c) {
return None;
}
let (tid, it) = seq.find_item(*c)?;
let dest = shift_track(seq, tid, *track_delta)?;
Some((dest, (it.start + *offset).max(Tick::ZERO) - it.start))
})
.collect();
// every clip it sits on must move to one track by one offset; one staying behind keeps the
// transition where it is (and the drop does not carry it either)
if lands.len() != endpoints.len() {
return None;
}
let &(dest, delta) = lands.first()?;
lands.iter().skip(1).all(|l| *l == (dest, delta)).then(|| (trn.start + delta, trn.end() + delta, dest))
}
/// The clips a marquee rectangle touches, on the tracks it crosses.
fn marquee_clips(seq: &Sequence, layout: &Layout, r: Rect) -> Vec<ClipId> {
let (a, b) = (layout.tick_at(r.min.x), layout.tick_at(r.max.x));
@@ -1870,6 +1928,26 @@ fn marquee_clips(seq: &Sequence, layout: &Layout, r: Rect) -> Vec<ClipId> {
.collect()
}
/// A move previews the same track-lock restrictions as its eventual edit.
fn drag_is_locked(session: &filmcraft_engine::Session, seq: &Sequence, drag: &Drag) -> bool {
let clips = match drag {
Drag::Move { clips, track_delta, .. } => return move_is_locked(seq, clips, *track_delta),
Drag::Trim { clip, .. } | Drag::Stretch { clip, .. } | Drag::Remix { clip, .. } | Drag::Slip { clip, .. } | Drag::Slide { clip, .. } => vec![*clip],
Drag::Roll { left, right, .. } => vec![*left, *right],
Drag::Transition { id, .. } => return seq.all_tracks().any(|t| t.locked && t.transitions.iter().any(|tr| tr.id == *id)),
_ => return false,
};
move_is_locked(seq, &filmcraft_engine::commands::with_links(session, &clips), 0)
}
fn move_is_locked(seq: &Sequence, clips: &[ClipId], delta: i32) -> bool {
clips.iter().any(|clip| {
seq.find_item(*clip).is_some_and(|(track, _)| {
seq.track(track).is_some_and(|t| t.locked) || shift_track(seq, track, delta).and_then(|id| seq.track(id)).is_some_and(|t| t.locked)
})
})
}
fn shift_track(seq: &Sequence, tid: TrackId, delta: i32) -> Option<TrackId> {
if delta == 0 {
return Some(tid);
@@ -2210,17 +2288,35 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: &
&& area.contains(p)
{
let g = grab_at(seq, layout, p, tool, mods, roll_ripple);
let cur = match (tool, g) {
(_, Grab::Edge { .. }) => CursorIcon::ResizeColumn,
(Tool::Selection, Grab::Other(Hit::Transition { edge: Some(_), .. })) => CursorIcon::ResizeColumn,
(Tool::Selection, Grab::Other(Hit::Transition { edge: None, .. })) => CursorIcon::Grab,
(_, Grab::Volume { .. }) if tool == Tool::Pen || mods.command => CursorIcon::Crosshair,
(_, Grab::Volume { .. }) => CursorIcon::ResizeVertical,
(Tool::Razor, Grab::Other(Hit::Clip { .. })) => CursorIcon::Crosshair,
(Tool::Slip | Tool::Slide, Grab::Other(Hit::Clip { .. })) => CursorIcon::ResizeHorizontal,
(Tool::Hand, _) => CursorIcon::Grab,
(Tool::Zoom, _) => CursorIcon::ZoomIn,
_ => CursorIcon::Default,
let blocked = tool == Tool::Selection
&& match g.hit() {
Hit::Clip { clip, .. } => {
let clips = if app.session.state.selection.contains(&clip) {
app.session.state.selection.clone()
} else if mods.alt {
vec![clip]
} else {
filmcraft_engine::commands::with_links(&app.session, &[clip])
};
move_is_locked(seq, &clips, 0)
}
_ => false,
};
let cur = if blocked {
CursorIcon::NotAllowed
} else {
match (tool, g) {
(_, Grab::Edge { .. }) => CursorIcon::ResizeColumn,
(Tool::Selection, Grab::Other(Hit::Transition { edge: Some(_), .. })) => CursorIcon::ResizeColumn,
(Tool::Selection, Grab::Other(Hit::Transition { edge: None, .. })) => CursorIcon::Grab,
(_, Grab::Volume { .. }) if tool == Tool::Pen || mods.command => CursorIcon::Crosshair,
(_, Grab::Volume { .. }) => CursorIcon::ResizeVertical,
(Tool::Razor, Grab::Other(Hit::Clip { .. })) => CursorIcon::Crosshair,
(Tool::Slip | Tool::Slide, Grab::Other(Hit::Clip { .. })) => CursorIcon::ResizeHorizontal,
(Tool::Hand, _) => CursorIcon::Grab,
(Tool::Zoom, _) => CursorIcon::ZoomIn,
_ => CursorIcon::Default,
}
};
ctx.set_cursor_icon(cur);
// the edge a press here would grab, and the trim it starts (#259)
@@ -2389,7 +2485,7 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: &
// the video and audio tracks, #683)
(_, Grab::Other(hit @ (Hit::Empty { .. } | Hit::None))) if layout.content.contains(p) => {
if resp.drag_started() {
Some(Drag::Marquee { start: p, before: app.session.state.selection.clone(), add: mods.shift || mods.command })
Some(Drag::Marquee { start: p, before: app.session.state.selection.clone(), add: mods.shift || mods.command, individual: mods.alt })
} else {
app.session.state.selection.clear();
app.session.state.edit_points.clear();
@@ -2407,6 +2503,13 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: &
}
(_, Grab::Other(Hit::Empty { .. } | Hit::None)) => None,
};
let started = started.filter(|drag| {
let blocked = drag_is_locked(&app.session, seq, drag);
if blocked {
ctx.set_cursor_icon(CursorIcon::NotAllowed);
}
!blocked
});
if let Some(d) = started {
if matches!(d, Drag::Scrub { .. }) {
scrub_playhead(app, seq, layout, p, mods.shift);
@@ -2485,14 +2588,19 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: &
(Some(c), Some(s)) if c.kind == s.kind => c.index as i32 - s.index as i32,
_ => 0,
};
// A linked or multitrack selection gets one destination per source track.
let new_track = start_row
.filter(|_| clips.iter().all(|c| seq.find_item(*c).is_some_and(|(tid, _)| seq.track(tid).is_some_and(|t| !t.locked))))
.and_then(|s| {
let (vh, ah) = (app.ui.timeline.video_track_h, app.ui.timeline.audio_track_h);
new_track_at(seq, layout, p.y, vh, ah).filter(|(kind, _)| *kind == s.kind)
});
Some(Drag::Move { clips, grab_tick, start_track, offset, track_delta, new_track, copy: mods.alt })
if move_is_locked(seq, &clips, track_delta) {
ctx.set_cursor_icon(CursorIcon::NotAllowed);
Some(Drag::Move { clips, grab_tick, start_track, offset: Tick::ZERO, track_delta: 0, new_track: None, copy: mods.alt })
} else {
// A linked or multitrack selection gets one destination per source track.
let new_track = start_row
.filter(|_| clips.iter().all(|c| seq.find_item(*c).is_some_and(|(tid, _)| seq.track(tid).is_some_and(|t| !t.locked))))
.and_then(|s| {
let (vh, ah) = (app.ui.timeline.video_track_h, app.ui.timeline.audio_track_h);
new_track_at(seq, layout, p.y, vh, ah).filter(|(kind, _)| *kind == s.kind)
});
Some(Drag::Move { clips, grab_tick, start_track, offset, track_delta, new_track, copy: mods.alt })
}
}
Drag::Trim { clip, edge, mode, from, .. } => seq.find_item(clip).map(|(_, it)| {
let base = if edge == filmcraft_edit::Edge::In { it.start } else { it.end() };
@@ -2532,24 +2640,32 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: &
Some(Drag::Transition { id, edge, from, start, duration })
}
Drag::Volume(v) => Some(Drag::Volume(super::timeline_volume::drag(app, ui, seq, layout, v, p.y))),
Drag::Marquee { start, before, add } => {
Drag::Marquee { start, before, add, individual } => {
let r = Rect::from_two_pos(start, p);
ui.painter().rect_filled(r, 0.0, Color32::from_white_alpha(18));
ui.painter().rect_stroke(r, 0.0, Stroke::new(1.0, Color32::from_white_alpha(140)), StrokeKind::Inside);
// the clips in the box are selected as it grows and shrinks; only the release runs
// the command, so the drag is one selection change
let mut sel = if add { before.clone() } else { Vec::new() };
for c in filmcraft_engine::commands::with_links(&app.session, &marquee_clips(seq, layout, r)) {
let clips = marquee_clips(seq, layout, r);
let clips = if individual { clips } else { filmcraft_engine::commands::with_links(&app.session, &clips) };
for c in clips {
if !sel.contains(&c) {
sel.push(c);
}
}
app.session.state.selection = sel;
Some(Drag::Marquee { start, before, add })
Some(Drag::Marquee { start, before, add, individual })
}
other => Some(other),
};
app.tl.drag = new;
app.tl.drag = new.filter(|drag| {
let blocked = drag_is_locked(&app.session, seq, drag);
if blocked {
ctx.set_cursor_icon(CursorIcon::NotAllowed);
}
!blocked
});
// auto-scroll when dragging near the edges
if !matches!(app.tl.drag, Some(Drag::Pan { .. }) | Some(Drag::ZoomBar { .. }) | Some(Drag::Volume(_))) {
let v = &mut app.ui.timeline;
@@ -2595,13 +2711,13 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: &
let moved = seq.all_tracks().find_map(|tr| tr.transitions.iter().find(|x| x.id == id)).is_some_and(|x| (x.start, x.duration) != (start, duration));
moved.then(|| app.session.execute("sequence.setTransition", json!({"transition": id.0, "start": start.0, "duration": duration.0})))
}
Drag::Marquee { start, before, add } => {
Drag::Marquee { start, before, add, individual } => {
app.session.state.selection = before;
// egui ends a drag on Escape: that cancels the box, as in the Project panel
match resp.interact_pointer_pos() {
Some(end) if !ctx.input(|i| i.key_pressed(egui::Key::Escape)) => {
let ids: Vec<u64> = marquee_clips(seq, layout, Rect::from_two_pos(start, end)).iter().map(|c| c.0).collect();
Some(app.session.execute("timeline.select", json!({"clips": ids, "add": add})))
Some(app.session.execute("timeline.select", json!({"clips": ids, "add": add, "linked": !individual})))
}
_ => None,
}
+6 -4
View File
@@ -781,16 +781,18 @@ fn media_browser_drag_places_every_selected_file() {
d.exec("mediaBrowser.select", json!({"paths": [a, b]}));
d.frames(2);
let n0 = d.app().session.project.items.len();
let clips = |d: &mut Driver| {
d.app().session.active_sequence().map(|q| q.video_tracks.iter().chain(&q.audio_tracks).map(|t| t.items.len()).sum::<usize>()).unwrap_or(0)
// clips named after each dragged file (a drop inside a long clip also splits that one)
let placed = |d: &mut Driver, name: &str| {
d.app().session.active_sequence().map_or(0, |q| q.video_tracks.iter().chain(&q.audio_tracks).flat_map(|t| &t.items).filter(|i| i.name == name).count())
};
let c0 = clips(&mut d);
assert_eq!((placed(&mut d, "a-interview.wav"), placed(&mut d, "b-roll.wav")), (0, 0));
let e = d.rect("mediaBrowser.entry.b-roll.wav");
let tl = d.rect("panel.Timeline");
d.ok("ui.drag", json!({"from": {"x": e[0] + 40.0, "y": e[1] + e[3] / 2.0}, "to": {"x": tl[0] + tl[2] * 0.6, "y": tl[1] + tl[3] * 0.75}, "steps": 20}));
d.frames(6);
assert_eq!(d.app().session.project.items.len(), n0 + 1, "only the new file is imported");
assert_eq!(clips(&mut d), c0 + 2, "both files are placed; status: {}", d.app().ui.status);
let status = d.app().ui.status.clone();
assert_eq!((placed(&mut d, "a-interview.wav"), placed(&mut d, "b-roll.wav")), (1, 1), "both files are placed; status: {status}");
let _ = std::fs::remove_dir_all(&dir);
}
@@ -121,6 +121,33 @@ fn the_selection_follows_the_box_while_it_is_dragged() {
assert!(d.selected(b) && !d.selected(a), "the release keeps what the box showed");
}
#[test]
fn alt_box_selects_only_video_and_delete_keeps_linked_audio() {
let mut d = Driver::demo();
let (_, video, _, rv, start) = d.setup();
let audio = {
let seq = d.harness.state().session.active_sequence().unwrap();
let link = seq.find_item(ClipId(video)).unwrap().1.link;
seq.audio_tracks.iter().flat_map(|tr| &tr.items).find(|clip| clip.link == link).unwrap().id.0
};
d.exec("timeline.select", json!({"clips": []}));
d.modifiers = egui::Modifiers::ALT;
d.send(egui::Event::PointerMoved(start));
d.button(start, true);
let over = egui::pos2(rv.center().x, start.y + 2.0);
d.move_to(start, over);
assert!(d.selected(video));
assert!(!d.selected(audio), "Alt excludes the linked audio during the drag");
d.button(over, false);
d.modifiers = egui::Modifiers::NONE;
d.frames(2);
assert!(d.selected(video) && !d.selected(audio), "release preserves the individual selection");
d.exec("edit.clear", json!({}));
let seq = d.harness.state().session.active_sequence().unwrap();
assert!(seq.find_item(ClipId(video)).is_none());
assert!(seq.find_item(ClipId(audio)).is_some(), "Delete leaves the unselected linked audio");
}
#[test]
fn shift_adds_to_the_selection_the_drag_began_with() {
let mut d = Driver::demo();
@@ -0,0 +1,238 @@
//! Dragging clips previews the transitions the drop will carry, exactly as the drop re-adds them
//! (`edit::move_items`): a fade follows its clip, a cut transition travels with its clips only
//! when every one of them moves — dragging a clip out from under one side of a cut leaves the
//! transition where it was until the release deletes it (Premiere) — an Option-drag adds a
//! traveling copy while the original stays.
use egui::{Event, Modifiers, PointerButton, Pos2, Rect, pos2, vec2};
use egui_kittest::Harness;
use filmcraft_engine::Session;
use filmcraft_ui_egui::FilmcraftApp;
use serde_json::json;
fn step(h: &mut Harness<'static, FilmcraftApp>) {
let ctx = h.ctx.clone();
let mut raw = std::mem::take(h.input_mut());
eframe::App::raw_input_hook(h.state_mut(), &ctx, &mut raw);
*h.input_mut() = raw;
ctx.request_repaint();
h.step();
}
fn pointer(h: &mut Harness<'static, FilmcraftApp>, pos: Pos2, pressed: Option<bool>) {
h.input_mut().events.push(Event::PointerMoved(pos));
if let Some(pressed) = pressed {
h.input_mut().events.push(Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE });
}
step(h);
}
fn rect(h: &Harness<'static, FilmcraftApp>, id: &str) -> Rect {
let [x, y, w, height] = h.state().auto.elements.iter().find(|e| e.id == id).unwrap_or_else(|| panic!("missing {id}")).rect;
Rect::from_min_size(pos2(x, y), vec2(w, height))
}
fn build(s: Session) -> Harness<'static, FilmcraftApp> {
let app = FilmcraftApp::new(s);
let mut builder = Harness::builder().with_size(vec2(1600.0, 980.0)).with_max_steps(10_000);
if std::env::var_os("FILMCRAFT_UI_SNAPSHOT_DIR").is_some() {
builder = builder.wgpu();
}
let mut h = builder.build_eframe(move |_cc| app);
for _ in 0..4 {
step(&mut h);
}
// Opening the new sequence restores its own view; set the test zoom after that initial fit.
h.state_mut().ui.timeline.pps = 60.0;
h.state_mut().ui.timeline.target_pps = 60.0;
h.state_mut().ui.timeline.fit_pending = false;
step(&mut h);
h
}
/// A sequence with a cross dissolve: between two clips at 0..48f and 48..96f, or a fade-in on the
/// single clip at 0..48f when `single`.
fn harness(single: bool) -> Harness<'static, FilmcraftApp> {
let mut s = Session::default();
s.execute("file.openDemoProject", json!({})).unwrap();
s.execute("file.newSequence", json!({"name":"Transition move","fps":24})).unwrap();
let rate = s.sequence_rate();
for (at, frames) in if single { vec![(0, 48)] } else { vec![(0, 48), (48, 48)] } {
s.execute("timeline.place", json!({"item":5,"time":rate.tick_of(at).0,"duration":rate.tick_of(frames).0})).unwrap();
}
let seq = s.active_sequence().unwrap().clone();
let clip = seq.video_tracks[0].items[if single { 0 } else { 1 }].id;
s.execute("effects.apply", json!({"effect": "cross_dissolve", "clip": clip.0, "edge": "in"})).unwrap();
build(s)
}
/// XYZ with a cut transition on each cut: X↔Y and Y↔Z.
fn harness_xyz() -> Harness<'static, FilmcraftApp> {
let mut s = Session::default();
s.execute("file.openDemoProject", json!({})).unwrap();
s.execute("file.newSequence", json!({"name":"Transition move","fps":24})).unwrap();
let rate = s.sequence_rate();
for (at, frames) in [(0, 48), (48, 48), (96, 48)] {
s.execute("timeline.place", json!({"item":5,"time":rate.tick_of(at).0,"duration":rate.tick_of(frames).0})).unwrap();
}
let seq = s.active_sequence().unwrap().clone();
let (y, z) = (seq.video_tracks[0].items[1].id, seq.video_tracks[0].items[2].id);
for clip in [y, z] {
s.execute("effects.apply", json!({"effect": "cross_dissolve", "clip": clip.0, "edge": "in"})).unwrap();
}
build(s)
}
/// A drag preview that keeps a transition's offset to its clip (it follows, not lags), and a drop
/// that re-adds the transition shifted by exactly the move.
#[test]
fn dragging_a_clip_carries_its_fade_in_the_preview_and_on_the_drop() {
let mut h = harness(true);
let q0 = h.state().session.active_sequence().unwrap().clone();
let (a0, tr0) = (q0.video_tracks[0].items[0].clone(), q0.video_tracks[0].transitions[0].clone());
let a_rect = rect(&h, &format!("timeline.clip.{}", a0.id.0));
let tr_rect = rect(&h, &format!("timeline.transition.{}", tr0.id.0));
assert_eq!(tr0.to, Some(a0.id), "the fade-in sits on the dragged clip");
let gap_before = tr_rect.min.x - a_rect.min.x;
let y = a_rect.center().y;
// press clear of the fade band (it covers the left 75 px of the clip) and of the edge zones
let x0 = a_rect.left() + 110.0;
pointer(&mut h, pos2(x0, y), Some(true));
for i in 1..=6 {
pointer(&mut h, pos2(x0 + 20.0 * i as f32, y), None);
}
// mid-drag: the fade follows the clip's ghost, keeping its offset to the clip edge
let gap_mid = rect(&h, &format!("timeline.transition.{}", tr0.id.0)).min.x - rect(&h, &format!("timeline.clip.{}", a0.id.0)).min.x;
assert!((gap_mid - gap_before).abs() < 1.0, "fade lagged behind: gap {gap_before} -> {gap_mid}");
pointer(&mut h, pos2(x0 + 140.0, y), Some(false));
let q1 = h.state().session.active_sequence().unwrap().clone();
let (a1, tr1) = (q1.video_tracks[0].items[0].clone(), q1.video_tracks[0].transitions[0].clone());
assert_eq!(a1.start.0 - a0.start.0, tr1.start.0 - tr0.start.0, "the committed transition moved with the clip");
assert_eq!((tr1.from, tr1.duration), (tr0.from, tr0.duration));
}
/// Both clips of a cut transition move together: the transition travels with them in the preview
/// and is re-added at the shared delta.
#[test]
fn dragging_both_clips_of_a_cut_carries_the_transition() {
let mut h = harness(false);
let q0 = h.state().session.active_sequence().unwrap().clone();
let (a0, b0, tr0) = (q0.video_tracks[0].items[0].clone(), q0.video_tracks[0].items[1].clone(), q0.video_tracks[0].transitions[0].clone());
h.state_mut().session.execute("timeline.select", json!({"clips": [a0.id.0, b0.id.0]})).unwrap();
step(&mut h);
let a_rect = rect(&h, &format!("timeline.clip.{}", a0.id.0));
let tr_rect = rect(&h, &format!("timeline.transition.{}", tr0.id.0));
let gap_before = tr_rect.min.x - a_rect.min.x;
let y = a_rect.center().y;
pointer(&mut h, a_rect.center(), Some(true));
for i in 1..=6 {
pointer(&mut h, pos2(a_rect.center().x + 20.0 * i as f32, y), None);
}
let gap_mid = rect(&h, &format!("timeline.transition.{}", tr0.id.0)).min.x - rect(&h, &format!("timeline.clip.{}", a0.id.0)).min.x;
assert!((gap_mid - gap_before).abs() < 1.0, "transition lagged: gap {gap_before} -> {gap_mid}");
pointer(&mut h, pos2(a_rect.center().x + 140.0, y), Some(false));
let q1 = h.state().session.active_sequence().unwrap().clone();
let (a1, b1, tr1) = (q1.video_tracks[0].items[0].clone(), q1.video_tracks[0].items[1].clone(), q1.video_tracks[0].transitions[0].clone());
assert_eq!(a1.start.0 - a0.start.0, tr1.start.0 - tr0.start.0);
assert_eq!(b1.start.0 - b0.start.0, tr1.start.0 - tr0.start.0, "the transition moved with both clips");
}
/// XYZ with cut transitions on both cuts: dragging YZ leaves the X↔Y transition where it is
/// while dragging (a preview of the drop that deletes it, as in Premiere) and the Y↔Z one
/// travels with the pair.
#[test]
fn dragging_two_clips_drops_the_transition_on_the_cut_that_stayed() {
let mut h = harness_xyz();
let q0 = h.state().session.active_sequence().unwrap().clone();
let (x0, y0, z0) = (q0.video_tracks[0].items[0].clone(), q0.video_tracks[0].items[1].clone(), q0.video_tracks[0].items[2].clone());
let (xy0, yz0) = (q0.video_tracks[0].transitions[0].clone(), q0.video_tracks[0].transitions[1].clone());
h.state_mut().session.execute("timeline.select", json!({"clips": [y0.id.0, z0.id.0]})).unwrap();
step(&mut h);
let y_rect = rect(&h, &format!("timeline.clip.{}", y0.id.0));
let xy_rect = rect(&h, &format!("timeline.transition.{}", xy0.id.0));
let yz_rect = rect(&h, &format!("timeline.transition.{}", yz0.id.0));
let gap_before = yz_rect.min.x - y_rect.min.x;
let y_center = y_rect.center().y;
// press in the middle of Y, clear of both transition bands and the edge zones
let x0p = y_rect.left() + 60.0;
pointer(&mut h, pos2(x0p, y_center), Some(true));
for i in 1..=6 {
pointer(&mut h, pos2(x0p + 20.0 * i as f32, y_center), None);
}
// mid-drag: the X↔Y transition stays on its cut (the drop will remove it), the Y↔Z one
// follows the pair
assert_eq!(rect(&h, &format!("timeline.transition.{}", xy0.id.0)), xy_rect, "the X↔Y transition stays on its cut while dragging");
let gap_mid = rect(&h, &format!("timeline.transition.{}", yz0.id.0)).min.x - rect(&h, &format!("timeline.clip.{}", y0.id.0)).min.x;
assert!((gap_mid - gap_before).abs() < 1.0, "Y↔Z transition lagged: gap {gap_before} -> {gap_mid}");
pointer(&mut h, pos2(x0p + 140.0, y_center), Some(false));
let q1 = h.state().session.active_sequence().unwrap().clone();
let (x1, y1, z1) = (q1.video_tracks[0].items[0].clone(), q1.video_tracks[0].items[1].clone(), q1.video_tracks[0].items[2].clone());
assert_eq!(x1.start, x0.start, "X is not part of the drag");
assert_eq!(y1.start.0 - y0.start.0, z1.start.0 - z0.start.0, "Y and Z moved together");
assert_eq!(q1.video_tracks[0].transitions.len(), 1, "the X↔Y transition was dropped with the move");
assert!(!q1.video_tracks[0].transitions.iter().any(|t| t.id == xy0.id), "its id is gone");
let yz1 = &q1.video_tracks[0].transitions[0];
assert_eq!(yz1.start.0 - yz0.start.0, y1.start.0 - y0.start.0, "the Y↔Z transition traveled with the pair");
}
/// Option-drag copies the clip and its fade: the original keeps its transition at home, the copy
/// shows a ghost beside it while dragging and keeps its own transition on the drop.
#[test]
fn option_drag_copies_show_a_ghost_transition_with_the_copy() {
let mut h = harness(true);
let q0 = h.state().session.active_sequence().unwrap().clone();
let a0 = q0.video_tracks[0].items[0].clone();
let tr0 = q0.video_tracks[0].transitions[0].clone();
let a_rect = rect(&h, &format!("timeline.clip.{}", a0.id.0));
let tr_rect = rect(&h, &format!("timeline.transition.{}", tr0.id.0));
let y = a_rect.center().y;
// hold Option for the whole drag, as the real drag does
h.input_mut().events.push(egui::Event::ModifiersChanged(Modifiers { alt: true, ..Default::default() }));
let x0 = a_rect.left() + 110.0; // clear of the fade band and the edge zones
pointer(&mut h, pos2(x0, y), Some(true));
for i in 1..=6 {
pointer(&mut h, pos2(x0 + 20.0 * i as f32, y), None);
}
// a ghost transition beside the copy; the original stays exactly where it was
let ghost = rect(&h, &format!("timeline.transition.{}.copy", tr0.id.0));
assert_eq!(rect(&h, &format!("timeline.transition.{}", tr0.id.0)), tr_rect, "the original transition stays at home");
assert!((ghost.min.x - tr_rect.min.x) > 60.0, "ghost {ghost:?} did not follow the copy from {tr_rect:?}");
pointer(&mut h, pos2(x0 + 140.0, y), Some(false));
h.input_mut().events.push(egui::Event::ModifiersChanged(Modifiers::NONE));
step(&mut h);
let q1 = h.state().session.active_sequence().unwrap().clone();
assert_eq!(q1.video_tracks[0].transitions.len(), 2, "the copy carried its own transition");
let (home, copy_tr) = (q1.video_tracks[0].transitions[0].clone(), q1.video_tracks[0].transitions[1].clone());
assert_eq!(home.start, tr0.start, "the original's transition stayed");
assert_ne!(copy_tr.id, tr0.id, "the copy's transition is a fresh one");
let copy = q1.video_tracks[0].items.iter().find(|i| i.id != a0.id).expect("a new clip appeared");
assert_eq!(copy_tr.to, Some(copy.id), "the copy's fade sits on the copy");
}
/// Dragging one side of a cut transition to another track drops it (Premiere); the preview leaves
/// it where it is, and the drop removes it.
#[test]
fn dragging_one_side_to_another_track_drops_the_transition() {
let mut h = harness(false);
// a clip far to the right on V2 marks that track's row
let q = h.state().session.active_sequence().unwrap().clone();
let (v2, rate) = (q.video_tracks[1].id, h.state().session.sequence_rate());
h.state_mut().session.execute("timeline.place", json!({"item":5,"time":rate.tick_of(200).0,"duration":rate.tick_of(24).0,"track": v2.0})).unwrap();
step(&mut h);
let q0 = h.state().session.active_sequence().unwrap().clone();
let (a0, tr0) = (q0.video_tracks[0].items[0].clone(), q0.video_tracks[0].transitions[0].clone());
let a_rect = rect(&h, &format!("timeline.clip.{}", a0.id.0));
let v2_rect = rect(&h, &format!("timeline.clip.{}", q0.video_tracks[1].items[0].id.0));
let tr_rect = rect(&h, &format!("timeline.transition.{}", tr0.id.0));
pointer(&mut h, a_rect.center(), Some(true));
for i in 1..=6 {
let p = pos2(a_rect.center().x + 20.0 * i as f32, a_rect.center().y + (v2_rect.center().y - a_rect.center().y) * i as f32 / 6.0);
// move into the V2 row first (track crossing), so the pointer ends up there
pointer(&mut h, p, None);
}
assert_eq!(rect(&h, &format!("timeline.transition.{}", tr0.id.0)), tr_rect, "the doomed transition stays at home while dragging");
pointer(&mut h, pos2(a_rect.center().x + 140.0, v2_rect.center().y), Some(false));
let q1 = h.state().session.active_sequence().unwrap().clone();
assert!(q1.video_tracks[0].transitions.is_empty(), "the clip moved to V2 and its transition was dropped");
assert!(q1.video_tracks.iter().any(|t| t.items.iter().any(|i| i.id == a0.id && t.id == v2)), "the clip landed on V2");
}
+82
View File
@@ -391,6 +391,88 @@ fn dragging_in_the_trim_monitor_trims_without_hanging() {
assert!((6..=14).contains(&-frames), "about 11 frames expected for 60 px at 6 px/frame, moved {}", frames.abs());
}
fn locked_move_keeps_the_original_preview(audio: bool, destination: bool, linked_partner: bool, edge: Option<&str>) {
let mut d = Driver::demo();
d.exec("sequence.linkedSelection", json!({"on": linked_partner}));
let seq = d.app().session.active_sequence().unwrap();
let tracks = if audio { &seq.audio_tracks } else { &seq.video_tracks };
let clip = tracks[0].items[0].id;
let locked = if linked_partner { seq.audio_tracks[0].id } else { tracks[usize::from(destination)].id };
d.exec("timeline.setTrack", json!({"track": locked.0, "locked": true}));
d.frames(3);
let prefix = format!("timeline.clip.{}", clip.0);
let original = d.ok("ui.elements", json!({"prefix": prefix}))[0]["rect"].clone();
let r = original.as_array().unwrap();
let from = pos2(r[0].as_f64().unwrap() as f32 + r[2].as_f64().unwrap() as f32 * 0.5, r[1].as_f64().unwrap() as f32 + 8.0);
let from = if let Some(edge) = edge {
let point = d.ok("ui.timeline.locate", json!({"clip": clip.0, "edge": edge}));
pos2(point["x"].as_f64().unwrap() as f32, from.y)
} else {
from
};
let mut to = from + egui::vec2(60.0, 0.0);
if destination {
let label = if audio { "A2" } else { "V2" };
let rows = d.ok("ui.elements", json!({"prefix": format!("timeline.track.{label}.target")}));
let y = &rows[0]["rect"];
to.y = (y[1].as_f64().unwrap() + y[3].as_f64().unwrap() * 0.5) as f32;
}
let before = d.app().session.project.clone();
let undo = d.app().session.history.undo.len();
d.harness.input_mut().events.push(egui::Event::PointerMoved(from));
d.frames(1);
d.harness.input_mut().events.push(egui::Event::PointerButton {
pos: from,
button: egui::PointerButton::Primary,
pressed: true,
modifiers: Default::default(),
});
d.frames(1);
for i in 1..=8 {
d.harness.input_mut().events.push(egui::Event::PointerMoved(from + (to - from) * (i as f32 / 8.0)));
d.frames(1);
}
d.frames(2);
let elements = d.ok("ui.elements", json!({"prefix": prefix}));
assert_eq!(elements[0]["rect"], original, "a prohibited move never previews a displaced clip");
assert_eq!(d.cursor(), CursorIcon::NotAllowed);
d.harness.input_mut().events.push(egui::Event::PointerButton {
pos: to,
button: egui::PointerButton::Primary,
pressed: false,
modifiers: Default::default(),
});
d.frames(2);
assert_eq!(serde_json::to_value(&*d.app().session.project).unwrap(), serde_json::to_value(&*before).unwrap());
assert_eq!(d.app().session.history.undo.len(), undo);
}
#[test]
fn locked_sources_do_not_preview_a_move() {
for audio in [false, true] {
locked_move_keeps_the_original_preview(audio, false, false, None);
}
}
#[test]
fn locked_destinations_do_not_preview_a_move() {
for audio in [false, true] {
locked_move_keeps_the_original_preview(audio, true, false, None);
}
}
#[test]
fn a_locked_linked_partner_prevents_the_move_preview() {
locked_move_keeps_the_original_preview(false, false, true, None);
}
#[test]
fn locked_edges_do_not_preview_a_trim() {
for audio in [false, true] {
for edge in ["in", "out"] {
locked_move_keeps_the_original_preview(audio, false, false, Some(edge));
}
}
}
fn move_existing_clip_to_a_new_track(audio: bool, linked: bool) {
let mut d = Driver::demo();
d.exec("sequence.linkedSelection", json!({"on": linked}));
+2 -2
View File
@@ -1,6 +1,6 @@
# File format parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (new file: every file type Premiere Pro 26.5.2 reads or writes, against ours) · **Target:** Adobe Premiere Pro 2026 (26.5.2) and Media Encoder 2026
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** major (new file: every file type Premiere Pro 26.5.2 reads or writes, against ours) · **Target:** Adobe Premiere Pro 2026 (26.5.2) and Media Encoder 2026
Containers, stills, audio files, project and interchange files, captions and presets. Codecs inside
the containers are in [codec-parity.md](codec-parity.md). Our import list is
@@ -55,7 +55,7 @@ only). "Tested" names the evidence.
| PNG, JPEG, TIFF, BMP, GIF | read + write | yes | PNG / TIFF / BMP / JPEG sequences, GIF | 90% |
| WebP | — | yes | no | ahead |
| **PSD** (layers as sequence) | read (layered) | no | — | 0% |
| **OpenEXR** | read + write | stills and sequences: scan-line, none / RLE / ZIPS / ZIP, half / float / uint, RGB(A) or Y, kept as linear float (#828); no PIZ, PXR24, B44, DWA, tiled, multi-part or deep | sequences (half float, linear RGB / premultiplied RGBA; uncompressed, RLE, ZIPS or ZIP; #806, #874) | 70% |
| **OpenEXR** | read + write | stills and sequences: scan-line, none / RLE / ZIPS / ZIP / PXR24, half / float / uint, RGB(A) or Y, kept as linear float (#828); no PIZ, B44, DWA, tiled, multi-part or deep | sequences (half float, linear RGB / premultiplied RGBA; uncompressed, RLE, ZIPS or ZIP; #806, #874) | 70% |
| **DPX** | read + write | yes: luma / RGB / RGBA, 8–16-bit, filled 10/12-bit, either byte order (#821); packed 10/12-bit, Y'CbCr 4:2:2 / 4:4:4 with or without alpha (#841); no RLE | sequences (10-bit RGB, BT.709; #779) | 90% |
| Targa | read + write | yes: every image type, RLE, colour maps, 15–32-bit (#821) | sequences (24-bit, 32-bit with alpha; #779) | 95% |
| HEIF / HEIC | read | no | — | 0% |
+4 -2
View File
@@ -185,14 +185,15 @@ platforms** G4, G9, G17; **performance** G16; **ecosystem** G11; **measurement**
384 kbit/s). Radiance HDR (`.hdr`) stills and image sequences (#1407): RGBE as linear float, flat
and run-length scan lines, any axis order. OpenEXR stills and image
sequences (#828): scan-line, uncompressed / RLE / ZIPS / ZIP, half / float / uint, RGB(A) or
luminance, decoded to linear float frames and bit-exact against ffmpeg.
luminance, decoded to linear float frames and bit-exact against ffmpeg; PXR24 (lossy 24-bit
float, 16 lines per chunk) decodes too, bit-exact against OpenEXR (via OpenImageIO, #828).
- **Missing:** free-format MPEG audio, E-AC-3 adaptive hybrid
transform, enhanced coupling and the extra channels of 7.1 dependent substreams (played as the
5.1 core), AVI (#598; listed as an extension but no
demuxer), WMV / ASF, DV / DVCPRO / DV100, MPEG-4 Part 2, FLAC in MP4 (#603), camera RAW (R3D
incl. R3D NE, ARRIRAW, Sony RAW / X-OCN, Canon RAW, BRAW, ProRes RAW; #345, #383), JPEG 2000 /
JPEG XS MXF, the rest of CinemaDNG (lossy / JPEG XL DNG, MXF-wrapped, opcode lists, Raw
settings; DNG frames and folders import since #1065); stills: PSD, EXR beyond scan-line RLE / ZIP (PIZ, PXR24, B44, DWA,
settings; DNG frames and folders import since #1065); stills: PSD, EXR beyond scan-line RLE / ZIP / PXR24 (PIZ, B44, DWA,
tiled, multi-part, deep), HEIF / HEIC,
AI / EPS, and the rarer DPX layout, run-length encoding (Targa and DPX stills and sequences import
since #821, packed 10/12-bit and Y'CbCr DPX since #841, deeper than 8 bits kept at 8); transparent
@@ -314,3 +315,4 @@ platforms** G4, G9, G17; **performance** G16; **ecosystem** G11; **measurement**
|---|---|---|
| 2026-10-10 | major | Created from ROADMAP.md's "Where we are lacking" (2026-10-05); re-ranked against Premiere 26.5.2 and the 188 open issues; added `.prproj`, 10-bit / 4:2:2 camera decode, launch crashes and missing PR CI, localization, delivery formats |
| 2026-10-12 | minor | G10: MP1 (`A_MPEG/L1`) in Matroska imports (#1402) |
| 2026-10-12 | minor | G10: OpenEXR PXR24 import |
+2 -2
View File
@@ -1,6 +1,6 @@
# Localization parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (German ships the menus, #630) · **Target:** Adobe Premiere Pro 2026 (26.5.2)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (Korean covers the whole interface, #508) · **Target:** Adobe Premiere Pro 2026 (26.5.2)
Per-language status of FilmCraft's interface. Premiere 26.5.2 ships 10 interface languages (the
installed bundle's `.lproj` folders): German, English, Spanish, French, Italian, Japanese, Korean,
@@ -51,7 +51,7 @@ whole interface, one PR) at ~4–8 h per language for the catalog plus tests.
| Indonesian | id | 3,541 (same entries as Spanish, plus the macOS Settings item); must cover the core menus, no entry without a Spanish one | as Spanish | Latin (bundled fonts); System Language also maps the legacy `in` code | not recorded; welcome | partial (#867) | review |
| Japanese | ja | 3,487 (99.7%) | as zh-cn; system-font kanji may pick the Chinese fallback (#547) | CJK fonts, vertical title text | not recorded | partial | 4–8 h + review |
| German | de | 371 (10.6%) | menus only | Latin | yes (#630) | menus only | 5–9 h + review |
| Korean | ko | 0 (0%) | — | needs Hangul fonts in craft-fonts and IME testing | — | none (requested #508) | 10–20 h + review |
| Korean | ko | 3,573 (same entries as Spanish, plus the macOS Settings item) | as Spanish | installed system Hangul font (Malgun Gothic, Apple SD Gothic Neo, Noto Sans CJK KR…); refused without one | not recorded; welcome | partial (#508) | review; Hangul in craft-fonts for the web |
| Vietnamese | vi | 0 (0%) | — | Latin with stacked diacritics (font coverage to check) | — | none | 6–12 h + review |
| *Other shipped:* Italian | it | 3,573 (same entries as Spanish, plus the macOS Settings item); must cover the core menus, no entry without a Spanish one | as Spanish | Latin (bundled fonts) | not recorded; welcome | partial | review |
| *Other shipped:* Ukrainian | uk | 351 (10.0%) | menus only | Cyrillic (bundled) | not recorded | menus only | 5–9 h |
@@ -10,4 +10,5 @@ Terminal=false
StartupNotify=true
StartupWMClass=ai.storyteller.filmcraft
Categories=AudioVideo;Video;AudioVideoEditing;
MimeType=application/x-filmcraft;video/mp4;video/x-m4v;video/quicktime;video/x-matroska;video/webm;video/vnd.avi;video/x-msvideo;application/mxf;video/mp2t;video/mpeg;video/3gpp;
Keywords=video;editor;film;timeline;color;grading;nle;
@@ -54,6 +54,7 @@
</supports>
<provides>
<binary>filmcraft</binary>
<mediatype>application/x-filmcraft</mediatype>
</provides>
<content_rating type="oars-1.1"/>
<releases>
@@ -1,3 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- shared-mime-info definitions for types the system database doesn't have.
Installed to /usr/share/mime/packages/ (update-mime-database runs on install).
The type name matches the macOS UTI's public.mime-type in packaging/macos/Info.plist.in. -->
<mime-info xmlns="http://www.freedesktop.org/standards/shared-mime-info">
<mime-type type="application/x-filmcraft">
<comment>FilmCraft project</comment>
<sub-class-of type="application/json"/>
<icon name="ai.storyteller.filmcraft"/>
<glob pattern="*.fcproj"/>
</mime-type>
</mime-info>
@@ -48,8 +48,8 @@ modules:
- install -Dm755 bin/filmcraft /app/bin/filmcraft
- install -Dm755 bin/filmcraft-cli /app/bin/filmcraft-cli
- install -Dm644 share/applications/ai.storyteller.filmcraft.desktop /app/share/applications/ai.storyteller.filmcraft.desktop
# The same MIME file the deb/rpm install (no types of its own yet; the runtime's
# shared-mime-info already knows the video and audio formats).
# The same MIME file the deb/rpm install: it defines the .fcproj project type (the
# runtime's shared-mime-info already knows the video and audio formats).
- install -Dm644 share/mime/packages/ai.storyteller.filmcraft.xml /app/share/mime/packages/ai.storyteller.filmcraft.xml
# Already has the release's version and date substituted by package.sh.
- install -Dm644 share/metainfo/ai.storyteller.filmcraft.metainfo.xml /app/share/metainfo/ai.storyteller.filmcraft.metainfo.xml