mirror of
https://github.com/storytold/filmcraft.git
synced 2026-10-14 11:17:01 -04:00
Merge origin/main into #1389 (Italian beside Korean: 12 languages)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
commit
48cae3e5a3
45 files changed
+5256
-117
No files matched your search
@@ -5,3 +5,4 @@ plan/
|
||||
/log
|
||||
.DS_Store
|
||||
.claude/worktrees/
|
||||
__pycache__/
|
||||
@@ -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
@@ -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).
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
])
|
||||
})
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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() }));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -1008,6 +1008,7 @@ static CATEGORIES: &[Category] = &[
|
||||
("fr", "Français"),
|
||||
("id", "Bahasa Indonesia"),
|
||||
("it", "Italiano"),
|
||||
("ko", "한국어"),
|
||||
]),
|
||||
true,
|
||||
),
|
||||
|
||||
@@ -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()?;
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
||||
@@ -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());
|
||||
|
||||
@@ -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.
|
@@ -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.
|
@@ -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;
|
||||
|
||||
@@ -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.") {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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}));
|
||||
|
||||
@@ -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
@@ -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 |
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user