#779: export JPEG, Targa and DPX image sequences

Problem: Premiere offers JPEG, Targa and DPX as image-sequence export
formats; FilmCraft only wrote PNG, TIFF and BMP sequences (docs/gaps.md G13).

Fix: three new export formats (ids jpg, tga, dpx), numbered like the other
sequences, with presets, the export UI (JPEG quality slider, Targa alpha)
and es/ja/zh-cn strings. JPEG uses the image crate at the export quality;
Targa (TGA 2.0, uncompressed 24/32-bit) and DPX (SMPTE ST 268, 10-bit RGB
method A, BT.709) are written by a small hostile-input-safe writer in
crates/export/src/raster.rs. Format::is_image_sequence replaces the
hand-kept format lists. OpenEXR stays open.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
vangel76 2026-10-10 23:51:41 +02:00
parent ed141c74d0
commit c673ee85b9
16 changed files with 357 additions and 28 deletions

View File

@ -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 (`<stem><3+ digits>.<same extension>`, 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
/// (`<stem>.srt` / `<stem>.vtt`), each only if it was written since `since`. Other files that
/// merely share the name's stem (`<stem>.aep`, `<stem>2.psd`, `<stem>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)]

View File

@ -1268,7 +1268,7 @@ fn build() -> Vec<CommandSpec> {
"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
),

View File

@ -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}");

View File

@ -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}"),
}

View File

@ -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<u8>, w: u32, h: u32) -> Result<Vec<u8>> {
}
/// 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<u8>, w: u32, h: u32, alpha: bool) -> Result<Vec<u8>> {
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<u8>, w: u32, h: u32, alpha: bool, quality: u8) -> Result<Vec<u8>> {
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<Project>, 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<Project>, seq: ItemId, settings: &ExportSettings, so
let written: Vec<Result<u64>> = (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<Project>, 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)));
}

View File

@ -152,6 +152,14 @@ pub fn builtin_presets() -> Vec<ExportPreset> {
),
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",

179
crates/export/src/raster.rs Normal file
View File

@ -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<usize> {
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<Vec<u8>> {
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<Vec<u8>> {
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<Vec<u8>> {
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<u8>, at: usize, v: u32| hd[at..at + 4].copy_from_slice(&v.to_be_bytes());
let put16 = |hd: &mut Vec<u8>, 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());
}
}

View File

@ -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()) };

View File

@ -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<String> = (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");

View File

@ -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

Can't render this file because it contains an unexpected character in line 3 and column 95.

View File

@ -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 % に変化

Can't render this file because it contains an unexpected character in line 11 and column 59.

View File

@ -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%

Can't render this file because it contains an unexpected character in line 3 and column 95.

View File

@ -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);

View File

@ -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 (`<stem>_A1.mxf` …, `Report::extra_files`) |
| `png`, `tiff`, `bmp` | `image` | numbered stills `<name>000.<ext>`, `<name>001.<ext>` … |
| `png`, `tiff`, `bmp`, `jpg` | `image` | numbered stills `<name>000.<ext>`, `<name>001.<ext>` … |
| `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

View File

@ -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% |

View File

@ -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