diff --git a/crates/automation/src/long_job.rs b/crates/automation/src/long_job.rs index 8d4035ab3..62549b903 100644 --- a/crates/automation/src/long_job.rs +++ b/crates/automation/src/long_job.rs @@ -108,7 +108,7 @@ impl FilmcraftMcp { /// Delete what an interrupted export wrote, and nothing else: the output file itself, the numbered /// stills of an image sequence (`<3+ digits>.`, only when the output is a -/// PNG/TIFF/BMP sequence) and the caption sidecar +/// PNG/TIFF/BMP/JPEG/Targa/DPX sequence) and the caption sidecar /// (`.srt` / `.vtt`), each only if it was written since `since`. Other files that /// merely share the name's stem (`.aep`, `2.psd`, `1.mp4` beside a movie, …) are /// never touched, even when they were saved during the export. @@ -152,7 +152,7 @@ fn is_export_output(name: &str, file: &str, stem: &str, ext: &str) -> bool { /// Whether an export to a file with extension `ext` writes an image sequence (one still per frame). fn is_sequence_extension(ext: &str) -> bool { use filmcraft_engine::export::Format; - Format::from_name(ext).is_some_and(|f| matches!(f, Format::PngSequence | Format::TiffSequence | Format::BmpSequence)) + Format::from_name(ext).is_some_and(Format::is_image_sequence) } #[cfg(test)] diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index d272c41f7..c0494d1d8 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -1268,7 +1268,7 @@ fn build() -> Vec { "Media…", ["File", "Export"], None, - r#"{"path":str,"preset":str?,"settings":ExportSettings?,"format":"h264|hevc|prores|dnxhr|apv|mjpeg|mxf-op1a|mxf-opatom|png|tiff|bmp|gif|wav|aiff"?,"width":u32?,"height":u32?,"fps":f64?,"bitrateKbps":u32?,"bitrateMode":"cbr|vbr1Pass|vbr2Pass|crf"?,"crf":0..51=23,"hardwareEncoding":"off|auto"?,"scale":f32=1,"audio":bool=true,"quality":0..100,"burnCaptions":bool=false,"alpha":bool=false,"captionSidecar":"srt|vtt"?,"loudnessLufs":f64?,"proresProfile":"proxy|lt|standard|hq|4444|4444xq"?,"dnxProfile":"lb|sq|hq|hqx"?,"apvProfile":"422-10|422-12|444-10|444-12"?,"mxfVideoCodec":"dnxhr|proRes|h264"?,"sequence":id?,"range":"entire|inOut|workArea|custom"?,"startSeconds":f64?,"endSeconds":f64?,"wait":bool=false}"#, + r#"{"path":str,"preset":str?,"settings":ExportSettings?,"format":"h264|hevc|prores|dnxhr|apv|mjpeg|mxf-op1a|mxf-opatom|png|tiff|bmp|jpg|tga|dpx|gif|wav|aiff"?,"width":u32?,"height":u32?,"fps":f64?,"bitrateKbps":u32?,"bitrateMode":"cbr|vbr1Pass|vbr2Pass|crf"?,"crf":0..51=23,"hardwareEncoding":"off|auto"?,"scale":f32=1,"audio":bool=true,"quality":0..100,"burnCaptions":bool=false,"alpha":bool=false,"captionSidecar":"srt|vtt"?,"loudnessLufs":f64?,"proresProfile":"proxy|lt|standard|hq|4444|4444xq"?,"dnxProfile":"lb|sq|hq|hqx"?,"apvProfile":"422-10|422-12|444-10|444-12"?,"mxfVideoCodec":"dnxhr|proRes|h264"?,"sequence":id?,"range":"entire|inOut|workArea|custom"?,"startSeconds":f64?,"endSeconds":f64?,"wait":bool=false}"#, has_seq, crate::export_tools::export_media ), diff --git a/crates/engine/src/export_tests.rs b/crates/engine/src/export_tests.rs index e0d58794c..6a8c5d711 100644 --- a/crates/engine/src/export_tests.rs +++ b/crates/engine/src/export_tests.rs @@ -137,7 +137,7 @@ fn every_builtin_preset_exports_and_ffprobe_confirms() { Some(a) => (a[0].as_u64().unwrap(), a[1].as_u64().unwrap()), None => (1920, 1080), }; - if matches!(fmt.as_str(), "png" | "tiff" | "bmp") { + if matches!(fmt.as_str(), "png" | "tiff" | "bmp" | "jpg" | "tga" | "dpx") { let files = files_in(&sub); let ext = Path::new(&out).extension().unwrap().to_string_lossy().to_string(); assert_eq!(frames, 7, "frames 23..=29 at 23.976 fps"); @@ -148,6 +148,9 @@ fn every_builtin_preset_exports_and_ffprobe_confirms() { let codec = match fmt.as_str() { "png" => "png", "tiff" => "tiff", + "jpg" => "mjpeg", + "tga" => "targa", + "dpx" => "dpx", _ => "bmp", }; assert_eq!(v["codec_name"], codec, "{name}"); diff --git a/crates/engine/src/export_tools.rs b/crates/engine/src/export_tools.rs index 5c4d875b7..9e37a7809 100644 --- a/crates/engine/src/export_tools.rs +++ b/crates/engine/src/export_tools.rs @@ -360,7 +360,13 @@ fn file_safe(n: &str) -> String { fn with_extension(path: &str, settings: &ExportSettings) -> String { let ext = settings.extension(); match Path::new(path).extension().and_then(|e| e.to_str()) { - Some(e) if e.eq_ignore_ascii_case(ext) || (settings.format == Format::TiffSequence && e.eq_ignore_ascii_case("tiff")) => path.to_string(), + Some(e) + if e.eq_ignore_ascii_case(ext) + || (settings.format == Format::TiffSequence && e.eq_ignore_ascii_case("tiff")) + || (settings.format == Format::JpegSequence && e.eq_ignore_ascii_case("jpeg")) => + { + path.to_string() + } Some(_) if settings.is_image_sequence() || matches!(settings.format, Format::Aiff) => format!("{}.{ext}", path.rsplit_once('.').map_or(path, |x| x.0)), _ => format!("{path}.{ext}"), } diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs index 57f8850b3..279384f2d 100644 --- a/crates/export/src/lib.rs +++ b/crates/export/src/lib.rs @@ -5,8 +5,8 @@ //! are shared atomics so the UI (Export mode, header progress) and MCP can observe/cancel jobs. //! //! Video encoders implement [`VideoEncoder`]; codec crates register theirs with -//! [`register_encoder`] (H.264, ProRes …). Built in: Motion-JPEG (MOV), PNG / TIFF / BMP -//! sequences, GIF, WAV and AIFF. +//! [`register_encoder`] (H.264, ProRes …). Built in: Motion-JPEG (MOV), PNG / TIFF / BMP / JPEG / +//! Targa / DPX sequences, GIF, WAV and AIFF. //! //! [`ExportSettings`] carries every Export-mode setting (frame size, rate, bitrate encoding, audio //! format, multiplexer, captions, effects, metadata) as serde data; [`presets`] defines the @@ -22,6 +22,7 @@ mod pace; mod pcm; mod pipeline; pub mod presets; +mod raster; pub mod settings; pub mod still; pub use audio_out::LoudnessReport; @@ -96,6 +97,15 @@ pub enum Format { /// Numbered BMP stills. #[serde(rename = "bmp", alias = "BmpSequence")] BmpSequence, + /// Numbered JPEG stills (baseline, [`ExportSettings::quality`]). + #[serde(rename = "jpg", alias = "JpegSequence")] + JpegSequence, + /// Numbered Targa stills (uncompressed, 24-bit, or 32-bit with [`ExportSettings::alpha`]). + #[serde(rename = "tga", alias = "TgaSequence")] + TgaSequence, + /// Numbered DPX stills (SMPTE ST 268, 10-bit RGB, BT.709). + #[serde(rename = "dpx", alias = "DpxSequence")] + DpxSequence, #[serde(rename = "gif", alias = "Gif")] Gif, #[serde(rename = "wav", alias = "Wav")] @@ -126,6 +136,10 @@ impl Format { "png" | "pngsequence" => Format::PngSequence, "tif" | "tiff" | "tiffsequence" => Format::TiffSequence, "bmp" | "bmpsequence" => Format::BmpSequence, + // "jpeg" alone stays Motion-JPEG (QuickTime), as it always was + "jpg" | "jpgsequence" | "jpegsequence" => Format::JpegSequence, + "tga" | "targa" | "tgasequence" | "targasequence" => Format::TgaSequence, + "dpx" | "dpxsequence" => Format::DpxSequence, "gif" | "animatedgif" => Format::Gif, "wav" | "waveform" | "waveformaudio" => Format::Wav, "aif" | "aiff" | "aifc" => Format::Aiff, @@ -145,6 +159,9 @@ impl Format { Format::PngSequence => "png", Format::TiffSequence => "tiff", Format::BmpSequence => "bmp", + Format::JpegSequence => "jpg", + Format::TgaSequence => "tga", + Format::DpxSequence => "dpx", Format::Gif => "gif", Format::Wav => "wav", Format::Aiff => "aiff", @@ -159,6 +176,9 @@ impl Format { Format::PngSequence => "png", Format::TiffSequence => "tif", Format::BmpSequence => "bmp", + Format::JpegSequence => "jpg", + Format::TgaSequence => "tga", + Format::DpxSequence => "dpx", Format::Gif => "gif", Format::Wav => "wav", Format::Aiff => "aif", @@ -177,6 +197,9 @@ impl Format { Format::PngSequence => "PNG", Format::TiffSequence => "TIFF", Format::BmpSequence => "BMP", + Format::JpegSequence => "JPEG", + Format::TgaSequence => "Targa", + Format::DpxSequence => "DPX", Format::Gif => "Animated GIF", Format::Wav => "Waveform Audio", Format::Aiff => "AIFF", @@ -184,6 +207,10 @@ impl Format { Format::MxfOpAtom => "MXF OP-Atom", } } + /// Numbered stills, one per frame ([`image_sequence_path`]). + pub fn is_image_sequence(self) -> bool { + matches!(self, Format::PngSequence | Format::TiffSequence | Format::BmpSequence | Format::JpegSequence | Format::TgaSequence | Format::DpxSequence) + } /// An MXF container format. pub fn is_mxf(self) -> bool { matches!(self, Format::MxfOp1a | Format::MxfOpAtom) @@ -205,7 +232,7 @@ impl Format { pub fn is_mp4_with(self, mux: Multiplexer) -> bool { self == Format::Av1 || (self.is_h26x() && mux == Multiplexer::Mp4) } - pub const ALL: [Format; 15] = [ + pub const ALL: [Format; 18] = [ Format::H264, Format::Hevc, Format::Av1, @@ -216,6 +243,9 @@ impl Format { Format::PngSequence, Format::TiffSequence, Format::BmpSequence, + Format::JpegSequence, + Format::TgaSequence, + Format::DpxSequence, Format::Gif, Format::Wav, Format::Aiff, @@ -244,7 +274,7 @@ pub struct ExportSettings { /// Output scale (1.0 = sequence frame size). pub scale: f32, pub include_audio: bool, - /// Quality 0–100 for lossy codecs. + /// Quality 0–100 for lossy codecs (Motion-JPEG, JPEG sequences). pub quality: u8, /// Target video bitrate (kbps) for bitrate-driven encoders. pub bitrate_kbps: u32, @@ -272,7 +302,7 @@ pub struct ExportSettings { /// sequence's HDR space and signal it (VUI / `colr` / `mdcv` / `clli` / SEI). #[serde(default)] pub sdr: bool, - /// Keep the alpha channel in PNG and TIFF sequences and ProRes 4444 / 4444 XQ QuickTime movies + /// Keep the alpha channel in PNG, TIFF and Targa sequences and ProRes 4444 / 4444 XQ QuickTime movies /// (straight alpha, Premiere's "Include Alpha Channel"). Off, every frame is flattened over /// black (#160). Other formats ignore it. #[serde(default)] @@ -1629,11 +1659,30 @@ pub fn encode_png(rgba: Vec, w: u32, h: u32) -> Result> { } /// Encode one still of an image sequence (also Export Frame). PNG always carries the alpha -/// channel it is given; `alpha` keeps it in TIFF too, which is otherwise written as RGB. +/// channel it is given; `alpha` keeps it in TIFF and Targa too, which are otherwise written as RGB. +/// JPEG uses the default export quality ([`encode_still_with_quality`] picks another). pub fn encode_still(format: Format, rgba: Vec, w: u32, h: u32, alpha: bool) -> Result> { + encode_still_with_quality(format, rgba, w, h, alpha, ExportSettings::default().quality) +} + +/// [`encode_still`] with the JPEG quality (1–100; ignored by the lossless formats). +pub fn encode_still_with_quality(format: Format, rgba: Vec, w: u32, h: u32, alpha: bool, quality: u8) -> Result> { let enc = |e: image::ImageError| ExportError::Encode(e.to_string()); let mut out = std::io::Cursor::new(Vec::new()); match format { + Format::JpegSequence => { + let rgb = raster::rgb(&rgba, w, h)?; + image::ImageEncoder::write_image( + image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, quality.clamp(1, 100)), + &rgb, + w, + h, + image::ExtendedColorType::Rgb8, + ) + .map_err(enc)? + } + Format::TgaSequence => return raster::tga(&rgba, w, h, alpha), + Format::DpxSequence => return raster::dpx(&rgba, w, h), Format::PngSequence => { image::ImageEncoder::write_image(image::codecs::png::PngEncoder::new(&mut out), &rgba, w, h, image::ExtendedColorType::Rgba8).map_err(enc)? } @@ -1707,7 +1756,7 @@ pub fn export(project: &Arc, seq: ItemId, settings: &ExportSettings, so } (n, frames) } - Format::PngSequence | Format::TiffSequence | Format::BmpSequence | Format::Gif => { + f if f.is_image_sequence() || f == Format::Gif => { let pipe = pipeline::Pipeline::new(project.clone(), seq, settings, false)?; let (f0, f1) = frame_span(pipe.rate, range); let count = (f1 - f0) as u64; @@ -1742,7 +1791,7 @@ pub fn export(project: &Arc, seq: ItemId, settings: &ExportSettings, so let written: Vec> = (f..end) .into_par_iter() .map(|fi| { - let data = encode_still(settings.format, pipe.frame(fi, sources).0, w, h, settings.alpha)?; + let data = encode_still_with_quality(settings.format, pipe.frame(fi, sources).0, w, h, settings.alpha, settings.quality)?; write_output(settings, &pcm::image_sequence_path(&settings.path, (fi - f0) as u64, count), data) }) .collect(); @@ -1761,7 +1810,7 @@ pub fn export(project: &Arc, seq: ItemId, settings: &ExportSettings, so } (total, count) } - Format::H264 | Format::Hevc | Format::Av1 | Format::ProRes | Format::DnxHr | Format::Apv | Format::Mjpeg | Format::MxfOp1a | Format::MxfOpAtom => { + _ => { // Handled by the stepped exporter above; reaching here would be a dispatch bug. return Err(ExportError::Unsupported(format!("{:?} must run as a stepped export", settings.format))); } diff --git a/crates/export/src/presets.rs b/crates/export/src/presets.rs index 4323de772..2a021d499 100644 --- a/crates/export/src/presets.rs +++ b/crates/export/src/presets.rs @@ -152,6 +152,14 @@ pub fn builtin_presets() -> Vec { ), p("TIFF Sequence", "Image Sequence", "Numbered TIFF stills (RGB, 8-bit)", ExportSettings { format: Format::TiffSequence, ..Default::default() }), p("BMP Sequence", "Image Sequence", "Numbered BMP stills (24-bit)", ExportSettings { format: Format::BmpSequence, ..Default::default() }), + p("JPEG Sequence", "Image Sequence", "Numbered JPEG stills (quality 90)", ExportSettings { format: Format::JpegSequence, ..Default::default() }), + p( + "Targa Sequence", + "Image Sequence", + "Numbered Targa stills (24-bit, uncompressed)", + ExportSettings { format: Format::TgaSequence, ..Default::default() }, + ), + p("DPX Sequence", "Image Sequence", "Numbered DPX stills (10-bit RGB)", ExportSettings { format: Format::DpxSequence, ..Default::default() }), p( "Animated GIF 640×360", "Animated GIF", diff --git a/crates/export/src/raster.rs b/crates/export/src/raster.rs new file mode 100644 index 000000000..596413196 --- /dev/null +++ b/crates/export/src/raster.rs @@ -0,0 +1,179 @@ +//! Uncompressed still formats written directly from RGBA8 frames: Targa and DPX. +//! +//! Targa follows the Truevision TGA File Format Specification 2.0 (image type 2, uncompressed +//! true-colour, bottom-left origin, the layout every reader accepts). DPX follows SMPTE ST 268 +//! (version 2.0 header): one RGB image element, 10 bits per component packed into 32-bit +//! big-endian words (method A, two padding bits at the bottom), BT.709 transfer and colorimetry. +use crate::{ExportError, Result}; + +/// `w * h * 4` when `rgba` holds exactly that many bytes. +fn checked_len(rgba: &[u8], w: u32, h: u32) -> Result { + let n = (w as usize).checked_mul(h as usize).and_then(|n| n.checked_mul(4)).ok_or_else(|| ExportError::Encode("frame size".into()))?; + if w == 0 || h == 0 || rgba.len() != n { + return Err(ExportError::Encode("frame size".into())); + } + Ok(n) +} + +/// The RGB bytes of an RGBA8 frame (alpha dropped). +pub(crate) fn rgb(rgba: &[u8], w: u32, h: u32) -> Result> { + checked_len(rgba, w, h)?; + Ok(rgba.as_chunks::<4>().0.iter().flat_map(|&[r, g, b, _]| [r, g, b]).collect()) +} + +/// An uncompressed Targa file: 24-bit BGR, or 32-bit BGRA with `alpha` (straight alpha, as the +/// frame carries it). +pub(crate) fn tga(rgba: &[u8], w: u32, h: u32, alpha: bool) -> Result> { + checked_len(rgba, w, h)?; + let (tw, th) = match (u16::try_from(w), u16::try_from(h)) { + (Ok(tw), Ok(th)) => (tw, th), + _ => return Err(ExportError::Unsupported(format!("Targa frames are at most 65535×65535 (got {w}×{h})"))), + }; + let bpp: usize = if alpha { 4 } else { 3 }; + let row = w as usize * 4; + let mut out = Vec::with_capacity(18 + w as usize * h as usize * bpp + 26); + out.extend_from_slice(&[0, 0, 2]); // no ID, no colour map, uncompressed true-colour + out.extend_from_slice(&[0; 5]); // colour map specification (unused) + out.extend_from_slice(&[0, 0, 0, 0]); // x / y origin + out.extend_from_slice(&tw.to_le_bytes()); + out.extend_from_slice(&th.to_le_bytes()); + out.push(if alpha { 32 } else { 24 }); + out.push(if alpha { 8 } else { 0 }); // attribute bits; bottom-left origin + // bottom-left origin: the last frame row comes first + for line in rgba.chunks_exact(row).rev() { + for &[r, g, b, a] in line.as_chunks::<4>().0 { + out.extend_from_slice(&[b, g, r]); + if alpha { + out.push(a); + } + } + } + // TGA 2.0 footer without extension or developer areas + out.extend_from_slice(&[0; 8]); + out.extend_from_slice(b"TRUEVISION-XFILE.\0"); + Ok(out) +} + +/// Header size of the DPX files we write (file + image + orientation + film + TV headers). +const DPX_HEADER: usize = 2048; + +/// A DPX file: 10-bit RGB (8-bit values widened by bit replication, so 0 → 0 and 255 → 1023). +pub(crate) fn dpx(rgba: &[u8], w: u32, h: u32) -> Result> { + let n = checked_len(rgba, w, h)?; + // one 32-bit word per pixel; `n` is already `w * h * 4` + let size = n.checked_add(DPX_HEADER).ok_or_else(|| ExportError::Encode("frame size".into()))?; + let file_size = u32::try_from(size).map_err(|_| ExportError::Unsupported(format!("a {w}×{h} DPX frame exceeds 4 GB")))?; + // SMPTE ST 268: undefined numeric fields are all ones, undefined text fields are NUL + let mut hd = vec![0xFF_u8; DPX_HEADER]; + for (a, b) in [ + (36, 660), // file name, creation time, creator, project, copyright + (664, 768), // reserved + (1356, 1408), // reserved + (1432, 1620), // source file name, time, input device, serial number + (1644, 1664), // reserved + (1664, 1712), // film manufacturer, type, offset, prefix, count, format + (1732, 1920), // frame id, slate, reserved + (1972, 2048), // reserved + ] { + hd[a..b].fill(0); + } + for e in 0..8 { + let d = 780 + e * 72 + 40; + hd[d..d + 32].fill(0); // element descriptions + } + let put32 = |hd: &mut Vec, at: usize, v: u32| hd[at..at + 4].copy_from_slice(&v.to_be_bytes()); + let put16 = |hd: &mut Vec, at: usize, v: u16| hd[at..at + 2].copy_from_slice(&v.to_be_bytes()); + // file information header + hd[0..4].copy_from_slice(b"SDPX"); + put32(&mut hd, 4, DPX_HEADER as u32); // offset to the image data + hd[8..16].copy_from_slice(b"V2.0\0\0\0\0"); + put32(&mut hd, 16, file_size); + put32(&mut hd, 20, 1); // ditto key: new frame + put32(&mut hd, 24, 1664); // generic header length + put32(&mut hd, 28, 384); // industry header length + put32(&mut hd, 32, 0); // user data length + hd[160..169].copy_from_slice(b"FilmCraft"); + // image information header + put16(&mut hd, 768, 0); // orientation: left to right, top to bottom + put16(&mut hd, 770, 1); // one image element + put32(&mut hd, 772, w); + put32(&mut hd, 776, h); + let el = 780; + put32(&mut hd, el, 0); // unsigned + put32(&mut hd, el + 4, 0); // reference low data code + put32(&mut hd, el + 12, 1023); // reference high data code + hd[el + 20] = 50; // descriptor: RGB + hd[el + 21] = 6; // transfer: ITU-R BT.709 + hd[el + 22] = 6; // colorimetric: ITU-R BT.709 + hd[el + 23] = 10; // bit depth + put16(&mut hd, el + 24, 1); // packing: filled to 32-bit words, method A + put16(&mut hd, el + 26, 0); // no encoding (uncompressed) + put32(&mut hd, el + 28, DPX_HEADER as u32); // offset to the element's data + put32(&mut hd, el + 32, 0); // end-of-line padding + put32(&mut hd, el + 36, 0); // end-of-image padding + // image orientation header: no offset, square pixels + put32(&mut hd, 1408, 0); + put32(&mut hd, 1412, 0); + put32(&mut hd, 1424, w); + put32(&mut hd, 1428, h); + put32(&mut hd, 1628, 1); + put32(&mut hd, 1632, 1); + // television header: progressive + hd[1928] = 0; + let mut out = Vec::with_capacity(size); + out.extend_from_slice(&hd); + let ten = |v: u8| (u32::from(v) << 2) | (u32::from(v) >> 6); + for &[r, g, b, _] in rgba.as_chunks::<4>().0 { + out.extend_from_slice(&((ten(r) << 22) | (ten(g) << 12) | (ten(b) << 2)).to_be_bytes()); + } + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn be32(b: &[u8], at: usize) -> u32 { + u32::from_be_bytes(b[at..at + 4].try_into().unwrap()) + } + + #[test] + fn dpx_header_and_packing() { + // 2×1: pure red, then (1, 128, 255) + let px = [255, 0, 0, 255, 1, 128, 255, 7]; + let f = dpx(&px, 2, 1).unwrap(); + assert_eq!(&f[0..4], b"SDPX"); + assert_eq!(f.len(), 2048 + 8); + assert_eq!(be32(&f, 4), 2048); + assert_eq!(be32(&f, 16), f.len() as u32); + assert_eq!((be32(&f, 772), be32(&f, 776)), (2, 1)); + assert_eq!((f[800], f[803]), (50, 10)); + assert_eq!(be32(&f, 2048), 1023 << 22); + let p = be32(&f, 2052); + assert_eq!(((p >> 22) & 1023, (p >> 12) & 1023, (p >> 2) & 1023, p & 3), (4, 514, 1023, 0)); + } + + #[test] + fn tga_rows_are_bottom_up_bgr() { + // 1×2: top red, bottom blue (half transparent) + let px = [255, 0, 0, 255, 0, 0, 255, 128]; + let f = tga(&px, 1, 2, false).unwrap(); + assert_eq!(&f[..3], &[0, 0, 2]); + assert_eq!((f[16], f[17]), (24, 0)); + assert_eq!(&f[18..24], &[255, 0, 0, 0, 0, 255]); // blue row first, as BGR + assert!(f.ends_with(b"TRUEVISION-XFILE.\0")); + let f = tga(&px, 1, 2, true).unwrap(); + assert_eq!((f[16], f[17]), (32, 8)); + assert_eq!(&f[18..26], &[255, 0, 0, 128, 0, 0, 255, 255]); + } + + #[test] + fn hostile_sizes_are_errors() { + assert!(dpx(&[0; 7], 2, 1).is_err()); + assert!(dpx(&[], 0, 0).is_err()); + assert!(tga(&[0; 4], 1, 2, false).is_err()); + assert!(tga(&[0; 4], u32::MAX, u32::MAX, false).is_err()); + assert!(rgb(&[0; 3], 1, 1).is_err()); + assert!(dpx(&[0; 4], u32::MAX, u32::MAX).is_err()); + } +} diff --git a/crates/export/src/settings.rs b/crates/export/src/settings.rs index 90dd595f8..4e062d2ab 100644 --- a/crates/export/src/settings.rs +++ b/crates/export/src/settings.rs @@ -378,11 +378,11 @@ impl ExportSettings { if self.format.is_mxf() { self.mxf_video_codec.encoder_format() } else { self.format } } - /// Whether the output can carry an alpha channel ([`ExportSettings::alpha`]): PNG and TIFF - /// sequences and ProRes 4444 / 4444 XQ QuickTime movies. + /// Whether the output can carry an alpha channel ([`ExportSettings::alpha`]): PNG, TIFF and + /// Targa sequences and ProRes 4444 / 4444 XQ QuickTime movies. pub fn supports_alpha(&self) -> bool { match self.format { - Format::PngSequence | Format::TiffSequence => true, + Format::PngSequence | Format::TiffSequence | Format::TgaSequence => true, Format::ProRes => crate::prores_profile(&self.prores_profile).chroma() == filmcraft_prores::ChromaFormat::Yuv444, _ => false, } @@ -395,7 +395,7 @@ impl ExportSettings { /// Whether the output is a numbered image sequence. pub fn is_image_sequence(&self) -> bool { - matches!(self.format, Format::PngSequence | Format::TiffSequence | Format::BmpSequence) + self.format.is_image_sequence() } /// Output size, rate, audio format and bitrate for a sequence of `seq_w`×`seq_h` at `seq_rate`. @@ -482,9 +482,12 @@ impl ExportSettings { }; px * fps * bpp } - Format::Mjpeg => px * 8.0 * (0.4 + self.quality as f64 / 100.0 * 2.0) * fps / 8.0, + Format::Mjpeg | Format::JpegSequence => px * 8.0 * (0.4 + self.quality as f64 / 100.0 * 2.0) * fps / 8.0, Format::PngSequence => px * 4.0 * 0.45 * 8.0 * fps, Format::TiffSequence | Format::BmpSequence => px * if self.format == Format::BmpSequence { 3.0 } else { 4.0 } * 8.0 * fps, + Format::TgaSequence => px * if self.keeps_alpha() { 4.0 } else { 3.0 } * 8.0 * fps, + // 10-bit RGB in one 32-bit word per pixel + Format::DpxSequence => px * 4.0 * 8.0 * fps, Format::Gif => px * 0.6 * 8.0 * fps, Format::Wav | Format::Aiff | Format::MxfOp1a | Format::MxfOpAtom => 0.0, }; @@ -566,7 +569,8 @@ impl ExportSettings { } Format::DnxHr => v += &format!(", DNxHR {}", if self.dnx_profile.is_empty() { "HQ".into() } else { self.dnx_profile.to_ascii_uppercase() }), Format::Apv => v += &format!(", {}", crate::apv_profile(&self.apv_profile).name()), - Format::Mjpeg => v += &format!(", quality {}", self.quality), + Format::Mjpeg | Format::JpegSequence => v += &format!(", quality {}", self.quality), + Format::DpxSequence => v += ", 10-bit RGB", _ => {} } v @@ -589,7 +593,7 @@ impl ExportSettings { Format::H264 | Format::Hevc | Format::Av1 => "MP4", Format::ProRes | Format::DnxHr | Format::Apv | Format::Mjpeg => "QuickTime", Format::MxfOp1a | Format::MxfOpAtom => self.mxf_video_codec.label(), - Format::PngSequence | Format::TiffSequence | Format::BmpSequence => "Image sequence", + f if f.is_image_sequence() => "Image sequence", _ => "", }; let format = if container.is_empty() { self.format.label().to_string() } else { format!("{} ({container})", self.format.label()) }; diff --git a/crates/export/src/settings_tests.rs b/crates/export/src/settings_tests.rs index d507639df..8cfb31702 100644 --- a/crates/export/src/settings_tests.rs +++ b/crates/export/src/settings_tests.rs @@ -109,6 +109,57 @@ fn image_sequences_are_numbered_like_premiere() { } } +/// JPEG, Targa and DPX sequences (#779): numbered like the others, JPEG at the export quality, +/// Targa with alpha when asked; every still decoded by ffmpeg (external test oracle). +#[test] +fn jpeg_targa_and_dpx_sequences() { + let (p, seq, m) = matte([0.0, 0.0, 1.0, 0.5], 64, 36, None); + let mut jpeg_sizes = Vec::new(); + for (fmt, ext, alpha, quality) in [ + (Format::JpegSequence, "jpg", false, 95), + (Format::JpegSequence, "jpg", false, 20), + (Format::TgaSequence, "tga", false, 90), + (Format::TgaSequence, "tga", true, 90), + (Format::DpxSequence, "dpx", false, 90), + ] { + let dir = Scratch::new(&format!("seq3-{ext}-{alpha}-{quality}")); + let mut s = ExportSettings { format: fmt, path: dir.path(&format!("Shot.{ext}")), alpha, quality, ..Default::default() }; + s.range = Some(TimeRange::new(Tick::ZERO, FrameRate::FPS_24.tick_of(3))); + assert_eq!(Format::from_name(fmt.id()), Some(fmt)); + assert!(fmt.is_image_sequence() && s.is_image_sequence() && !s.has_audio()); + let r = export(&p, seq, &s, &m, &Progress::default()).unwrap(); + assert_eq!(r.frames, 3); + let want: Vec = (0..3).map(|i| format!("Shot{i:03}.{ext}")).collect(); + assert_eq!(files_in(&dir.0), want, "{fmt:?}"); + let first = dir.0.join(&want[0]); + let bytes = std::fs::read(&first).unwrap(); + match fmt { + Format::JpegSequence => { + let px = image::load_from_memory(&bytes).unwrap().to_rgb8().get_pixel(32, 18).0; + assert!(px[2] > 100 && px[0] < 20, "{px:?}"); + jpeg_sizes.push(bytes.len()); + } + Format::DpxSequence => assert_eq!(bytes.len(), 2048 + 64 * 36 * 4), + _ => assert_eq!(bytes.len(), 18 + 64 * 36 * if alpha { 4 } else { 3 } + 26), + } + let Some(ffmpeg) = filmcraft_testkit::ffmpeg_or_skip("jpeg / targa / dpx sequences") else { continue }; + let first = first.to_string_lossy().to_string(); + let out = std::process::Command::new(ffmpeg).args(["-v", "error", "-i", &first, "-f", "rawvideo", "-pix_fmt", "rgba", "-"]).output().unwrap(); + assert!(out.status.success(), "{first}: {}", String::from_utf8_lossy(&out.stderr)); + assert_eq!(out.stdout.len(), 64 * 36 * 4, "{first}"); + let at = (18 * 64 + 32) * 4; + let px = &out.stdout[at..at + 4]; + if alpha { + assert!((100..=160).contains(&px[3]) && px[2] > 200 && px[0] < 15, "{first} straight alpha: {px:?}"); + } else { + assert!(px[3] == 255 && px[2] > 100 && px[2] < 240 && px[0] < 20, "{first} flattened over black: {px:?}"); + } + // the top-left pixel is the top row (Targa is stored bottom-up), and all of it is blue + assert!(out.stdout.as_chunks::<4>().0.iter().all(|p| p[2] > 100 && p[0] < 20), "{first}"); + } + assert!(jpeg_sizes[0] > jpeg_sizes[1], "quality 95 must be larger than quality 20: {jpeg_sizes:?}"); +} + /// `alpha` keeps straight alpha in PNG and TIFF sequences; off (the default) flattens over black (#160). #[test] fn image_sequences_keep_alpha_when_asked() { @@ -607,7 +658,7 @@ fn h265_is_a_format_that_needs_a_registered_encoder() { } assert_eq!(Format::from_name(Format::Hevc.id()), Some(Format::Hevc)); assert_eq!((Format::Hevc.extension(), Format::Hevc.label()), ("mp4", "H.265 (HEVC)")); - assert!(Format::ALL.contains(&Format::Hevc) && Format::ALL.len() == 15); + assert!(Format::ALL.contains(&Format::Hevc) && Format::ALL.len() == 18); let s: ExportSettings = serde_json::from_value(serde_json::json!({"format": "hevc"})).unwrap(); assert_eq!(s.format, Format::Hevc); assert_eq!(serde_json::to_value(&s).unwrap()["format"], "hevc"); diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index f55b52269..51d1cd030 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -3173,6 +3173,15 @@ Nudge Volume -1dB Ajustar volumen -1 dB Nudge Volume -3dB Ajustar volumen -3 dB Numbered BMP stills (24-bit) Imágenes fijas BMP numeradas (24 bits) + JPEG JPEG + Targa Targa + DPX DPX + JPEG Sequence Secuencia JPEG + Targa Sequence Secuencia Targa + DPX Sequence Secuencia DPX + Numbered JPEG stills (quality 90) Imágenes fijas JPEG numeradas (calidad 90) + Numbered Targa stills (24-bit, uncompressed) Imágenes fijas Targa numeradas (24 bits, sin comprimir) + Numbered DPX stills (10-bit RGB) Imágenes fijas DPX numeradas (RGB de 10 bits) Numbered PNG stills at the sequence's frame size Imágenes fijas PNG numeradas al tamaño de fotograma de la secuencia Numbered TIFF stills (RGB, 8-bit) Imágenes fijas TIFF numeradas (RGB, 8 bits) Opacity 0 → 100 % over the first second Opacidad de 0 a 100 % durante el primer segundo diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 65419ebc2..79c7d025d 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3162,6 +3162,15 @@ Nudge Volume -1dB 音量を -1dB 微調整 Nudge Volume -3dB 音量を -3dB 微調整 Numbered BMP stills (24-bit) 連番 BMP 静止画 (24-bit) + JPEG JPEG + Targa Targa + DPX DPX + JPEG Sequence JPEG 連番画像 + Targa Sequence Targa 連番画像 + DPX Sequence DPX 連番画像 + Numbered JPEG stills (quality 90) 連番 JPEG 静止画 (品質 90) + Numbered Targa stills (24-bit, uncompressed) 連番 Targa 静止画 (24-bit、非圧縮) + Numbered DPX stills (10-bit RGB) 連番 DPX 静止画 (10-bit RGB) Numbered PNG stills at the sequence's frame size シーケンスと同じフレームサイズの連番 PNG 静止画 Numbered TIFF stills (RGB, 8-bit) 連番 TIFF 静止画 (RGB、8-bit) Opacity 0 → 100 % over the first second 最初の 1 秒で不透明度を 0 → 100 % に変化 diff --git a/crates/ui-egui/src/i18n/zh-cn.tsv b/crates/ui-egui/src/i18n/zh-cn.tsv index fc8054350..16472ef07 100644 --- a/crates/ui-egui/src/i18n/zh-cn.tsv +++ b/crates/ui-egui/src/i18n/zh-cn.tsv @@ -3156,6 +3156,15 @@ Nudge Volume -1dB 微调音量 -1dB Nudge Volume -3dB 微调音量 -3dB Numbered BMP stills (24-bit) 带编号的 BMP 静帧(24 位) + JPEG JPEG + Targa Targa + DPX DPX + JPEG Sequence JPEG 序列 + Targa Sequence Targa 序列 + DPX Sequence DPX 序列 + Numbered JPEG stills (quality 90) 带编号的 JPEG 静帧(质量 90) + Numbered Targa stills (24-bit, uncompressed) 带编号的 Targa 静帧(24 位,未压缩) + Numbered DPX stills (10-bit RGB) 带编号的 DPX 静帧(10 位 RGB) Numbered PNG stills at the sequence's frame size 带编号的 PNG 静帧(采用序列帧大小) Numbered TIFF stills (RGB, 8-bit) 带编号的 TIFF 静帧(RGB,8 位) Opacity 0 → 100 % over the first second 在最初一秒内不透明度从 0 → 100% diff --git a/crates/ui-egui/src/panels/export_mode.rs b/crates/ui-egui/src/panels/export_mode.rs index 426da87cd..30d21268e 100644 --- a/crates/ui-egui/src/panels/export_mode.rs +++ b/crates/ui-egui/src/panels/export_mode.rs @@ -743,7 +743,7 @@ fn video_section(ui: &mut egui::Ui, reg: &mut Reg, s: &mut ExportSettings, t: &T } }); } - Format::Mjpeg => { + Format::Mjpeg | Format::JpegSequence => { row(ui, t, tl!("Quality"), |ui| { let mut q = s.quality as f64; drag(ui, reg, "export.video.quality", &mut q, 1.0..=100.0, 1.0, "", 0); diff --git a/docs/architecture.md b/docs/architecture.md index 24a62065a..ed1486af9 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -666,7 +666,8 @@ file.exportMedia {path, preset?, settings?, format?, range?, …} export.qui | `mjpeg` | built in | MOV | | `mxf-op1a` | DNxHR (default), ProRes or H.264 (Annex B, long GOP), `mxfVideoCodec`; PCM 16/24-bit | MXF OP1a (`filmcraft-mxf` writer: frame-wrapped, index with temporal offsets, start timecode) | | `mxf-opatom` | as `mxf-op1a` | Avid-style MXF OP-Atom: clip-wrapped picture at `path`, one mono PCM file per channel (`_A1.mxf` …, `Report::extra_files`) | -| `png`, `tiff`, `bmp` | `image` | numbered stills `000.`, `001.` … | +| `png`, `tiff`, `bmp`, `jpg` | `image` | numbered stills `000.`, `001.` … | +| `tga`, `dpx` | built in (`raster.rs`: uncompressed Targa 24/32-bit; DPX 10-bit RGB, SMPTE ST 268) | numbered stills, as above | | `gif`, `wav`, `aiff` | built in / `image` | GIF / RIFF WAVE (`WAVE_FORMAT_EXTENSIBLE` for 5.1) / AIFF (16- or 24-bit PCM) | - **Reproducible.** The same project and settings give the same file on every machine. Frames render diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index b19262c84..58c45cb6b 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -52,12 +52,12 @@ only). "Tested" names the evidence. | Format | Premiere | Read | Write (frame / sequence) | % | |---|---|---|---|---| -| PNG, JPEG, TIFF, BMP, GIF | read + write | yes | PNG / TIFF / BMP sequences, GIF; **no JPEG sequence** | 80% | +| 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 | no | no | 0% | -| **DPX** | read + write | no | no | 0% | -| Targa | read + write | no | no | 0% | +| **DPX** | read + write | no | sequences (10-bit RGB, BT.709; #779) | 40% | +| Targa | read + write | no | sequences (24-bit, 32-bit with alpha; #779) | 40% | | HEIF / HEIC | read | no | — | 0% | | Radiance HDR, AI / EPS, ICO, PTL | read | no | — | 0% | diff --git a/docs/gaps.md b/docs/gaps.md index 3532ae7b5..7ab42d059 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -178,9 +178,10 @@ platforms** G4, G9, G17; **performance** G16; **ecosystem** G11; **measurement** ## G13. Delivery formats - **Missing:** AV1 export (software or hardware), MPEG-2 / DVD / Blu-ray, DCP, JPEG 2000 MXF, - AS-10 / AS-11, XDCAM HD MXF, P2, EXR / DPX / Targa / JPEG sequences, MP3 and AAC-only audio, + AS-10 / AS-11, XDCAM HD MXF, P2, OpenEXR sequences, MP3 and AAC-only audio, ProRes 4444 / XQ in the export UI (#342; the encoder has the profiles), interlaced encoding, smart render, HEVC software encoder (HEVC exists only where a hardware encoder does). +- **Have:** PNG, TIFF, BMP, JPEG, Targa and DPX (10-bit) image sequences (#779). - **Estimate:** 60–110 h. Doc: [file-format-parity.md](file-format-parity.md). ## G14. Localization: 8 of the 12 key languages missing