Skip to content
Open
Show file tree
Hide file tree
Changes from 11 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ panic path, and fix a crash before building on top of it. The cross-app standard
uses `unsafe_code = "deny"` with `#[allow(unsafe_code)]` only on its FFI modules, a
`// SAFETY:` comment on every `unsafe` block, a safe `Result`-returning public API, and the
pure-Rust decoder as the tested fallback ([ADR 0001](docs/adr/0001-platform-ffi.md)).
*Proposed, not in effect* ([ADR 0002](docs/adr/0002-optional-vendor-sdk-helpers.md), #383): an
opt-in vendor SDK feature may add one module that maps its helper process's frames read-only,
under the same rules; such a format has no pure-Rust decoder, and without the feature it is
reported as unsupported.
4. **Every input-derived number is hostile.** Media files, project and interchange files, presets, fonts,
CLI / MCP / control-channel parameters and UI state are untrusted. Index and slice with `get()` (or
validate bounds once, up front, for a hot loop); slice strings only at char boundaries; use
Expand Down Expand Up @@ -122,7 +126,20 @@ reproduce an image (for example, point lists traced from someone else's icon).

See [§0](#0-never-crash). It outranks every other engineering rule.

### 3.2 The rest
### 3.2 Optional vendor SDK decoders (proposed, not in effect)

*Proposed in [ADR 0002](docs/adr/0002-optional-vendor-sdk-helpers.md) for #383; until the owners
accept it, the rules above stand unchanged.* A camera format with no public specification may be
decoded through its vendor's SDK only as an optional Cargo feature that is off by default and never
enabled by CI or official builds, built only against an SDK the user supplies (nothing of it
downloaded, committed or bundled), with the vendor's code running in a separate helper process the
editor talks to over a versioned protocol whose replies it treats as hostile input. Its `unsafe` is
at most one shared-memory module in `crates/platform` under ADR 0001's rules; the default build,
its gates and its tests are unchanged, and the format is still recognised there with a message
naming the feature. The adapter is original code; SDK-enabled builds are not distributed by the
project.

### 3.3 The rest

See `CLAUDE.md`: pure Rust, dependency layering (`cargo xtask layers`), exact `Tick` time, everything is
a command, everything is agent-drivable, and the quality gates (`cargo xtask ci`) before every commit.
Expand Down
3 changes: 3 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,11 @@ opt-level = 3
[profile.dev.package.filmcraft-vp9]
opt-level = 3

# OS media bindings and the RED helper's client: a 6K camera RAW frame is converted on the
# playback path (20 ms a frame at opt-level 1, a few at 3).
[profile.dev.package.filmcraft-platform]
opt-level = 3

[profile.release]
lto = "thin"
codegen-units = 1
1 change: 1 addition & 0 deletions apps/filmcraft-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ path = "src/main.rs"

[features]
default = []
red-sdk = ["filmcraft-platform/red-sdk"]
# Speech-to-text (Text panel ▸ Transcribe) with Whisper on the CPU, and model downloads.
whisper = ["filmcraft-engine/whisper", "filmcraft-engine/speech-download"]

Expand Down
8 changes: 8 additions & 0 deletions apps/filmcraft-cli/src/probe.rs
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,14 @@ pub fn probe(path: &str, image_sequence: bool) -> Result<Value, String> {
});
return Ok(v);
}
// Native SDK sources need the local reader, and camera RAW files must not be read whole.
if filmcraft_codecs::r3d::is_r3d(path) {
let reader: filmcraft_media::SharedReader =
Arc::new(filmcraft_media::reader::FileReader::open(std::path::Path::new(path)).map_err(|e| format!("{path}: {e}"))?);
let src = filmcraft_media::reader::open_reader(path, reader, &filmcraft_codecs::reader_openers(), &filmcraft_codecs::openers())
.map_err(|e| format!("{path}: {e}"))?;
return serde_json::to_value(src.info()).map_err(|e| e.to_string());
}
let bytes: Arc<[u8]> = std::fs::read(path).map_err(|e| format!("{path}: {e}"))?.into();
let name = std::path::Path::new(path).file_name().map(|s| s.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string());
let head = &bytes[..bytes.len().min(70_000)];
Expand Down
1 change: 1 addition & 0 deletions apps/filmcraft/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ workspace = true

[features]
default = []
red-sdk = ["filmcraft-platform/red-sdk"]
# Speech-to-text (Text panel ▸ Transcribe) with Whisper on the CPU, and model downloads.
whisper = ["filmcraft-engine/whisper", "filmcraft-engine/speech-download"]
# Natural Text to Speech voices (Kokoro-82M on the CPU, downloaded on first use).
Expand Down
2 changes: 1 addition & 1 deletion crates/codecs/src/gop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -556,7 +556,7 @@ impl GopCache {
/// container's clean aperture and then its display rotation are applied.
fn finish(&self, mut f: VideoFrame) -> VideoFrame {
if let Some(c) = self.explicit_color
&& !matches!(f.data, filmcraft_frame::PixelData::Rgba8(_) | filmcraft_frame::PixelData::RgbaF32(_))
&& !matches!(f.data, filmcraft_frame::PixelData::Rgba8(_) | filmcraft_frame::PixelData::RgbaF32(_) | filmcraft_frame::PixelData::RgbaF16(_))
{
f.color = c;
}
Expand Down
4 changes: 3 additions & 1 deletion crates/codecs/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ pub mod mp4;
pub mod mpeg;
pub mod mxf;
pub mod ogg;
pub mod r3d;
mod stream_color;
pub mod video;
mod webm_alpha;
Expand Down Expand Up @@ -133,13 +134,14 @@ pub fn software_video_decoder(entry: &filmcraft_isobmff::SampleEntry) -> Result<
/// Openers for the engine's media pool (MP4/MOV, Matroska/WebM, MXF, Ogg Opus/Vorbis, MPEG TS/PS and
/// MPEG-1/2 video elementary streams, APV raw bitstreams, YUV4MPEG2, standalone audio).
pub fn openers() -> Vec<filmcraft_media::Opener> {
vec![mp4::opener, mkv::opener, mxf::opener, ogg::opener, mpeg::opener, apv::opener, avi::opener, y4m::opener, audio::opener]
vec![r3d::opener, mp4::opener, mkv::opener, mxf::opener, ogg::opener, mpeg::opener, apv::opener, avi::opener, y4m::opener, audio::opener]
}

fn reader_registry() -> &'static RwLock<Vec<filmcraft_media::ReaderOpener>> {
static R: std::sync::OnceLock<RwLock<Vec<filmcraft_media::ReaderOpener>>> = std::sync::OnceLock::new();
R.get_or_init(|| {
RwLock::new(vec![
r3d::reader_opener,
mp4::reader_opener,
mkv::reader_opener,
mxf::reader_opener,
Expand Down
52 changes: 52 additions & 0 deletions crates/codecs/src/r3d.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
//! Recognise R3D imports even in builds without the optional native SDK backend.
use filmcraft_media::{MediaError, Result, SharedReader, SharedSource};
use std::{path::Path, sync::Arc};

pub fn is_r3d(name: &str) -> bool {
Path::new(name).extension().is_some_and(|s| s.eq_ignore_ascii_case("r3d"))
}

fn unavailable() -> Result<SharedSource> {
Err(MediaError::Unsupported(
"R3D / Nikon ZR R3D NE requires a native FilmCraft build with the red-sdk feature, RED SDK 9.2.1, and a local file. See docs/r3d-ne.md.".into(),
))
}

pub fn opener(name: &str, _: Arc<[u8]>) -> Option<Result<SharedSource>> {
is_r3d(name).then(unavailable)
}

pub fn reader_opener(name: &str, _: &[u8], _: &SharedReader) -> Option<Result<SharedSource>> {
is_r3d(name).then(unavailable)
}

#[cfg(test)]
mod tests {
use super::*;
#[test]
fn disabled_backend_does_not_read_a_large_raw_file_into_memory() {
struct Large;
impl filmcraft_media::ByteReader for Large {
fn len(&self) -> u64 {
10 * 1024 * 1024 * 1024
}
fn read_at(&self, _: u64, buf: &mut [u8]) -> std::io::Result<()> {
assert!(buf.len() <= filmcraft_media::reader::HEAD_LEN);
buf.fill(0);
Ok(())
}
}
let result = filmcraft_media::reader::open_reader("DSC_0001.R3D", Arc::new(Large), &[reader_opener], &[opener]);
assert!(matches!(result, Err(MediaError::Unsupported(message)) if message.contains("red-sdk")));
}
#[test]
fn recognises_case_without_claiming_other_files() {
for name in ["DSC_0001.R3D", "DSC_0002.r3d", "a.R3d"] {
assert!(is_r3d(name));
assert!(opener(name, Arc::from([])).unwrap().is_err());
}
for name in ["a.mov", "a.nev", "a.r3d.mov", "r3d", ""] {
assert!(!is_r3d(name));
}
}
}
2 changes: 1 addition & 1 deletion crates/codecs/src/webm_alpha.rs
Original file line number Diff line number Diff line change
Expand Up @@ -233,7 +233,7 @@ pub(crate) fn set_alpha(frame: &mut VideoFrame, a: &VideoFrame) {
p[3] = rescale(v, 8) as u8;
}
}
PixelData::RgbaF32(_) => {}
PixelData::RgbaF32(_) | PixelData::RgbaF16(_) => {}
}
}

Expand Down
2 changes: 2 additions & 0 deletions crates/color/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ for limited- and full-range files.
| DJI D-Log | D-Gamut | DJI, *White Paper on D-Log and D-Gamut of DJI Cinema Color System* (2017) | CV | y ≤ 0.14: x = (y − 0.0929)/6.025; else x = (10^(3.89616y − 2.27752) − 0.0108)/0.9892 |
| Fujifilm F-Log | F-Gamut (= BT.2020 primaries) | Fujifilm, *GFX ETERNA Technical Guide – White Paper* v1.01 (2026), F-Log conversion formula (same as the F-Log data sheet) | CV | y ≥ cut2: x = 10^((y − d)/c)/a − b/a; else x = (y − f)/e; a 0.555556, b 0.009468, c 0.344676, d 0.790453, e 8.735631, f 0.092864, cut1 0.00089, cut2 0.100537775223865 |
| Fujifilm F-Log2 / F-Log2 C | F-Gamut / F-Gamut C (R 0.7347, 0.2653; G 0.0263, 0.9737; B 0.1173, −0.0224) | same document, F-Log2 C / F-Log2 conversion formula | CV | same form; a 5.555556, b 0.064829, c 0.245281, d 0.384316, e 8.799461, f 0.092864, cut1 0.000889, cut2 0.100686685370811 |
| RED Log3G10 | REDWideGamutRGB | RED, *White Paper on REDWideGamutRGB and Log3G10* (Rev B, 2017; shipped with the R3D SDK) | CV (full-scale float) | y < 0: x = y/15.1927 − 0.01; else x = (10^(y/0.224282) − 1)/155.975327 − 0.01 (18 % → 1/3, 184.32 → 1.0) |

The encoders are the exact inverses given in the same documents. DJI **D-Log M** is not
implemented: DJI has not published its formula (only conversion LUTs), and we do not derive curves
Expand All @@ -60,6 +61,7 @@ Gamut primaries (all D65 white, x/y):
| ARRI Wide Gamut 3 | 0.6840, 0.3130 | 0.2210, 0.8480 | 0.0861, −0.1020 | ARRI Log C document |
| ARRI Wide Gamut 4 | 0.7347, 0.2653 | 0.1424, 0.8576 | 0.0991, −0.0308 | ARRI LogC4 specification |
| D-Gamut | 0.71, 0.31 | 0.21, 0.88 | 0.09, −0.08 | DJI white paper |
| REDWideGamutRGB | 0.780308, 0.304253 | 0.121595, 1.493994 | 0.095612, −0.084589 | RED white paper |

Matrices are derived from the chromaticities (SMPTE RP 177 normalised primary matrix); tests check
the result against the published BT.709 RGB→XYZ and BT.2087 BT.709→BT.2020 matrices.
Expand Down
32 changes: 31 additions & 1 deletion crates/color/src/log.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,10 +46,12 @@ pub enum LogCurve {
FLog,
/// Fujifilm F-Log2 (also the curve of F-Log2 C).
FLog2,
/// RED Log3G10 (18 % grey at 1/3, 10 stops above grey to 1.0).
Log3G10,
}

impl LogCurve {
pub const ALL: [LogCurve; 10] = [
pub const ALL: [LogCurve; 11] = [
LogCurve::SLog3,
LogCurve::VLog,
LogCurve::CLog2,
Expand All @@ -60,6 +62,7 @@ impl LogCurve {
LogCurve::DLog,
LogCurve::FLog,
LogCurve::FLog2,
LogCurve::Log3G10,
];

pub fn domain(self) -> Domain {
Expand All @@ -81,6 +84,7 @@ impl LogCurve {
LogCurve::DLog => "DJI D-Log",
LogCurve::FLog => "Fujifilm F-Log",
LogCurve::FLog2 => "Fujifilm F-Log2",
LogCurve::Log3G10 => "RED Log3G10",
}
}

Expand All @@ -97,6 +101,7 @@ impl LogCurve {
LogCurve::DLog => dlog_encode(x),
LogCurve::FLog => fujifilm_encode(&FLOG, x),
LogCurve::FLog2 => fujifilm_encode(&FLOG2, x),
LogCurve::Log3G10 => log3g10_encode(x),
}
}

Expand All @@ -113,6 +118,7 @@ impl LogCurve {
LogCurve::DLog => dlog_decode(y),
LogCurve::FLog => fujifilm_decode(&FLOG, y),
LogCurve::FLog2 => fujifilm_decode(&FLOG2, y),
LogCurve::Log3G10 => log3g10_decode(y),
}
}

Expand Down Expand Up @@ -311,6 +317,22 @@ fn fujifilm_decode(k: &Fujifilm, y: f64) -> f64 {
if y >= k.cut2 { 10f64.powf((y - k.d) / k.c) / k.a - k.b / k.a } else { (y - k.f) / k.e }
}

// RED, "White Paper on REDWideGamutRGB and Log3G10" (Rev B, 2017): a = 0.224282, b = 155.975327,
// offset c = 0.01, and the linear extension below −c with the curve's gradient at zero, g = 15.1927.
// The signal is the full-scale float value (18 % grey → 1/3, 184.32 → 1.0).
const LOG3G10_A: f64 = 0.224_282;
const LOG3G10_B: f64 = 155.975_327;
const LOG3G10_C: f64 = 0.01;
const LOG3G10_G: f64 = 15.1927;

pub fn log3g10_encode(x: f64) -> f64 {
let x = x + LOG3G10_C;
if x < 0.0 { x * LOG3G10_G } else { LOG3G10_A * (x * LOG3G10_B + 1.0).log10() }
}
pub fn log3g10_decode(y: f64) -> f64 {
if y < 0.0 { y / LOG3G10_G - LOG3G10_C } else { (10f64.powf(y / LOG3G10_A) - 1.0) / LOG3G10_B - LOG3G10_C }
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -376,6 +398,14 @@ mod tests {
// the decoding cut is where the encoding's two pieces meet (within the published rounding)
close(LogCurve::FLog.encode(0.000_89), 0.100_537_775_223_865, 2e-4, "F-Log cut");
close(LogCurve::FLog2.encode(0.000_889), 0.100_686_685_370_811, 2e-4, "F-Log2 cut");
// RED Log3G10 mapping table: −0.01 → 0, 0 → 0.091551, 0.18 → 1/3, 1.0 → 0.493449, 184.322 → 1.0.
close(LogCurve::Log3G10.encode(-0.01), 0.0, 1e-12, "Log3G10 -1%");
close(LogCurve::Log3G10.encode(0.0), 0.091_551, 1e-6, "Log3G10 0");
close(LogCurve::Log3G10.encode(0.18), 1.0 / 3.0, 1e-6, "Log3G10 18%");
close(LogCurve::Log3G10.encode(1.0), 0.493_449, 1e-6, "Log3G10 100%");
close(LogCurve::Log3G10.encode(184.322), 1.0, 1e-6, "Log3G10 peak");
close(LogCurve::Log3G10.decode(1.0), 184.322, 0.001, "Log3G10 peak linear");
close(LogCurve::Log3G10.decode(-0.1), -0.1 / 15.1927 - 0.01, 1e-12, "Log3G10 linear toe");
}

#[test]
Expand Down
12 changes: 11 additions & 1 deletion crates/color/src/spaces.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ pub enum Gamut {
DGamut,
/// Fujifilm F-Gamut C (F-Log2 C). F-Gamut itself has the BT.2020 primaries.
FGamutC,
/// REDWideGamutRGB.
Rwg,
}

impl Gamut {
Expand All @@ -54,6 +56,7 @@ impl Gamut {
Gamut::Awg4 => [[0.7347, 0.2653], [0.1424, 0.8576], [0.0991, -0.0308]],
Gamut::DGamut => [[0.71, 0.31], [0.21, 0.88], [0.09, -0.08]],
Gamut::FGamutC => [[0.7347, 0.2653], [0.0263, 0.9737], [0.1173, -0.0224]],
Gamut::Rwg => [[0.780_308, 0.304_253], [0.121_595, 1.493_994], [0.095_612, -0.084_589]],
}
}

Expand Down Expand Up @@ -174,12 +177,14 @@ pub enum ColorSpace {
FLog2FGamut,
/// Fujifilm F-Log2 C / F-Gamut C.
FLog2CFGamutC,
/// RED Log3G10 in REDWideGamutRGB (the R3D SDK's IPP2 grading output, also RED's log exports).
Log3G10Rwg,
/// Linear light with BT.709 primaries (EXR-style float sources).
Linear709,
}

impl ColorSpace {
pub const ALL: [ColorSpace; 19] = [
pub const ALL: [ColorSpace; 20] = [
ColorSpace::Rec709,
ColorSpace::Srgb,
ColorSpace::Rec2020,
Expand All @@ -198,6 +203,7 @@ impl ColorSpace {
ColorSpace::FLogFGamut,
ColorSpace::FLog2FGamut,
ColorSpace::FLog2CFGamutC,
ColorSpace::Log3G10Rwg,
ColorSpace::Linear709,
];

Expand All @@ -222,6 +228,7 @@ impl ColorSpace {
ColorSpace::FLogFGamut => "flog-fgamut",
ColorSpace::FLog2FGamut => "flog2-fgamut",
ColorSpace::FLog2CFGamutC => "flog2c-fgamutc",
ColorSpace::Log3G10Rwg => "log3g10-rwg",
ColorSpace::Linear709 => "linear709",
}
}
Expand All @@ -246,6 +253,7 @@ impl ColorSpace {
ColorSpace::FLogFGamut => "Fujifilm F-Log / F-Gamut",
ColorSpace::FLog2FGamut => "Fujifilm F-Log2 / F-Gamut",
ColorSpace::FLog2CFGamutC => "Fujifilm F-Log2 C / F-Gamut C",
ColorSpace::Log3G10Rwg => "RED Log3G10 / REDWideGamutRGB",
ColorSpace::Linear709 => "Linear (Rec. 709 primaries)",
}
}
Expand All @@ -272,6 +280,7 @@ impl ColorSpace {
ColorSpace::LogC4Awg4 => Gamut::Awg4,
ColorSpace::DLogDGamut => Gamut::DGamut,
ColorSpace::FLog2CFGamutC => Gamut::FGamutC,
ColorSpace::Log3G10Rwg => Gamut::Rwg,
}
}

Expand All @@ -292,6 +301,7 @@ impl ColorSpace {
ColorSpace::DLogDGamut => Curve::Log(LogCurve::DLog),
ColorSpace::FLogFGamut => Curve::Log(LogCurve::FLog),
ColorSpace::FLog2FGamut | ColorSpace::FLog2CFGamutC => Curve::Log(LogCurve::FLog2),
ColorSpace::Log3G10Rwg => Curve::Log(LogCurve::Log3G10),
}
}

Expand Down
Loading
Loading