#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:
parent
ed141c74d0
commit
c673ee85b9
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
|
|
|
|||
|
|
@ -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}"),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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()) };
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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% |
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue