diff --git a/crates/engine/src/clip_ops.rs b/crates/engine/src/clip_ops.rs
--- a/crates/engine/src/clip_ops.rs
+++ b/crates/engine/src/clip_ops.rs
@@ -691,7 +691,7 @@ pub(crate) fn item_range(p: &Project, id: ItemId, still: Tick) -> Option<TimeRan
     Some(match &it.kind {
         ItemKind::Media(m) => {
             let is_still = matches!(m.info.kind, filmcraft_media::MediaKind::Still);
-            let full = if m.info.duration.0 > 0 && !is_still { m.info.duration } else { still };
+            let full = if m.info.duration.0 > 0 && !is_still { m.duration() } else { still };
             let a = m.mark_in.unwrap_or(Tick::ZERO);
             let b = m.mark_out.map(|o| o + rate.frame_duration()).unwrap_or(full);
             TimeRange::from_bounds(a, b.max(a + rate.frame_duration()))
@@ -1125,7 +1125,7 @@ fn subclip_plan(s: &Session, p: &Value) -> Result<(ItemId, TimeRange, String, La
         let rate = m.frame_rate();
         let still = s.prefs.timeline.still_duration(rate);
         let is_still = matches!(m.info.kind, filmcraft_media::MediaKind::Still);
-        let full = sub.unwrap_or(TimeRange::new(Tick::ZERO, if m.info.duration.0 > 0 && !is_still { m.info.duration } else { still }));
+        let full = sub.unwrap_or(TimeRange::new(Tick::ZERO, if m.info.duration.0 > 0 && !is_still { m.duration() } else { still }));
         let range = if sub.is_some() {
             full
         } else {
@@ -1628,7 +1628,7 @@ fn extract_audio(s: &mut Session, p: &Value) -> Result<Value> {
     for item in items {
         let (root, m, sub) = media_root(&s.project, item).ok_or_else(|| bad("clip.extractAudio", "not a media clip"))?;
         let a = m.info.audio().cloned().ok_or_else(|| bad("clip.extractAudio", "the clip has no audio"))?;
-        let range = sub.unwrap_or(TimeRange::new(Tick::ZERO, m.info.duration));
+        let range = sub.unwrap_or(TimeRange::new(Tick::ZERO, m.duration()));
         let dir = match str_p(p, "dir") {
             Some(d) => d.to_string(),
             None => s
diff --git a/crates/engine/src/color.rs b/crates/engine/src/color.rs
--- a/crates/engine/src/color.rs
+++ b/crates/engine/src/color.rs
@@ -10,7 +10,7 @@
 //! renders a folder's thumbnail grid (our Lumetri on the procedural preview picture) to a PNG.
 
 use filmcraft_color::{ColorSpace, Lut, LutFormat, WorkingSpace};
-use filmcraft_project::{ClipId, ItemId, ItemKind, ParamValue, ProjectLut, TrackKind};
+use filmcraft_project::{ClipId, ItemId, ItemKind, MediaClip, ParamValue, ProjectLut, TrackKind};
 use serde_json::{Value, json};
 
 use crate::commands::{CommandSpec, always, bad, bool_p, clip_p, has_seq, item_p, str_p, time_p};
@@ -40,7 +40,7 @@ pub(crate) fn commands() -> Vec<CommandSpec> {
             "clip.interpretFootage",
             "Interpret Footage…",
             &["Clip", "Modify"],
-            r#"{"items":[id]?,"colorSpace":"auto"|"<color space id>"?,"pixelAspect":[num,den]|"file"?}"#,
+            r#"{"items":[id]?,"colorSpace":"auto"|"<color space id>"?,"pixelAspect":[num,den]|"file"?,"frameRate":fps|"num/den"|"file"?,"ignoreAlpha":bool?,"invertAlpha":bool?}"#,
             has_footage,
             interpret,
         ),
@@ -295,9 +295,12 @@ pub(crate) fn footage_targets(s: &Session, p: &Value) -> Vec<ItemId> {
     v
 }
 
-/// `clip.interpretFootage`: the colour space (`colorSpace`) and / or the pixel aspect ratio
-/// (`pixelAspect`) the footage is interpreted with. `"auto"` / `"file"` go back to the file's
-/// metadata; a parameter left out keeps its current interpretation.
+/// `clip.interpretFootage`: the colour space (`colorSpace`), the pixel aspect ratio
+/// (`pixelAspect`), the frame rate (`frameRate`, Assume this frame rate) and / or the alpha
+/// channel (`ignoreAlpha`, `invertAlpha`) the footage is interpreted with. `"auto"` / `"file"` go back to the file's metadata; a parameter left out
+/// keeps its current interpretation. A new frame rate conforms the footage
+/// ([`filmcraft_project::Conform`]) and keeps every clip, mark and keyframe on its frame
+/// ([`filmcraft_project::Project::retime_media`]).
 fn interpret(s: &mut Session, p: &Value) -> Result<Value> {
     const CMD: &str = "clip.interpretFootage";
     let items = footage_targets(s, p);
@@ -317,8 +320,23 @@ fn interpret(s: &mut Session, p: &Value) -> Result<Value> {
                 bad(CMD, format!("pixelAspect must be [num, den] between 1:{0} and {0}:1, or \"file\"", filmcraft_project::MAX_PIXEL_ASPECT))
             })?)),
         };
-    if cs.is_none() && par.is_none() {
-        return Err(bad(CMD, "need `colorSpace` (auto or a colour space id) or `pixelAspect`"));
+    let rate = match p.get("frameRate") {
+        None | Some(Value::Null) => None,
+        Some(Value::String(f)) if f == "file" || f == "auto" => Some(None),
+        Some(v) => Some(Some(
+            crate::export_tools::parse_rate(v)
+                .filter(|r| r.is_usable())
+                .ok_or_else(|| bad(CMD, "frameRate must be a rate in frames per second (25, 29.97) or \"num/den\", up to 1000, or \"file\""))?,
+        )),
+    };
+    let flag = |k: &str| match p.get(k) {
+        None | Some(Value::Null) => Ok(None),
+        Some(Value::Bool(b)) => Ok(Some(*b)),
+        Some(_) => Err(bad(CMD, format!("`{k}` must be true or false"))),
+    };
+    let (ignore_alpha, invert_alpha) = (flag("ignoreAlpha")?, flag("invertAlpha")?);
+    if cs.is_none() && par.is_none() && rate.is_none() && ignore_alpha.is_none() && invert_alpha.is_none() {
+        return Err(bad(CMD, "need `colorSpace` (auto or a colour space id), `pixelAspect`, `frameRate`, `ignoreAlpha` or `invertAlpha`"));
     }
     s.edit("Interpret Footage", |pr, _| {
         for i in &items {
@@ -329,6 +347,25 @@ fn interpret(s: &mut Session, p: &Value) -> Result<Value> {
                 if let Some(par) = par {
                     m.interpret.par = par;
                 }
+                if let Some(b) = ignore_alpha {
+                    m.interpret.ignore_alpha = b;
+                }
+                if let Some(b) = invert_alpha {
+                    m.interpret.invert_alpha = b;
+                }
+            }
+            if let Some(rate) = rate {
+                let Some(m) = pr.item_mut(*i).and_then(|it| it.as_media_mut()) else { continue };
+                // stills and sound have no frame rate to assume (several items may be mixed)
+                if m.info.video.is_none() || matches!(m.info.kind, filmcraft_media::MediaKind::Still) {
+                    continue;
+                }
+                // the rate the footage plays at before and after (a still or sound has none to conform)
+                let playing = |m: &MediaClip| m.conform().map_or(m.info.frame_rate(), |c| c.assumed);
+                let from = playing(m);
+                m.interpret.frame_rate = rate;
+                let to = playing(m);
+                pr.retime_media(*i, from, to);
             }
         }
         Ok(())
@@ -338,6 +375,10 @@ fn interpret(s: &mut Session, p: &Value) -> Result<Value> {
         "items": items.iter().map(|i| i.0).collect::<Vec<_>>(),
         "colorSpace": first.and_then(|m| m.interpret.color_space).map(|c| c.id()).unwrap_or("auto"),
         "pixelAspect": first.map(|m| m.pixel_aspect()),
+        "frameRate": first.map(|m| m.frame_rate().label()),
+        "fileFrameRate": first.map(|m| m.info.frame_rate().label()),
+        "ignoreAlpha": first.is_some_and(|m| m.interpret.ignore_alpha),
+        "invertAlpha": first.is_some_and(|m| m.interpret.invert_alpha),
     }))
 }
 
diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs
--- a/crates/engine/src/commands.rs
+++ b/crates/engine/src/commands.rs
@@ -696,9 +696,9 @@ pub(crate) fn default_seq_settings_for(media: &MediaClip) -> SequenceSettings {
     if let Some(v) = &info.video {
         st.width = v.width;
         st.height = v.height;
-        st.frame_rate = v.frame_rate;
+        st.frame_rate = media.frame_rate();
         st.par = media.pixel_aspect();
-        st.preset = format!("{}x{} {}", v.width, v.height, v.frame_rate.label());
+        st.preset = format!("{}x{} {}", v.width, v.height, media.frame_rate().label());
     }
     if let Some(a) = info.audio() {
         st.sample_rate = a.sample_rate.max(8000);
diff --git a/crates/engine/src/conform_tests.rs b/crates/engine/src/conform_tests.rs
new file mode 100644
--- /dev/null
+++ b/crates/engine/src/conform_tests.rs
@@ -0,0 +1,262 @@
+//! Interpret Footage ▸ Assume this frame rate: footage conformed to another rate plays every frame
+//! of the file at the assumed rate (picture and sound), lasts accordingly, and keeps its clips,
+//! marks, markers and keyframes on their frames; `"file"` and undo go back. Alpha Channel ▸
+//! Ignore / Invert change the pictures of footage with alpha.
+
+use std::sync::{Arc, Mutex};
+
+use serde_json::json;
+
+use crate::Session;
+use filmcraft_frame::{AudioBuffer, VideoFrame};
+use filmcraft_media::{AudioStreamInfo, FrameRequest, MediaInfo, MediaKind, MediaSource};
+use filmcraft_project::{ClipId, ItemId, ItemKind, Keyframe, Label, MediaClip, MediaRef, Project, SequenceSettings, TrackKind};
+use filmcraft_time::{FrameRate, TICKS_PER_SECOND, Tick, TimeRange};
+
+const FILE_RATE: FrameRate = FrameRate::FPS_50;
+
+fn secs(s: f64) -> Tick {
+    Tick::from_seconds_f64(s)
+}
+
+/// Two seconds of 50 fps footage: frame `k` is grey level `2k` at alpha 64, its sound is a ramp
+/// of the file time in seconds. It logs the file times it is asked for.
+struct Counter {
+    info: MediaInfo,
+    asked: Arc<Mutex<Vec<Tick>>>,
+}
+
+impl MediaSource for Counter {
+    fn info(&self) -> &MediaInfo {
+        &self.info
+    }
+    fn video_frame(&self, req: FrameRequest) -> filmcraft_media::Result<Arc<VideoFrame>> {
+        self.asked.lock().unwrap().push(req.time);
+        let v = (FILE_RATE.frame_at(req.time) * 2).clamp(0, 255) as u8;
+        Ok(Arc::new(VideoFrame::rgba8(8, 8, [v, v, v, 64].repeat(64))))
+    }
+    fn audio(&self, start: i64, frames: usize, sample_rate: u32) -> filmcraft_media::Result<AudioBuffer> {
+        let ramp = (0..frames).map(|i| (start + i as i64) as f32 / sample_rate as f32).collect();
+        Ok(AudioBuffer { sample_rate, channels: vec![ramp] })
+    }
+}
+
+struct Setup {
+    s: Session,
+    item: ItemId,
+    clip: ClipId,
+    audio: ClipId,
+    asked: Arc<Mutex<Vec<Tick>>>,
+}
+
+/// The footage with a mark In at 0.4 s (frame 20) and a marker at 1 s (frame 50), cut into a
+/// 25 fps sequence at 0 from 0.2 s (frame 10) for one second, picture and sound, with an Opacity
+/// keyframe at 0.6 s (frame 30).
+fn setup() -> Setup {
+    let g = filmcraft_media::generators::GeneratorSource::new(filmcraft_media::Generator::ColorMatte { color: [0.5; 4] }, 8, 8, FILE_RATE, secs(2.0));
+    let mut info = g.info().clone();
+    info.kind = MediaKind::Movie;
+    info.audio_streams = vec![AudioStreamInfo { sample_rate: 48_000, channels: 1, codec: "pcm".into(), bits_per_sample: None }];
+    let mut p = Project::new("conform");
+    let marker = filmcraft_project::Marker {
+        id: filmcraft_project::MarkerId(900),
+        start: secs(1.0),
+        duration: Tick::ZERO,
+        name: "frame 50".into(),
+        comment: String::new(),
+        kind: Default::default(),
+        color: Label::Iris,
+    };
+    let media = MediaClip {
+        media: MediaRef::Generator(g.generator.clone()),
+        info: info.clone(),
+        interpret: Default::default(),
+        mark_in: Some(secs(0.4)),
+        mark_out: None,
+        markers: vec![marker],
+        offline: false,
+        proxy: None,
+        identity: None,
+    };
+    let item = p.add_item("counter", Label::Iris, ItemKind::Media(media), None);
+    let rate = FrameRate::FPS_25;
+    let seq = p.new_sequence("s", SequenceSettings { width: 8, height: 8, frame_rate: rate, ..Default::default() }, 1, 1, None);
+    let range = TimeRange::new(secs(0.2), secs(1.0));
+    let mut v = p.make_track_item(item, TrackKind::Video, Tick::ZERO, range, rate).unwrap();
+    v.effect_mut("opacity").unwrap().params.get_mut("opacity").unwrap().keyframes =
+        vec![Keyframe::new(secs(0.6), filmcraft_project::ParamValue::Float(50.0)), Keyframe::new(secs(1.1), filmcraft_project::ParamValue::Float(100.0))];
+    let a = p.make_track_item(item, TrackKind::Audio, Tick::ZERO, range, rate).unwrap();
+    let (clip, audio) = (v.id, a.id);
+    let q = p.sequence_mut(seq).unwrap();
+    q.video_tracks[0].items.push(v);
+    q.audio_tracks[0].items.push(a);
+    let mut s = Session { project: Arc::new(p), ..Default::default() };
+    s.state.active_sequence = Some(seq);
+    let asked = Arc::new(Mutex::new(Vec::new()));
+    // the file has alpha; the project item says so only where a test wants it
+    if let Some(v) = info.video.as_mut() {
+        v.has_alpha = true;
+    }
+    s.media.insert(item, Arc::new(Counter { info, asked: asked.clone() }));
+    Setup { s, item, clip, audio, asked }
+}
+
+fn media(s: &Session, item: ItemId) -> MediaClip {
+    s.project.item(item).unwrap().as_media().unwrap().clone()
+}
+
+fn track_item(s: &Session, clip: ClipId) -> filmcraft_project::TrackItem {
+    let q = s.active_sequence().unwrap();
+    q.video_tracks.iter().chain(&q.audio_tracks).flat_map(|t| &t.items).find(|t| t.id == clip).unwrap().clone()
+}
+
+/// The file time the decoder is asked for when the Program monitor shows sequence time `t`.
+fn program_file_time(su: &mut Setup, t: Tick) -> Tick {
+    su.asked.lock().unwrap().clear();
+    su.s.render_program_at(1.0, t).unwrap();
+    *su.asked.lock().unwrap().last().expect("the clip's frame was decoded")
+}
+
+#[test]
+fn assumed_rate_conforms_picture_sound_and_every_media_time() {
+    let mut su = setup();
+    // at the file's rate: sequence frame 2 (0.08 s) shows file frame 14 (0.2 s + 0.08 s)
+    assert_eq!(FILE_RATE.frame_at(program_file_time(&mut su, secs(0.08))), 14);
+    let r = su.s.execute("clip.interpretFootage", json!({"items": [su.item.0], "frameRate": 25})).unwrap();
+    assert_eq!((r["frameRate"].as_str(), r["fileFrameRate"].as_str()), (Some("25"), Some("50")), "{r}");
+    let m = media(&su.s, su.item);
+    assert_eq!((m.interpret.frame_rate, m.frame_rate()), (Some(FrameRate::FPS_25), FrameRate::FPS_25));
+    // 100 frames at 25 fps: twice as long, with marks and markers on the same frames
+    assert_eq!(m.duration(), secs(4.0));
+    assert_eq!(m.mark_in, Some(secs(0.8)));
+    assert_eq!(m.markers[0].start, secs(2.0));
+    // the clips keep their place and length on the timeline and start on frame 10 (now at 0.4 s);
+    // the keyframes stay on frames 30 and 55
+    for c in [su.clip, su.audio] {
+        let ti = track_item(&su.s, c);
+        assert_eq!((ti.start, ti.duration, ti.source_in), (Tick::ZERO, secs(1.0), secs(0.4)));
+    }
+    let kf: Vec<Tick> = track_item(&su.s, su.clip).effect("opacity").unwrap().params["opacity"].keyframes.iter().map(|k| k.time).collect();
+    assert_eq!(kf, [secs(1.2), secs(2.2)]);
+    // the picture plays at half speed: sequence frames 2 and 3 show file frames 12 and 13
+    assert_eq!(FILE_RATE.frame_at(program_file_time(&mut su, secs(0.08))), 12);
+    assert_eq!(FILE_RATE.frame_at(program_file_time(&mut su, secs(0.12))), 13);
+    // the source reports the conformed duration and rate, and every frame boundary lands on its frame
+    let src = su.s.media.source_for(&su.s.project, su.item, &*su.s.services).unwrap();
+    assert_eq!((src.info().duration, src.info().frame_rate()), (secs(4.0), FrameRate::FPS_25));
+    for k in 0..100 {
+        let f = src.video_frame(FrameRequest::full(FrameRate::FPS_25.tick_of(k))).unwrap();
+        assert_eq!(i64::from(f.to_rgba8()[0]), 2 * k, "frame {k}");
+    }
+    // the sound is conformed with it: one second in plays the file's half second, at 48 kHz
+    let a = src.audio(48_000, 4, 48_000).unwrap();
+    assert_eq!(a.sample_rate, 48_000);
+    assert!((a.channels[0][0] - 0.5).abs() < 1e-6, "{:?}", a.channels[0]);
+    // the Source monitor and New Sequence From Clip see the assumed rate
+    su.s.execute("file.newSequenceFromClip", json!({"items": [su.item.0]})).unwrap();
+    assert_eq!(su.s.active_sequence().unwrap().settings.frame_rate, FrameRate::FPS_25);
+}
+
+#[test]
+fn file_rate_and_undo_bring_every_frame_back() {
+    let mut su = setup();
+    // compared as JSON: the auto anchor and position are NaN, which never equals itself
+    let json_of = |s: &Session| serde_json::to_string(&*s.project).unwrap();
+    let before = json_of(&su.s);
+    su.s.execute("clip.interpretFootage", json!({"items": [su.item.0], "frameRate": "24000/1001"})).unwrap();
+    assert_eq!(media(&su.s, su.item).frame_rate(), FrameRate::FPS_23_976);
+    // undo restores the project exactly
+    su.s.execute("edit.undo", json!({})).unwrap();
+    assert_eq!(json_of(&su.s), before);
+    // "file" maps every time back onto the file's frames
+    su.s.execute("clip.interpretFootage", json!({"items": [su.item.0], "frameRate": 25})).unwrap();
+    su.s.execute("clip.interpretFootage", json!({"items": [su.item.0], "frameRate": "file"})).unwrap();
+    let m = media(&su.s, su.item);
+    assert_eq!((m.interpret.frame_rate, m.duration(), m.mark_in), (None, secs(2.0), Some(secs(0.4))));
+    let ti = track_item(&su.s, su.clip);
+    assert_eq!((ti.duration, ti.source_in), (secs(1.0), secs(0.2)));
+    assert_eq!(FILE_RATE.frame_at(program_file_time(&mut su, secs(0.08))), 14);
+}
+
+#[test]
+fn a_faster_rate_shortens_clips_that_would_run_past_the_end() {
+    let mut su = setup();
+    // 100 frames at 100 fps last one second; the clip starts on frame 10 (0.1 s) and would run to 1.1 s
+    su.s.execute("clip.interpretFootage", json!({"items": [su.item.0], "frameRate": 100})).unwrap();
+    assert_eq!(media(&su.s, su.item).duration(), secs(1.0));
+    for c in [su.clip, su.audio] {
+        let ti = track_item(&su.s, c);
+        assert_eq!((ti.source_in, ti.duration), (secs(0.1), secs(0.9)));
+        assert!(ti.source_out() <= secs(1.0));
+    }
+}
+
+#[test]
+fn hostile_frame_rates_are_refused_and_change_nothing() {
+    let mut su = setup();
+    let rev = su.s.revision;
+    for bad in [json!(0), json!(-25), json!(1001), json!(1e300), json!("fast"), json!("25/0"), json!("0/1"), json!("-25/1"), json!([25, 1]), json!({"fps": 25})]
+    {
+        assert!(su.s.execute("clip.interpretFootage", json!({"items": [su.item.0], "frameRate": bad})).is_err(), "{bad}");
+    }
+    assert_eq!((su.s.revision, media(&su.s, su.item).interpret.frame_rate), (rev, None));
+    // the same rate as the file's changes no time
+    su.s.execute("clip.interpretFootage", json!({"items": [su.item.0], "frameRate": 50})).unwrap();
+    let m = media(&su.s, su.item);
+    assert_eq!((m.conform(), m.duration(), track_item(&su.s, su.clip).source_in), (None, secs(2.0), secs(0.2)));
+    assert_eq!(Tick(TICKS_PER_SECOND), secs(1.0));
+}
+
+#[test]
+fn ignore_and_invert_alpha_change_footage_with_alpha() {
+    let mut su = setup();
+    let alpha_of = |su: &Setup| {
+        let src = su.s.media.source_for(&su.s.project, su.item, &*su.s.services).unwrap();
+        (src.video_frame(FrameRequest::full(Tick::ZERO)).unwrap().to_rgba8()[3], src.info().video.as_ref().unwrap().has_alpha)
+    };
+    // footage without alpha: the options change nothing
+    su.s.execute("clip.interpretFootage", json!({"items": [su.item.0], "invertAlpha": true})).unwrap();
+    assert_eq!(media(&su.s, su.item).alpha(), None);
+    assert_eq!(alpha_of(&su).0, 64);
+    // with alpha: Invert, then Ignore (which wins), then neither
+    let mut p = (*su.s.project).clone();
+    p.item_mut(su.item).and_then(|i| i.as_media_mut()).unwrap().info.video.as_mut().unwrap().has_alpha = true;
+    su.s.project = Arc::new(p);
+    assert_eq!(alpha_of(&su), (191, true));
+    let r = su.s.execute("clip.interpretFootage", json!({"items": [su.item.0], "ignoreAlpha": true})).unwrap();
+    assert_eq!((r["ignoreAlpha"].as_bool(), r["invertAlpha"].as_bool()), (Some(true), Some(true)), "{r}");
+    assert_eq!(alpha_of(&su), (255, false));
+    su.s.execute("clip.interpretFootage", json!({"items": [su.item.0], "ignoreAlpha": false, "invertAlpha": false})).unwrap();
+    assert_eq!(alpha_of(&su), (64, true));
+    // with a conformed rate too
+    su.s.execute("clip.interpretFootage", json!({"items": [su.item.0], "frameRate": 25, "invertAlpha": true})).unwrap();
+    assert_eq!(alpha_of(&su), (191, true));
+    assert!(su.s.execute("clip.interpretFootage", json!({"items": [su.item.0], "ignoreAlpha": "yes"})).is_err());
+}
+
+#[test]
+fn inverting_premultiplied_and_planar_alpha() {
+    use filmcraft_frame::{Chroma, PixelData};
+    use filmcraft_project::AlphaInterpret::{Ignore, Invert};
+    let f = |data| VideoFrame { width: 1, height: 1, data, color: Default::default(), par: (1, 1), pts: Tick::ZERO };
+    let px = |v: &VideoFrame| match &v.data {
+        PixelData::RgbaF32(p) => p.as_ref().clone(),
+        _ => unreachable!(),
+    };
+    // premultiplied 0.5 grey at 25 %: inverted it is the same grey at 75 %
+    let half = f(PixelData::RgbaF32(Arc::new(vec![0.125, 0.125, 0.125, 0.25])));
+    assert_eq!(px(&crate::media_pool::interpret_alpha(&half, Invert)), [0.375, 0.375, 0.375, 0.75]);
+    assert_eq!(px(&crate::media_pool::interpret_alpha(&half, Ignore)), [0.125, 0.125, 0.125, 1.0]);
+    // hostile values stay finite
+    let nan = f(PixelData::RgbaF32(Arc::new(vec![0.5, 0.5, 0.5, f32::NAN])));
+    assert!(px(&crate::media_pool::interpret_alpha(&nan, Invert)).iter().all(|v| v.is_finite()));
+    // a 10-bit alpha plane inverts within 0..=1023; Ignore drops it
+    let y = |a: Vec<u16>| {
+        f(PixelData::Yuv16 { planes: [Arc::new(vec![64]), Arc::new(vec![512]), Arc::new(vec![512])], chroma: Chroma::C444, bits: 10, alpha: Some(Arc::new(a)) })
+    };
+    match crate::media_pool::interpret_alpha(&y(vec![1023]), Invert).data {
+        PixelData::Yuv16 { alpha: Some(a), .. } => assert_eq!(*a, [0]),
+        _ => panic!("no alpha"),
+    }
+    assert!(matches!(crate::media_pool::interpret_alpha(&y(vec![7]), Ignore).data, PixelData::Yuv16 { alpha: None, .. }));
+}
diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs
--- a/crates/engine/src/lib.rs
+++ b/crates/engine/src/lib.rs
@@ -1240,7 +1240,7 @@ pub fn media_duration(p: &Project, _pool: &MediaPool, id: ItemId) -> Option<Tick
         return match &it.kind {
             filmcraft_project::ItemKind::Media(m) => match m.info.kind {
                 filmcraft_media::MediaKind::Still | filmcraft_media::MediaKind::Synthetic => None,
-                _ => Some(m.info.duration),
+                _ => Some(m.duration()),
             },
             filmcraft_project::ItemKind::Sequence(s) => Some(s.duration()),
             // a subclip that restricts trims ends at its Out point; otherwise its parent's media is the limit
@@ -1275,6 +1275,8 @@ mod clip_ops_tests;
 #[cfg(test)]
 mod color_tests;
 #[cfg(test)]
+mod conform_tests;
+#[cfg(test)]
 mod essential_sound_tests;
 #[cfg(test)]
 mod explicit_targets_tests;
diff --git a/crates/engine/src/media_pool.rs b/crates/engine/src/media_pool.rs
--- a/crates/engine/src/media_pool.rs
+++ b/crates/engine/src/media_pool.rs
@@ -19,10 +19,10 @@ use std::path::PathBuf;
 use std::sync::atomic::{AtomicBool, Ordering};
 use std::sync::{Arc, Mutex, RwLock};
 
-use filmcraft_frame::{AudioBuffer, VideoFrame};
+use filmcraft_frame::{AudioBuffer, PixelData, VideoFrame};
 use filmcraft_media::generators::GeneratorSource;
 use filmcraft_media::{FrameRequest, MediaError, MediaInfo, MediaSource, Opener, SharedSource};
-use filmcraft_project::{ItemId, MediaClip, MediaRef, Project};
+use filmcraft_project::{AlphaInterpret, Conform, ItemId, MediaClip, MediaRef, Project};
 use filmcraft_render::offline::OfflineReason;
 
 use crate::Services;
@@ -50,6 +50,8 @@ pub struct MediaPool {
     /// item → (reference key, source)
     sources: RwLock<HashMap<ItemId, (String, SharedSource)>>,
     proxies: RwLock<HashMap<ItemId, (String, SharedSource)>>,
+    /// Interpreted sources ([`InterpretSource`]), per item: the source they wrap and how.
+    interpreted: RwLock<HashMap<ItemId, (SharedSource, Interpret, SharedSource)>>,
     offline: Arc<RwLock<HashMap<ItemId, OfflineStatus>>>,
     use_proxies: AtomicBool,
     /// Per item: video frames that failed in a row in the monitors (see [`Self::monitor_source`]).
@@ -67,6 +69,7 @@ impl Default for MediaPool {
         Self {
             sources: RwLock::new(HashMap::new()),
             proxies: RwLock::new(HashMap::new()),
+            interpreted: RwLock::default(),
             offline: Arc::new(RwLock::new(HashMap::new())),
             use_proxies: AtomicBool::new(false),
             failures: Arc::default(),
@@ -308,7 +311,33 @@ impl MediaPool {
         self.resolve(p, item, services, false)
     }
 
+    /// The item's file as it is, at its own frame rate: never a proxy, never conformed to the rate
+    /// Interpret Footage assumes (proxies are made from it, and conformed when read like it).
+    pub fn file_source(&self, p: &Project, item: ItemId, services: &dyn Services) -> Option<SharedSource> {
+        self.resolve_file(p, item, services, false)
+    }
+
+    /// The source for an item as Interpret Footage interprets it: conformed to the frame rate it
+    /// assumes and with its alpha ignored or inverted, if so.
     fn resolve(&self, p: &Project, item: ItemId, services: &dyn Services, proxies: bool) -> Option<SharedSource> {
+        let src = self.resolve_file(p, item, services, proxies)?;
+        let Some((id, m, _)) = p.resolve_media(item) else { return Some(src) };
+        let how = Interpret { conform: m.conform(), alpha: m.alpha() };
+        if how == Interpret::default() {
+            return Some(src);
+        }
+        if let Some((inner, h, s)) = self.interpreted.read().unwrap_or_else(|e| e.into_inner()).get(&id)
+            && Arc::ptr_eq(inner, &src)
+            && *h == how
+        {
+            return Some(s.clone());
+        }
+        let s: SharedSource = Arc::new(InterpretSource::new(src.clone(), how));
+        self.interpreted.write().unwrap_or_else(|e| e.into_inner()).insert(id, (src, how, s.clone()));
+        Some(s)
+    }
+
+    fn resolve_file(&self, p: &Project, item: ItemId, services: &dyn Services, proxies: bool) -> Option<SharedSource> {
         let (item, m, _) = p.resolve_media(item)?;
         let it = p.item(item)?;
         if proxies
@@ -586,6 +615,125 @@ impl MediaSource for DamagedAsSlate {
     }
 }
 
+/// How Interpret Footage changes what a file plays: the frame rate it assumes and what it does to
+/// the alpha channel.
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
+pub struct Interpret {
+    pub conform: Option<Conform>,
+    pub alpha: Option<AlphaInterpret>,
+}
+
+/// Footage as Interpret Footage interprets it.
+///
+/// Conformed to an assumed frame rate ([`Conform`]): asked for media time, it reads the file at
+/// the matching file time, and its audio at the sample rate that makes it last as long as the
+/// conformed picture (so a 50 fps clip assumed at 25 plays picture and sound at half speed, an
+/// octave lower). Its info reports the conformed duration and the assumed rate.
+///
+/// With Ignore Alpha Channel its pictures are opaque; with Invert Alpha Channel their alpha is
+/// inverted ([`interpret_alpha`]).
+pub struct InterpretSource {
+    pub inner: SharedSource,
+    pub how: Interpret,
+    info: MediaInfo,
+}
+
+impl InterpretSource {
+    pub fn new(inner: SharedSource, how: Interpret) -> Self {
+        let mut info = inner.info().clone();
+        if let Some(c) = how.conform {
+            info.duration = c.media_time(info.duration);
+            if let Some(v) = info.video.as_mut() {
+                v.frame_rate = c.assumed;
+            }
+        }
+        if how.alpha == Some(AlphaInterpret::Ignore)
+            && let Some(v) = info.video.as_mut()
+        {
+            v.has_alpha = false;
+        }
+        Self { inner, how, info }
+    }
+
+    fn file_sample_rate(&self, rate: u32) -> u32 {
+        self.how.conform.map_or(rate, |c| c.file_sample_rate(rate))
+    }
+
+    fn conformed(&self, buf: AudioBuffer, sample_rate: u32) -> AudioBuffer {
+        // the samples were read at the file's rate; they play at the one asked for
+        AudioBuffer { sample_rate, ..buf }
+    }
+}
+
+impl MediaSource for InterpretSource {
+    fn info(&self) -> &MediaInfo {
+        &self.info
+    }
+    fn video_frame(&self, req: FrameRequest) -> filmcraft_media::Result<Arc<VideoFrame>> {
+        let time = self.how.conform.map_or(req.time, |c| c.file_time(req.time));
+        let frame = self.inner.video_frame(FrameRequest { time, ..req })?;
+        Ok(match self.how.alpha {
+            Some(a) => Arc::new(interpret_alpha(&frame, a)),
+            None => frame,
+        })
+    }
+    fn audio(&self, start: i64, frames: usize, sample_rate: u32) -> filmcraft_media::Result<AudioBuffer> {
+        let buf = self.inner.audio(start, frames, self.file_sample_rate(sample_rate))?;
+        Ok(self.conformed(buf, sample_rate))
+    }
+    fn audio_stream(&self, stream: usize, start: i64, frames: usize, sample_rate: u32) -> filmcraft_media::Result<AudioBuffer> {
+        let buf = self.inner.audio_stream(stream, start, frames, self.file_sample_rate(sample_rate))?;
+        Ok(self.conformed(buf, sample_rate))
+    }
+}
+
+/// `frame` with its alpha ignored (opaque) or inverted. Straight alpha (8-bit RGBA, Y'CbCr alpha
+/// planes) changes alone; premultiplied linear RGBA is unpremultiplied, inverted and premultiplied
+/// again, and ignoring its alpha keeps the colours as stored.
+pub fn interpret_alpha(frame: &VideoFrame, how: AlphaInterpret) -> VideoFrame {
+    let ignore = how == AlphaInterpret::Ignore;
+    let data = match &frame.data {
+        PixelData::Rgba8(px) => {
+            let mut px = px.as_ref().clone();
+            for a in px.iter_mut().skip(3).step_by(4) {
+                *a = if ignore { 255 } else { 255 - *a };
+            }
+            PixelData::Rgba8(Arc::new(px))
+        }
+        PixelData::RgbaF32(px) => {
+            let mut px = px.as_ref().clone();
+            for p in px.chunks_exact_mut(4) {
+                let a = if p[3].is_finite() { p[3].clamp(0.0, 1.0) } else { 1.0 };
+                if ignore {
+                    p[3] = 1.0;
+                } else {
+                    let k = if a > 0.0 { (1.0 - a) / a } else { 1.0 };
+                    for c in &mut p[..3] {
+                        *c *= k;
+                    }
+                    p[3] = 1.0 - a;
+                }
+            }
+            PixelData::RgbaF32(Arc::new(px))
+        }
+        PixelData::Yuv8 { planes, chroma, alpha } => PixelData::Yuv8 {
+            planes: planes.clone(),
+            chroma: *chroma,
+            alpha: if ignore { None } else { alpha.as_ref().map(|a| Arc::new(a.iter().map(|v| 255 - v).collect())) },
+        },
+        PixelData::Yuv16 { planes, chroma, bits, alpha } => {
+            let max = u16::try_from((1u32 << (*bits).clamp(1, 16)) - 1).unwrap_or(u16::MAX);
+            PixelData::Yuv16 {
+                planes: planes.clone(),
+                chroma: *chroma,
+                bits: *bits,
+                alpha: if ignore { None } else { alpha.as_ref().map(|a| Arc::new(a.iter().map(|v| max.saturating_sub(*v)).collect())) },
+            }
+        }
+    };
+    VideoFrame { data, ..frame.clone() }
+}
+
 /// A proxy standing in for its full-resolution media: reports the full-resolution info and hands
 /// out the proxy's (smaller) frames. A proxy whose aspect ratio differs from the original is
 /// resampled to the original's aspect so it lines up exactly.
diff --git a/crates/engine/src/project_manager.rs b/crates/engine/src/project_manager.rs
--- a/crates/engine/src/project_manager.rs
+++ b/crates/engine/src/project_manager.rs
@@ -145,7 +145,9 @@ pub fn run(s: &mut Session, p: &Value) -> Result<Value> {
         let file = Path::new(path);
         let stem = file.file_stem().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| format!("media-{}", id.0));
         let ext = file.extension().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
-        let transcode = consolidate && matches!(m.info.kind, MediaKind::Movie | MediaKind::AudioOnly) && ranges.contains_key(&id);
+        // footage conformed to an assumed frame rate is copied whole: its clips' times are media
+        // time, which a trimmed copy at the file's rate would not keep
+        let transcode = consolidate && matches!(m.info.kind, MediaKind::Movie | MediaKind::AudioOnly) && ranges.contains_key(&id) && m.conform().is_none();
         let (out, range) = if transcode {
             let rate = m.frame_rate();
             let (lo, hi) = ranges[&id];
diff --git a/crates/engine/src/proxies.rs b/crates/engine/src/proxies.rs
--- a/crates/engine/src/proxies.rs
+++ b/crates/engine/src/proxies.rs
@@ -256,7 +256,8 @@ fn start_job(s: &mut Session, label: String, work: Vec<(ItemId, ReservedOutput,
     let mut total = 0;
     for (item, output, pr) in work {
         let (clip, _, name) = media_path(&s.project, item).ok_or_else(|| bad("media.createProxies", format!("item {} is not file media", item.0)))?;
-        let src = s.media.full_res_source(&s.project, item, &*s.services).ok_or_else(|| EngineError::Other(format!("{name}: no source")))?;
+        // the file at its own rate: the proxy stands in for the file, and is conformed when read like it
+        let src = s.media.file_source(&s.project, item, &*s.services).ok_or_else(|| EngineError::Other(format!("{name}: no source")))?;
         if s.media.offline_status(item).is_some() {
             return Err(EngineError::Other(format!("{name} is offline; link it first")));
         }
diff --git a/crates/interchange/src/comp.rs b/crates/interchange/src/comp.rs
--- a/crates/interchange/src/comp.rs
+++ b/crates/interchange/src/comp.rs
@@ -458,7 +458,8 @@ impl Exporter<'_> {
             CKind::Sound if audio.is_none() => return None,
             _ => {}
         }
-        let frame_rate = video.map(|v| v.frame_rate).filter(|r| r.num > 0).unwrap_or(self.seq_rate);
+        // the rate and length the footage plays at (Interpret Footage's, else the file's)
+        let frame_rate = video.map(|_| m.frame_rate()).filter(|r| r.num > 0).unwrap_or(self.seq_rate);
         let mut markers: Vec<CMarker> = m.markers.iter().chain(c.markers.iter()).map(marker_out).collect();
         markers.sort_by(|a, b| (a.start, &a.name).cmp(&(b.start, &b.name)));
         markers.dedup();
@@ -475,7 +476,7 @@ impl Exporter<'_> {
             frame_rate,
             sample_rate: audio.map_or(48_000, |a| a.sample_rate.max(1)),
             bits: audio.and_then(|a| a.bits_per_sample).unwrap_or(16).clamp(8, 32) as u16,
-            length: if still { Tick::ZERO } else { m.info.duration },
+            length: if still { Tick::ZERO } else { m.duration() },
             offset: Tick::ZERO,
             start_tc: m.info.start_timecode,
             tc_rate: frame_rate,
diff --git a/crates/interchange/src/fcp7.rs b/crates/interchange/src/fcp7.rs
--- a/crates/interchange/src/fcp7.rs
+++ b/crates/interchange/src/fcp7.rs
@@ -984,7 +984,7 @@ impl Exp<'_, '_> {
                 .unwrap_or_else(|| self.p.sequence(seq_id).map(|s| s.settings.frame_rate).unwrap_or_default());
             let name = self.p.item(item).map(|i| i.name.clone()).unwrap_or_default();
             let label = self.p.item(item).map(|i| i.label).unwrap_or(Label::Iris);
-            let duration = m.info.duration;
+            let duration = m.duration();
             let markers = m.markers.clone();
             let mid = self.master_id(item);
             self.w.open("clip", &[("id", &mid)]);
@@ -1144,7 +1144,7 @@ impl Exp<'_, '_> {
             self.w.text("pathurl", path);
         }
         write_rate(&mut self.w, frate);
-        self.w.text("duration", frames_round(frate, m.info.duration));
+        self.w.text("duration", frames_round(frate, m.duration()));
         let tc = m.info.start_timecode.unwrap_or(0);
         self.write_timecode(tc, frate, false);
         self.w.open("media", &[]);
diff --git a/crates/interchange/src/fcpxml.rs b/crates/interchange/src/fcpxml.rs
--- a/crates/interchange/src/fcpxml.rs
+++ b/crates/interchange/src/fcpxml.rs
@@ -742,7 +742,7 @@ impl Exp<'_, '_> {
             "    <asset id=\"{id}\" name=\"{}\" start=\"{}\" duration=\"{}\" hasVideo=\"{}\" hasAudio=\"{}\"",
             crate::xml::escape_attr(&name),
             fmt_time(start, mrate),
-            fmt_time(m.info.duration, mrate),
+            fmt_time(m.duration(), mrate),
             m.info.video.is_some() as u8,
             m.info.has_audio() as u8
         );
diff --git a/crates/interchange/src/otio.rs b/crates/interchange/src/otio.rs
--- a/crates/interchange/src/otio.rs
+++ b/crates/interchange/src/otio.rs
@@ -235,7 +235,7 @@ impl Exp<'_, '_> {
         let Some(m) = item_media(self.p, item) else {
             return json!({"OTIO_SCHEMA": "MissingReference.1", "name": name, "available_range": null, "metadata": {}});
         };
-        let avail = tr(m.info.start_timecode.map(|f| m.frame_rate().tick_of(f)).unwrap_or(Tick::ZERO), m.info.duration, rate);
+        let avail = tr(m.info.start_timecode.map(|f| m.frame_rate().tick_of(f)).unwrap_or(Tick::ZERO), m.duration(), rate);
         let meta = json!({"filmcraft": {"item": item.0, "label": self.p.item(item).map(|i| i.label), "info": m.info, "offline": m.offline}});
         match &m.media {
             MediaRef::Generator(g) => json!({
diff --git a/crates/project/src/lib.rs b/crates/project/src/lib.rs
--- a/crates/project/src/lib.rs
+++ b/crates/project/src/lib.rs
@@ -255,11 +255,93 @@ impl MediaClip {
     pub fn pixel_aspect(&self) -> (u32, u32) {
         self.interpret.par.and_then(checked_par).or_else(|| self.info.video.as_ref().and_then(|v| checked_par(v.par))).unwrap_or((1, 1))
     }
+    /// Media duration: the file's, conformed when Interpret Footage assumes another frame rate.
     pub fn duration(&self) -> Tick {
-        self.info.duration
+        self.conform().map_or(self.info.duration, |c| c.media_time(self.info.duration))
+    }
+    /// Modify ▸ Interpret Footage ▸ Assume this frame rate: how the footage's media time maps to
+    /// the file's. `None` when it plays at the file's own rate, for stills and footage without
+    /// a picture, and for an override that is not a usable rate.
+    pub fn conform(&self) -> Option<Conform> {
+        let assumed = self.interpret.frame_rate.filter(|r| r.is_usable())?;
+        let file = self.info.video.as_ref().map(|v| v.frame_rate).filter(|r| r.is_usable())?;
+        if matches!(self.info.kind, filmcraft_media::MediaKind::Still) {
+            return None;
+        }
+        let (file, assumed) = (FrameRate::new(file.num, file.den), FrameRate::new(assumed.num, assumed.den));
+        (file != assumed).then_some(Conform { file, assumed })
+    }
+    /// Modify ▸ Interpret Footage ▸ Alpha Channel: what playback does to the picture's alpha.
+    /// `None` when the file has no alpha or neither option is on; Ignore wins over Invert.
+    pub fn alpha(&self) -> Option<AlphaInterpret> {
+        if !self.info.video.as_ref().is_some_and(|v| v.has_alpha) {
+            return None;
+        }
+        if self.interpret.ignore_alpha { Some(AlphaInterpret::Ignore) } else { self.interpret.invert_alpha.then_some(AlphaInterpret::Invert) }
     }
 }
 
+/// Interpret Footage ▸ Alpha Channel: Ignore Alpha Channel (the picture is opaque) or Invert Alpha
+/// Channel (transparent becomes opaque and the other way round).
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum AlphaInterpret {
+    Ignore,
+    Invert,
+}
+
+/// Footage conformed to an assumed frame rate (Modify ▸ Interpret Footage): frame `n` of the file
+/// is shown at media time `n / assumed`, so footage shot at 50 fps and assumed at 25 plays at half
+/// speed for twice as long. Its audio is conformed with it and stays in sync.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub struct Conform {
+    pub file: FrameRate,
+    pub assumed: FrameRate,
+}
+
+impl Conform {
+    /// File time of media time `t`, in the same frame of the file as `t` is of the footage.
+    pub fn file_time(self, t: Tick) -> Tick {
+        retime(t, self.assumed, self.file)
+    }
+    /// Media time of file time `t` (the inverse of [`Conform::file_time`]).
+    pub fn media_time(self, t: Tick) -> Tick {
+        retime(t, self.file, self.assumed)
+    }
+    /// Sample rate to read the file's audio at so that, played at `rate`, it lasts as long as the
+    /// conformed picture (at least 1 Hz, at most [`MAX_CONFORM_SAMPLE_RATE`]).
+    pub fn file_sample_rate(self, rate: u32) -> u32 {
+        let (a, f) = (self.assumed, self.file);
+        let num = i128::from(rate) * i128::from(a.den) * i128::from(f.num);
+        let den = i128::from(a.num) * i128::from(f.den);
+        if den <= 0 {
+            return rate;
+        }
+        // to nearest
+        let r = (num * 2 + den) / (den * 2);
+        r.clamp(1, i128::from(MAX_CONFORM_SAMPLE_RATE)) as u32
+    }
+}
+
+/// Highest sample rate [`Conform::file_sample_rate`] asks a source for (120 fps footage assumed
+/// at 23.976 reads 48 kHz audio at about 240 kHz).
+pub const MAX_CONFORM_SAMPLE_RATE: u32 = 1_536_000;
+
+/// Time `t` of footage playing at `from` as time of the same footage playing at `to`: scaled by
+/// `from / to` and kept in the same frame (frame `n` at `n / from` goes to `n / to`; rounding
+/// never moves a tick into a neighbouring frame). `t` when either rate is unusable.
+pub fn retime(t: Tick, from: FrameRate, to: FrameRate) -> Tick {
+    if !from.is_usable() || !to.is_usable() {
+        return t;
+    }
+    let num = i128::from(from.num) * i128::from(to.den);
+    let den = i128::from(from.den) * i128::from(to.num);
+    let Some(y) = i128::from(t.0).saturating_mul(num).checked_div_euclid(den) else { return t };
+    let y = Tick(y.clamp(i128::from(i64::MIN), i128::from(i64::MAX)) as i64);
+    // rounding down can land just before the frame's first tick: move it up to that tick
+    let n = from.frame_at(t);
+    if to.frame_at(y) < n { to.tick_of(n) } else { y }
+}
+
 #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
 #[allow(clippy::large_enum_variant)]
 pub enum ItemKind {
@@ -946,13 +1028,7 @@ pub fn conformed_size(size: (u32, u32), par: (u32, u32), frame_par: (u32, u32))
 impl SequenceSettings {
     pub fn validate(&self) -> Result<(), String> {
         validate_frame_size(self.width, self.height)?;
-        if self.frame_rate.num <= 0
-            || self.frame_rate.den <= 0
-            || self.frame_rate.as_f64() > 1000.0
-            || self.frame_rate.num > i64::from(u32::MAX)
-            || self.frame_rate.den > i64::from(u32::MAX)
-            || (i128::from(TICKS_PER_SECOND) * i128::from(self.frame_rate.den) / i128::from(self.frame_rate.num.max(1))) > i128::from(Tick::MAX.0)
-        {
+        if !self.frame_rate.is_usable() {
             return Err("frame rate must be positive, at most 1000 fps, with unsigned 32-bit rational components".into());
         }
         if self.sample_rate == 0 || self.sample_rate > 384_000 {
@@ -1778,6 +1854,73 @@ impl Project {
         None
     }
 
+    /// Interpret Footage changed the rate `media` plays at from `from` to `to` (each the assumed
+    /// rate, or the file's): every media time pointing into it is scaled by `from / to` so it keeps
+    /// its frame. That is its marks and markers, its subclips' ranges, and in every sequence the
+    /// clips that play it (source In, frame hold, clip markers, effect and mask keyframes). Clips
+    /// keep their place and length on the timeline; one that would now run past the media's end
+    /// is shortened to end with it.
+    pub fn retime_media(&mut self, media: ItemId, from: FrameRate, to: FrameRate) {
+        if !from.is_usable() || !to.is_usable() || FrameRate::new(from.num, from.den) == FrameRate::new(to.num, to.den) {
+            return;
+        }
+        let scale = |t: &mut Tick| *t = retime(*t, from, to);
+        let scale_markers = |markers: &mut Vec<Marker>| {
+            for mk in markers {
+                scale(&mut mk.start);
+                scale(&mut mk.duration);
+            }
+        };
+        // the media and every item that plays it (subclips, subclips of subclips)
+        let playing: std::collections::BTreeSet<ItemId> =
+            self.items.keys().copied().filter(|id| self.resolve_media(*id).is_some_and(|(m, _, _)| m == media)).collect();
+        let limit = match self.item_mut(media).and_then(|i| i.as_media_mut()) {
+            Some(m) => {
+                m.mark_in.as_mut().map(scale);
+                m.mark_out.as_mut().map(scale);
+                scale_markers(&mut m.markers);
+                m.duration()
+            }
+            None => return,
+        };
+        for id in &playing {
+            if let Some(ItemKind::Subclip { range, .. }) = self.item_mut(*id).map(|i| &mut i.kind) {
+                scale(&mut range.start);
+                scale(&mut range.duration);
+            }
+        }
+        let sequences: Vec<ItemId> = self.items.iter().filter(|(_, i)| matches!(i.kind, ItemKind::Sequence(_))).map(|(id, _)| *id).collect();
+        for sid in sequences {
+            let touches =
+                self.sequence(sid).is_some_and(|q| q.video_tracks.iter().chain(&q.audio_tracks).flat_map(|t| &t.items).any(|ti| playing.contains(&ti.item)));
+            if !touches {
+                continue;
+            }
+            let Some(seq) = self.sequence_mut(sid) else { continue };
+            for ti in seq.video_tracks.iter_mut().chain(seq.audio_tracks.iter_mut()).flat_map(|t| t.items.iter_mut()) {
+                if !playing.contains(&ti.item) {
+                    continue;
+                }
+                scale(&mut ti.source_in);
+                ti.frame_hold.as_mut().map(scale);
+                scale_markers(&mut ti.markers);
+                for fx in &mut ti.effects {
+                    let params =
+                        fx.params.values_mut().chain(fx.masks.iter_mut().flat_map(|m| [&mut m.path, &mut m.feather, &mut m.opacity, &mut m.expansion]));
+                    for p in params {
+                        for k in &mut p.keyframes {
+                            scale(&mut k.time);
+                        }
+                    }
+                }
+                let speed = ti.speed.abs();
+                if ti.frame_hold.is_none() && speed > 0.0 && ti.source_out() > limit && limit > ti.source_in {
+                    ti.duration = Tick((((limit - ti.source_in).0 as f64) / speed).floor() as i64);
+                }
+            }
+        }
+    }
+
     /// Size in pixels of what an item shows: a media clip's picture, a sequence's frame, an
     /// adjustment layer or graphic; a subclip has the size of its parent. `None` without
     /// picture. This is the size the renderer centres a clip's "auto" anchor in.
@@ -2244,6 +2387,42 @@ mod tests {
         assert_eq!(conformed_size((1440, 1080), (0, 0), (u32::MAX, 1)), (1440.0, 1080.0));
     }
 
+    /// Between any two standard rates, the first tick of every frame lands in the same frame of
+    /// the file and back, durations scale exactly, and stills, sound and hostile overrides are
+    /// never conformed.
+    #[test]
+    fn conform_keeps_every_frame_and_skips_what_it_cannot_conform() {
+        for file in FrameRate::COMMON {
+            for assumed in FrameRate::COMMON {
+                let c = Conform { file, assumed };
+                for f in [0, 1, 2, 23, 24, 25, 1001, 1_000_000] {
+                    assert_eq!(file.frame_at(c.file_time(assumed.tick_of(f))), f, "{file} as {assumed}: frame {f}");
+                    assert_eq!(assumed.frame_at(c.media_time(file.tick_of(f))), f, "{file} as {assumed}: back {f}");
+                    // the last tick of a frame stays in it too
+                    assert_eq!(file.frame_at(c.file_time(assumed.tick_of(f + 1) - Tick(1))), f, "{file} as {assumed}: end of {f}");
+                }
+                let r = c.file_sample_rate(48_000);
+                assert!((f64::from(r) - 48_000.0 * file.as_f64() / assumed.as_f64()).abs() <= 0.5, "{file} as {assumed}: {r}");
+            }
+        }
+        let (mut p, clip, _) = demo_project();
+        let m = p.item_mut(clip).and_then(|i| i.as_media_mut()).unwrap();
+        let file = m.info.frame_rate();
+        assert_eq!((m.conform(), m.duration()), (None, m.info.duration));
+        m.interpret.frame_rate = Some(FrameRate::new(file.num * 2, file.den));
+        assert_eq!(m.duration(), Conform { file, assumed: FrameRate::new(file.num * 2, file.den) }.media_time(m.info.duration));
+        for hostile in [FrameRate { num: 0, den: 1 }, FrameRate { num: 25, den: 0 }, FrameRate { num: -25, den: 1 }, FrameRate { num: 2001, den: 2 }] {
+            m.interpret.frame_rate = Some(hostile);
+            assert_eq!((m.conform(), m.duration()), (None, m.info.duration), "{hostile:?}");
+        }
+        m.interpret.frame_rate = Some(FrameRate::FPS_25);
+        m.info.kind = filmcraft_media::MediaKind::Still;
+        assert_eq!(m.conform(), None);
+        m.info.kind = filmcraft_media::MediaKind::AudioOnly;
+        m.info.video = None;
+        assert_eq!(m.conform(), None);
+    }
+
     #[test]
     fn source_par_follows_interpret_footage_subclips_and_sequences() {
         let (mut p, clip, seq) = demo_project();
diff --git a/crates/time/src/lib.rs b/crates/time/src/lib.rs
--- a/crates/time/src/lib.rs
+++ b/crates/time/src/lib.rs
@@ -220,6 +220,17 @@ impl FrameRate {
         self.num as f64 / self.den as f64
     }
 
+    /// A rate a sequence or an interpretation can use: positive, at most 1000 fps, with unsigned
+    /// 32-bit rational components and a frame at least one tick long.
+    pub fn is_usable(self) -> bool {
+        self.num > 0
+            && self.den > 0
+            && self.as_f64() <= 1000.0
+            && self.num <= i64::from(u32::MAX)
+            && self.den <= i64::from(u32::MAX)
+            && (i128::from(TICKS_PER_SECOND) * i128::from(self.den) / i128::from(self.num)) <= i128::from(i64::MAX)
+    }
+
     /// Exact duration of one frame (rounded down only for exotic rates).
     /// Values outside the integer tick range saturate at its limit.
     pub fn frame_duration(self) -> Tick {
diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv
--- a/crates/ui-egui/src/i18n/es.tsv
+++ b/crates/ui-egui/src/i18n/es.tsv
@@ -3628,3 +3628,18 @@
 	No matching text.	Ningún texto coincide.
 	H.265 (HEVC) needs a hardware encoder (NVENC on NVIDIA GPUs, VideoToolbox on macOS); none was found on this machine, and FilmCraft has no software H.265 encoder yet. Choose H.264 or ProRes instead.	H.265 (HEVC) necesita un codificador por hardware (NVENC en GPU NVIDIA, VideoToolbox en macOS); no se encontró ninguno en este equipo y FilmCraft aún no tiene un codificador H.265 por software. Elija H.264 o ProRes.
 	AV1 needs a hardware encoder; none was found on this machine, and FilmCraft has no software AV1 encoder yet.	AV1 necesita un codificador por hardware; no se encontró ninguno en este equipo y FilmCraft aún no tiene un codificador AV1 por software.
+# Modify Clip ▸ Interpret Footage
+	Assume this frame rate:	Asumir esta velocidad de fotogramas:
+	Modify Clip	Modificar clip
+	Use Frame Rate from File:	Usar la velocidad de fotogramas del archivo:
+	fps	fps
+	Conform to:	Ajustar a:
+	Use Field Order from File:	Usar el orden de campos del archivo:
+	Upper Field First	Campo superior primero
+	Lower Field First	Campo inferior primero
+	FilmCraft plays every frame as a whole picture; conforming the field order is not supported yet.	FilmCraft reproduce cada fotograma como una imagen completa; todavía no se puede ajustar el orden de campos.
+	Ignore Alpha Channel	Ignorar canal alfa
+	Invert Alpha Channel	Invertir canal alfa
+	(differs between the selected items)	(difiere entre los elementos seleccionados)
+	Changes apply to all {n} selected items; settings left as they are stay as each item has them.	Los cambios se aplican a los {n} elementos seleccionados; los ajustes que no cambies se quedan como los tiene cada elemento.
+	Various	Varios
diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv
--- a/crates/ui-egui/src/i18n/fr.tsv
+++ b/crates/ui-egui/src/i18n/fr.tsv
@@ -3627,3 +3627,18 @@
 	H.265 (HEVC) needs a hardware encoder (NVENC on NVIDIA GPUs, VideoToolbox on macOS); none was found on this machine, and FilmCraft has no software H.265 encoder yet. Choose H.264 or ProRes instead.	H.265 (HEVC) nécessite un encodeur matériel (NVENC sur les GPU NVIDIA, VideoToolbox sur macOS) ; aucun n'a été trouvé sur cet ordinateur, et FilmCraft n'a pas encore d'encodeur H.265 logiciel. Choisissez plutôt H.264 ou ProRes.
 	AV1 needs a hardware encoder; none was found on this machine, and FilmCraft has no software AV1 encoder yet.	AV1 nécessite un encodeur matériel ; aucun n'a été trouvé sur cet ordinateur, et FilmCraft n'a pas encore d'encodeur AV1 logiciel.
 	Hardware decoding: Auto uses the system's video decoder (VideoToolbox on macOS, Media Foundation on Windows, VA-API for H.264 and HEVC on Linux) for the streams it supports, and FilmCraft's own decoder for everything else or if the hardware fails. Media that is already open keeps its decoder until it is reopened.	Décodage matériel : Auto utilise le décodeur vidéo du système (VideoToolbox sur macOS, Media Foundation sur Windows, VA-API pour H.264 et HEVC sous Linux) pour les flux qu'il prend en charge, et le décodeur de FilmCraft pour tout le reste ou en cas d'échec du matériel. Un média déjà ouvert garde son décodeur jusqu'à sa réouverture.
+# Modify Clip ▸ Interpret Footage
+	Assume this frame rate:	Utiliser cette fréquence d'images :
+	Modify Clip	Modifier le clip
+	Use Frame Rate from File:	Utiliser la fréquence d'images du fichier :
+	fps	i/s
+	Conform to:	Conformer à :
+	Use Field Order from File:	Utiliser l'ordre des trames du fichier :
+	Upper Field First	Trame supérieure en premier
+	Lower Field First	Trame inférieure en premier
+	FilmCraft plays every frame as a whole picture; conforming the field order is not supported yet.	FilmCraft lit chaque image comme une image complète ; la conformation de l'ordre des trames n'est pas encore prise en charge.
+	Ignore Alpha Channel	Ignorer la couche alpha
+	Invert Alpha Channel	Inverser la couche alpha
+	(differs between the selected items)	(diffère entre les éléments sélectionnés)
+	Changes apply to all {n} selected items; settings left as they are stay as each item has them.	Les modifications s'appliquent aux {n} éléments sélectionnés ; les réglages non modifiés restent tels qu'ils sont pour chaque élément.
+	Various	Divers
diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv
--- a/crates/ui-egui/src/i18n/id.tsv
+++ b/crates/ui-egui/src/i18n/id.tsv
@@ -3583,3 +3583,18 @@
 	Open a sequence to search its graphics	Buka sekuens untuk mencari di grafisnya
 	No text graphics in this sequence. Add text with the Type tool.	Tidak ada grafis teks di sekuens ini. Tambahkan teks dengan alat Teks.
 	No matching text.	Tidak ada teks yang cocok.
+# Modify Clip ▸ Interpret Footage
+	Assume this frame rate:	Anggap kecepatan bingkai ini:
+	Modify Clip	Ubah Klip
+	Use Frame Rate from File:	Gunakan kecepatan bingkai dari file:
+	fps	fps
+	Conform to:	Sesuaikan ke:
+	Use Field Order from File:	Gunakan urutan field dari file:
+	Upper Field First	Field atas dahulu
+	Lower Field First	Field bawah dahulu
+	FilmCraft plays every frame as a whole picture; conforming the field order is not supported yet.	FilmCraft memutar setiap bingkai sebagai gambar utuh; menyesuaikan urutan field belum didukung.
+	Ignore Alpha Channel	Abaikan Saluran Alfa
+	Invert Alpha Channel	Balikkan Saluran Alfa
+	(differs between the selected items)	(berbeda di antara item yang dipilih)
+	Changes apply to all {n} selected items; settings left as they are stay as each item has them.	Perubahan berlaku untuk semua {n} item yang dipilih; pengaturan yang tidak diubah tetap seperti pada masing-masing item.
+	Various	Beragam
diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv
--- a/crates/ui-egui/src/i18n/it.tsv
+++ b/crates/ui-egui/src/i18n/it.tsv
@@ -3609,3 +3609,18 @@
 	Open a sequence to search its graphics	Apri una sequenza per cercare nelle sue grafiche
 	No text graphics in this sequence. Add text with the Type tool.	Nessuna grafica con testo in questa sequenza. Aggiungi testo con lo strumento Testo.
 	No matching text.	Nessun testo corrispondente.
+# Modify Clip ▸ Interpret Footage
+	Assume this frame rate:	Assumi questa frequenza fotogrammi:
+	Modify Clip	Modifica clip
+	Use Frame Rate from File:	Usa la frequenza fotogrammi del file:
+	fps	fps
+	Conform to:	Conforma a:
+	Use Field Order from File:	Usa l'ordine dei campi del file:
+	Upper Field First	Prima il campo superiore
+	Lower Field First	Prima il campo inferiore
+	FilmCraft plays every frame as a whole picture; conforming the field order is not supported yet.	FilmCraft riproduce ogni fotogramma come un'immagine intera; conformare l'ordine dei campi non è ancora supportato.
+	Ignore Alpha Channel	Ignora canale alfa
+	Invert Alpha Channel	Inverti canale alfa
+	(differs between the selected items)	(diverso tra gli elementi selezionati)
+	Changes apply to all {n} selected items; settings left as they are stay as each item has them.	Le modifiche si applicano a tutti i {n} elementi selezionati; le impostazioni non modificate restano come sono per ciascun elemento.
+	Various	Vari
diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv
--- a/crates/ui-egui/src/i18n/ja.tsv
+++ b/crates/ui-egui/src/i18n/ja.tsv
@@ -3627,3 +3627,18 @@
 	No matching text.	一致するテキストはありません。
 	H.265 (HEVC) needs a hardware encoder (NVENC on NVIDIA GPUs, VideoToolbox on macOS); none was found on this machine, and FilmCraft has no software H.265 encoder yet. Choose H.264 or ProRes instead.	H.265 (HEVC) にはハードウェアエンコーダー（NVIDIA GPU の NVENC、macOS の VideoToolbox）が必要です。このマシンには見つからず、FilmCraft にはまだソフトウェアの H.265 エンコーダーがありません。代わりに H.264 または ProRes を選んでください。
 	AV1 needs a hardware encoder; none was found on this machine, and FilmCraft has no software AV1 encoder yet.	AV1 にはハードウェアエンコーダーが必要です。このマシンには見つからず、FilmCraft にはまだソフトウェアの AV1 エンコーダーがありません。
+# Modify Clip ▸ Interpret Footage
+	Assume this frame rate:	このフレームレートとみなす：
+	Modify Clip	クリップを変更
+	Use Frame Rate from File:	ファイルのフレームレートを使用：
+	fps	fps
+	Conform to:	適用：
+	Use Field Order from File:	ファイルのフィールドオーダーを使用：
+	Upper Field First	上位フィールドから
+	Lower Field First	下位フィールドから
+	FilmCraft plays every frame as a whole picture; conforming the field order is not supported yet.	FilmCraft は各フレームを 1 枚の画像として再生します。フィールドオーダーの変更にはまだ対応していません。
+	Ignore Alpha Channel	アルファチャンネルを無視
+	Invert Alpha Channel	アルファチャンネルを反転
+	(differs between the selected items)	（選択した項目間で異なります）
+	Changes apply to all {n} selected items; settings left as they are stay as each item has them.	変更は選択した {n} 個の項目すべてに適用されます。変更しない設定は各項目のままです。
+	Various	複数
diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv
--- a/crates/ui-egui/src/i18n/ko.tsv
+++ b/crates/ui-egui/src/i18n/ko.tsv
@@ -3604,3 +3604,18 @@
 	Open a sequence to search its graphics	그래픽을 검색하려면 시퀀스를 여세요
 	No text graphics in this sequence. Add text with the Type tool.	이 시퀀스에는 텍스트 그래픽이 없습니다. 문자 도구로 텍스트를 추가하세요.
 	No matching text.	일치하는 텍스트가 없습니다.
+# Modify Clip ▸ Interpret Footage
+	Assume this frame rate:	이 프레임 속도로 간주:
+	Modify Clip	클립 수정
+	Use Frame Rate from File:	파일의 프레임 속도 사용:
+	fps	fps
+	Conform to:	적용:
+	Use Field Order from File:	파일의 필드 순서 사용:
+	Upper Field First	위쪽 필드 먼저
+	Lower Field First	아래쪽 필드 먼저
+	FilmCraft plays every frame as a whole picture; conforming the field order is not supported yet.	FilmCraft는 모든 프레임을 하나의 완전한 그림으로 재생합니다. 필드 순서 변경은 아직 지원되지 않습니다.
+	Ignore Alpha Channel	알파 채널 무시
+	Invert Alpha Channel	알파 채널 반전
+	(differs between the selected items)	(선택한 항목마다 다름)
+	Changes apply to all {n} selected items; settings left as they are stay as each item has them.	변경 사항은 선택한 {n}개 항목 모두에 적용됩니다. 변경하지 않은 설정은 각 항목의 설정대로 유지됩니다.
+	Various	여러 값
diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv
--- a/crates/ui-egui/src/i18n/pt-br.tsv
+++ b/crates/ui-egui/src/i18n/pt-br.tsv
@@ -3586,3 +3586,18 @@
 	Open a sequence to search its graphics	Abra uma sequência para pesquisar nos gráficos dela
 	No text graphics in this sequence. Add text with the Type tool.	Esta sequência não tem gráficos com texto. Adicione texto com a ferramenta Texto.
 	No matching text.	Nenhum texto corresponde.
+# Modify Clip ▸ Interpret Footage
+	Assume this frame rate:	Assumir esta taxa de quadros:
+	Modify Clip	Modificar clipe
+	Use Frame Rate from File:	Usar a taxa de quadros do arquivo:
+	fps	fps
+	Conform to:	Conformar para:
+	Use Field Order from File:	Usar a ordem de campos do arquivo:
+	Upper Field First	Campo superior primeiro
+	Lower Field First	Campo inferior primeiro
+	FilmCraft plays every frame as a whole picture; conforming the field order is not supported yet.	O FilmCraft reproduz cada quadro como uma imagem inteira; conformar a ordem de campos ainda não é suportado.
+	Ignore Alpha Channel	Ignorar canal alfa
+	Invert Alpha Channel	Inverter canal alfa
+	(differs between the selected items)	(diferente entre os itens selecionados)
+	Changes apply to all {n} selected items; settings left as they are stay as each item has them.	As alterações se aplicam a todos os {n} itens selecionados; as configurações não alteradas ficam como estão em cada item.
+	Various	Vários
diff --git a/crates/ui-egui/src/i18n/zh-cn.tsv b/crates/ui-egui/src/i18n/zh-cn.tsv
--- a/crates/ui-egui/src/i18n/zh-cn.tsv
+++ b/crates/ui-egui/src/i18n/zh-cn.tsv
@@ -3603,3 +3603,18 @@
 	No matching text.	没有匹配的文本。
 	H.265 (HEVC) needs a hardware encoder (NVENC on NVIDIA GPUs, VideoToolbox on macOS); none was found on this machine, and FilmCraft has no software H.265 encoder yet. Choose H.264 or ProRes instead.	H.265 (HEVC) 需要硬件编码器（NVIDIA GPU 上的 NVENC、macOS 上的 VideoToolbox）；本机未找到，且 FilmCraft 尚无软件 H.265 编码器。请改选 H.264 或 ProRes。
 	AV1 needs a hardware encoder; none was found on this machine, and FilmCraft has no software AV1 encoder yet.	AV1 需要硬件编码器；本机未找到，且 FilmCraft 尚无软件 AV1 编码器。
+# Modify Clip ▸ Interpret Footage
+	Assume this frame rate:	采用此帧速率：
+	Modify Clip	修改剪辑
+	Use Frame Rate from File:	使用文件中的帧速率：
+	fps	fps
+	Conform to:	符合：
+	Use Field Order from File:	使用文件中的场序：
+	Upper Field First	高场优先
+	Lower Field First	低场优先
+	FilmCraft plays every frame as a whole picture; conforming the field order is not supported yet.	FilmCraft 将每一帧作为完整画面播放；暂不支持更改场序。
+	Ignore Alpha Channel	忽略 Alpha 通道
+	Invert Alpha Channel	反转 Alpha 通道
+	(differs between the selected items)	（所选项目之间不同）
+	Changes apply to all {n} selected items; settings left as they are stay as each item has them.	更改将应用于所选的全部 {n} 个项目；未更改的设置保持各项目原样。
+	Various	多种
diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs
--- a/crates/ui-egui/src/menus.rs
+++ b/crates/ui-egui/src/menus.rs
@@ -479,7 +479,7 @@ pub fn invoke(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, mut params:
             return Ok(json!({"dialog": "audioGain"}));
         }
         // Colour dialogs from the menus; with params the engine command applies directly.
-        "clip.interpretFootage" if params.get("colorSpace").is_none() && params.get("pixelAspect").is_none() => {
+        "clip.interpretFootage" if ["colorSpace", "pixelAspect", "frameRate", "ignoreAlpha", "invertAlpha"].iter().all(|k| params.get(*k).is_none()) => {
             filmcraft_engine::find_command(id).map_or(Ok(()), |c| (c.enabled)(&app.session))?;
             crate::panels::color_dialogs::open_interpret(app, &params);
             return Ok(json!({"dialog": "interpretFootage"}));
diff --git a/crates/ui-egui/src/panels/color_dialogs.rs b/crates/ui-egui/src/panels/color_dialogs.rs
--- a/crates/ui-egui/src/panels/color_dialogs.rs
+++ b/crates/ui-egui/src/panels/color_dialogs.rs
@@ -1,15 +1,63 @@
-//! Colour dialogs: Clip ▸ Modify ▸ Interpret Footage… (Color Management) and Sequence ▸ Color
-//! Management… (working space, wide gamut, Auto Tone Map Media). Each applies one engine command
-//! on OK, so it is a single undo step. Automation ids: `colorDialog.space.<id>`,
-//! `colorDialog.working.<id>`, `colorDialog.wideGamut`, `colorDialog.autoToneMap`,
-//! `colorDialog.ok`, `colorDialog.cancel`.
+//! Colour dialogs: Clip ▸ Modify ▸ Interpret Footage… opens Modify Clip, with an Interpret Footage
+//! tab (Frame Rate, Field Order, Alpha Channel) and a Color tab (the media
+//! colour space), and Sequence ▸ Color Management… (working space, wide gamut, Auto Tone Map
+//! Media). Each applies one engine command on OK, so it is a single undo step. With several
+//! items selected, Modify Clip changes all of them, but only in the settings changed in the dialog;
+//! a group whose setting differs between the items says so. Automation ids:
+//! `colorDialog.tab.interpret`, `colorDialog.tab.color`, `colorDialog.rate.file`,
+//! `colorDialog.rate.assume`, `colorDialog.rate.value` (typed), `colorDialog.rate.list` and, while
+//! it is open, `colorDialog.rate.<fps>` (e.g. `colorDialog.rate.23.976`), `colorDialog.duration`,
+//! `colorDialog.fields.file`, `colorDialog.alpha.ignore`, `colorDialog.alpha.invert`,
+//! `colorDialog.space.list` and, while it is open, `colorDialog.space.auto` and
+//! `colorDialog.space.<id>`, `colorDialog.working.<id>`, `colorDialog.wideGamut`,
+//! `colorDialog.autoToneMap`, `colorDialog.ok`, `colorDialog.cancel`.
 
 use filmcraft_color::{ColorSpace, WorkingSpace};
+use filmcraft_project::MediaClip;
+use filmcraft_time::FrameRate;
 use serde_json::{Value, json};
 
 use crate::FilmcraftApp;
 use crate::state::ColorDialog;
 
+/// Width of the option column (the radio buttons) of the Interpret Footage groups.
+const OPTION_W: f32 = 215.0;
+
+/// The media of `items` (bins, sequences and missing items left out).
+fn medias(app: &FilmcraftApp, items: &[u64]) -> Vec<MediaClip> {
+    items.iter().filter_map(|i| app.session.project.item(filmcraft_project::ItemId(*i))).filter_map(|i| i.as_media()).cloned().collect()
+}
+
+/// How `m` is interpreted, as `clip.interpretFootage` parameters.
+fn settings(m: &MediaClip) -> Value {
+    json!({
+        "colorSpace": m.interpret.color_space.map_or("auto", |c| c.id()),
+        "frameRate": m.conform().map_or(json!("file"), |c| json!(c.assumed.as_f64())),
+        "ignoreAlpha": m.interpret.ignore_alpha,
+        "invertAlpha": m.interpret.invert_alpha,
+    })
+}
+
+/// The settings the items differ in (`clip.interpretFootage` parameter names).
+fn mixed_settings(ms: &[MediaClip]) -> Vec<String> {
+    let all: Vec<Value> = ms.iter().map(settings).collect();
+    let Some(first) = all.first() else { return Vec::new() };
+    ["colorSpace", "frameRate", "ignoreAlpha", "invertAlpha"].into_iter().filter(|k| all.iter().any(|v| v[*k] != first[*k])).map(str::to_string).collect()
+}
+
+/// `values` as one label, or "Various" when they differ.
+fn one_or_various(values: impl IntoIterator<Item = String>) -> String {
+    let mut it = values.into_iter();
+    let Some(first) = it.next() else { return String::new() };
+    if it.all(|v| v == first) { first } else { tl!("Various").to_string() }
+}
+
+fn touch(touched: &mut Vec<String>, key: &str) {
+    if !touched.iter().any(|k| k == key) {
+        touched.push(key.to_string());
+    }
+}
+
 pub fn open_interpret(app: &mut FilmcraftApp, params: &Value) {
     let items: Vec<u64> = match params.get("items").and_then(Value::as_array) {
         Some(a) => a.iter().filter_map(Value::as_u64).collect(),
@@ -30,7 +78,181 @@ pub fn open_interpret(app: &mut FilmcraftApp, params: &Value) {
         .and_then(|i| app.session.execute("media.colorInfo", json!({"item": i})).ok())
         .and_then(|v| v["override"].as_str().map(str::to_string))
         .unwrap_or_else(|| "auto".into());
-    app.ui.color_dialog = Some(ColorDialog::Interpret { items, color_space: current });
+    let ms = medias(app, &items);
+    let mixed = mixed_settings(&ms);
+    let m = ms.first();
+    let assume = m.and_then(MediaClip::conform).map(|c| c.assumed);
+    let file_rate = m.map(|m| m.info.frame_rate());
+    let tab = match params.get("tab").and_then(Value::as_str) {
+        Some("color") => "color",
+        _ => "interpret",
+    };
+    app.ui.color_dialog = Some(ColorDialog::Interpret {
+        items,
+        color_space: current,
+        assume_rate: assume.map(FrameRate::as_f64),
+        tab: tab.into(),
+        rate_text: assume.or(file_rate).map(FrameRate::label).unwrap_or_default(),
+        ignore_alpha: m.is_some_and(|m| m.interpret.ignore_alpha),
+        invert_alpha: m.is_some_and(|m| m.interpret.invert_alpha),
+        mixed,
+        touched: Vec::new(),
+    });
+}
+
+/// The rate typed in Assume this frame rate (`25`, `23.976`, `24000/1001`), if usable.
+fn parse_rate(text: &str) -> Option<FrameRate> {
+    filmcraft_engine::export_tools::parse_rate(&json!(text.trim())).filter(|r| r.is_usable())
+}
+
+/// The field order the file's pixel format names (MPEG-2 and MXF report interlaced pictures).
+fn file_field_order(m: &MediaClip) -> &'static str {
+    let f = m.info.video.as_ref().map(|v| v.pixel_format.to_ascii_lowercase()).unwrap_or_default();
+    if f.contains("upper field first") || f.contains("top field first") {
+        tl!("Upper Field First")
+    } else if f.contains("lower field first") || f.contains("bottom field first") {
+        tl!("Lower Field First")
+    } else {
+        tl!("No Fields (Progressive Scan)")
+    }
+}
+
+/// A titled group of the Interpret Footage tab; `mixed` notes that the selected items differ in it.
+fn group(ui: &mut egui::Ui, title: &str, mixed: bool, add: impl FnOnce(&mut egui::Ui)) {
+    ui.horizontal(|ui| {
+        ui.label(egui::RichText::new(title).strong());
+        if mixed {
+            ui.label(egui::RichText::new(tl!("(differs between the selected items)")).weak().italics());
+        }
+    });
+    egui::Frame::group(ui.style()).inner_margin(egui::Margin::symmetric(10, 8)).show(ui, |ui| {
+        ui.set_min_width(ui.available_width());
+        add(ui);
+    });
+    ui.add_space(6.0);
+}
+
+/// The Interpret Footage tab's choices (a view of the dialog's draft).
+struct Choices<'a> {
+    assume: &'a mut Option<f64>,
+    rate_text: &'a mut String,
+    ignore_alpha: &'a mut bool,
+    invert_alpha: &'a mut bool,
+    mixed: &'a [String],
+    /// The settings changed in the dialog (`clip.interpretFootage` parameter names).
+    touched: &'a mut Vec<String>,
+}
+
+/// The Interpret Footage tab for the media `ms` (at least one; the draft starts from the first).
+fn interpret_tab(app: &mut FilmcraftApp, ui: &mut egui::Ui, ms: &[MediaClip], c: Choices) {
+    let Some(m) = ms.first() else { return };
+    let moving = |m: &MediaClip| m.info.video.is_some() && !matches!(m.info.kind, filmcraft_media::MediaKind::Still);
+    let any_video = ms.iter().any(|m| m.info.video.is_some());
+    let has_rate = ms.iter().any(moving);
+    let file_rate = ms.iter().find(|m| moving(m)).map_or_else(|| m.info.frame_rate(), |m| m.info.frame_rate());
+    let mixed = |k: &str| c.mixed.iter().any(|m| m == k);
+    if ms.len() > 1 {
+        let n = ms.len();
+        ui.label(tlf!("Changes apply to all {n} selected items; settings left as they are stay as each item has them.", n));
+        ui.add_space(4.0);
+    }
+    let rate_before = (*c.assume, c.rate_text.clone());
+    group(ui, tl!("Frame Rate"), mixed("frameRate"), |ui| {
+        ui.add_enabled_ui(has_rate, |ui| {
+            egui::Grid::new("interpret-rate").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| {
+                if radio(app, ui, "colorDialog.rate.file", c.assume.is_none(), tl!("Use Frame Rate from File:"), OPTION_W) {
+                    *c.assume = None;
+                }
+                ui.label(one_or_various(ms.iter().filter(|m| moving(m)).map(|m| format!("{:.4}", m.info.frame_rate().as_f64()))));
+                ui.end_row();
+                if radio(app, ui, "colorDialog.rate.assume", c.assume.is_some(), tl!("Assume this frame rate:"), OPTION_W) && c.assume.is_none() {
+                    *c.assume = Some(parse_rate(c.rate_text).unwrap_or(file_rate).as_f64());
+                }
+                ui.horizontal(|ui| {
+                    let r = ui.add(egui::TextEdit::singleline(c.rate_text).desired_width(64.0));
+                    app.auto.add("colorDialog.rate.value", r.rect, "Assumed frame rate");
+                    // typing a rate chooses Assume this frame rate
+                    if r.changed()
+                        && let Some(rate) = parse_rate(c.rate_text)
+                    {
+                        *c.assume = Some(rate.as_f64());
+                    }
+                    let mut picked = None;
+                    let list = egui::ComboBox::from_id_salt("interpret-rate-list").selected_text("").width(20.0).show_ui(ui, |ui| {
+                        for rate in FrameRate::COMMON {
+                            let label = rate.label();
+                            let on = c.assume.is_some_and(|f| (f - rate.as_f64()).abs() < 0.0005);
+                            let r = ui.selectable_label(on, &label);
+                            app.auto.add(&format!("colorDialog.rate.{label}"), r.rect, &label);
+                            if r.clicked() {
+                                picked = Some(rate);
+                            }
+                        }
+                    });
+                    app.auto.add("colorDialog.rate.list", list.response.rect, "Frame rates");
+                    if let Some(rate) = picked {
+                        *c.assume = Some(rate.as_f64());
+                        *c.rate_text = rate.label();
+                    }
+                    ui.label(tl!("fps"));
+                });
+                ui.end_row();
+                // how long the footage plays at the rate chosen
+                let tc = one_or_various(ms.iter().filter(|m| moving(m)).map(|m| {
+                    let file = m.info.frame_rate();
+                    let playing = c.assume.and_then(|f| parse_rate(&f.to_string())).unwrap_or(file);
+                    filmcraft_time::format_timecode_frames(file.frame_at(m.info.duration), playing, playing.supports_drop_frame())
+                }));
+                ui.label(tl!("Duration:"));
+                let r = ui.label(&tc);
+                app.auto.add("colorDialog.duration", r.rect, &tc);
+                ui.end_row();
+            });
+        });
+    });
+    if (*c.assume, c.rate_text.clone()) != rate_before {
+        touch(c.touched, "frameRate");
+    }
+    group(ui, tl!("Field Order"), false, |ui| {
+        let why = tl!("FilmCraft plays every frame as a whole picture; conforming the field order is not supported yet.");
+        egui::Grid::new("interpret-fields").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| {
+            ui.add_enabled_ui(any_video, |ui| {
+                radio(app, ui, "colorDialog.fields.file", true, tl!("Use Field Order from File:"), OPTION_W);
+            });
+            let fields = one_or_various(ms.iter().filter(|m| m.info.video.is_some()).map(|m| file_field_order(m).to_string()));
+            ui.label(&fields);
+            ui.end_row();
+            ui.add_enabled_ui(false, |ui| {
+                ui.allocate_ui_with_layout(egui::vec2(OPTION_W, 18.0), egui::Layout::left_to_right(egui::Align::Center), |ui| {
+                    ui.radio(false, tl!("Conform to:")).on_disabled_hover_text(why);
+                });
+            });
+            ui.add_enabled_ui(false, |ui| {
+                egui::ComboBox::from_id_salt("interpret-fields-list")
+                    .selected_text(&fields)
+                    .width(200.0)
+                    .show_ui(ui, |_| {})
+                    .response
+                    .on_disabled_hover_text(why);
+            });
+            ui.end_row();
+        });
+    });
+    let has_alpha = ms.iter().any(|m| m.info.video.as_ref().is_some_and(|v| v.has_alpha));
+    group(ui, tl!("Alpha Channel"), mixed("ignoreAlpha") || mixed("invertAlpha"), |ui| {
+        ui.add_enabled_ui(has_alpha, |ui| {
+            let r = ui.checkbox(c.ignore_alpha, tl!("Ignore Alpha Channel"));
+            app.auto.add("colorDialog.alpha.ignore", r.rect, "Ignore Alpha Channel");
+            if r.changed() {
+                touch(c.touched, "ignoreAlpha");
+            }
+            let r = ui.add_enabled(!*c.ignore_alpha, egui::Checkbox::new(c.invert_alpha, tl!("Invert Alpha Channel")));
+            app.auto.add("colorDialog.alpha.invert", r.rect, "Invert Alpha Channel");
+            if r.changed() {
+                touch(c.touched, "invertAlpha");
+            }
+        });
+    });
 }
 
 pub fn open_sequence(app: &mut FilmcraftApp) {
@@ -38,8 +260,13 @@ pub fn open_sequence(app: &mut FilmcraftApp) {
     app.ui.color_dialog = Some(ColorDialog::Sequence { working_space: c.working.id().into(), wide_gamut: c.wide_gamut, auto_tone_map: c.auto_tone_map });
 }
 
-fn radio(app: &mut FilmcraftApp, ui: &mut egui::Ui, id: &str, selected: bool, label: &str) -> bool {
-    let r = ui.radio(selected, label);
+/// A radio button registered under `id`; `width` > 0 gives it a column of that width.
+fn radio(app: &mut FilmcraftApp, ui: &mut egui::Ui, id: &str, selected: bool, label: &str, width: f32) -> bool {
+    let r = if width > 0.0 {
+        ui.allocate_ui_with_layout(egui::vec2(width, 18.0), egui::Layout::left_to_right(egui::Align::Center), |ui| ui.radio(selected, label)).inner
+    } else {
+        ui.radio(selected, label)
+    };
     app.auto.add(id, r.rect, label);
     r.clicked()
 }
@@ -63,39 +290,84 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) {
     let mut apply: Option<(&'static str, Value)> = None;
     let mut close = false;
     match &mut d {
-        ColorDialog::Interpret { items, color_space } => {
+        ColorDialog::Interpret { items, color_space, assume_rate, tab, rate_text, ignore_alpha, invert_alpha, mixed, touched } => {
+            let ms = medias(app, items);
             let detected = items
                 .first()
                 .and_then(|i| app.session.execute("media.colorInfo", json!({"item": i})).ok())
                 .and_then(|v| v["detectedLabel"].as_str().map(str::to_string))
                 .unwrap_or_else(|| "—".into());
             let n = items.len();
-            egui::Window::new(tl!("Interpret Footage"))
+            egui::Window::new(tl!("Modify Clip"))
                 .id(egui::Id::new("Interpret Footage"))
                 .open(&mut open)
                 .collapsible(false)
                 .resizable(false)
-                .default_width(380.0)
+                .default_width(520.0)
                 .show(ctx, |ui| {
-                    ui.label(
-                        egui::RichText::new(if n == 1 { tlf!("Color Management · {n} item", n) } else { tlf!("Color Management · {n} items", n) }).strong(),
-                    );
-                    ui.label(tlf!("Media colour space (from file metadata): {detected}", detected));
-                    ui.add_space(4.0);
-                    ui.label(tl!("Override media colour space:"));
-                    if radio(app, ui, "colorDialog.space.auto", color_space == "auto", tl!("Use file metadata")) {
-                        *color_space = "auto".into();
-                    }
-                    egui::ScrollArea::vertical().max_height(300.0).show(ui, |ui| {
-                        for cs in ColorSpace::ALL {
-                            if radio(app, ui, &format!("colorDialog.space.{}", cs.id()), color_space == cs.id(), cs.label()) {
-                                *color_space = cs.id().into();
+                    ui.horizontal(|ui| {
+                        for (id, label) in [("color", tl!("Color")), ("interpret", tl!("Interpret Footage"))] {
+                            let r = ui.selectable_label(tab == id || (tab.is_empty() && id == "interpret"), label);
+                            app.auto.add(&format!("colorDialog.tab.{id}"), r.rect, label);
+                            if r.clicked() {
+                                *tab = id.into();
                             }
                         }
                     });
+                    ui.separator();
+                    if tab == "color" {
+                        ui.label(
+                            egui::RichText::new(if n == 1 { tlf!("Color Management · {n} item", n) } else { tlf!("Color Management · {n} items", n) }).strong(),
+                        );
+                        ui.label(tlf!("Media colour space (from file metadata): {detected}", detected));
+                        if mixed.iter().any(|k| k == "colorSpace") {
+                            ui.label(egui::RichText::new(tl!("(differs between the selected items)")).weak().italics());
+                        }
+                        ui.add_space(4.0);
+                        ui.horizontal(|ui| {
+                            ui.label(tl!("Override media colour space:"));
+                            let auto = tl!("Use file metadata");
+                            let shown = ColorSpace::ALL.iter().find(|cs| cs.id() == color_space).map_or(auto, |cs| cs.label());
+                            let mut picked = None;
+                            let list =
+                                egui::ComboBox::from_id_salt("interpret-color-space").selected_text(shown).width(240.0).height(360.0).show_ui(ui, |ui| {
+                                    let r = ui.selectable_label(color_space == "auto", auto);
+                                    app.auto.add("colorDialog.space.auto", r.rect, auto);
+                                    if r.clicked() {
+                                        picked = Some("auto");
+                                    }
+                                    for cs in ColorSpace::ALL {
+                                        let r = ui.selectable_label(color_space == cs.id(), cs.label());
+                                        app.auto.add(&format!("colorDialog.space.{}", cs.id()), r.rect, cs.label());
+                                        if r.clicked() {
+                                            picked = Some(cs.id());
+                                        }
+                                    }
+                                });
+                            app.auto.add("colorDialog.space.list", list.response.rect, "Media colour space");
+                            if let Some(id) = picked {
+                                *color_space = id.into();
+                                touch(touched, "colorSpace");
+                            }
+                        });
+                    } else if !ms.is_empty() {
+                        let choices = Choices { assume: assume_rate, rate_text, ignore_alpha, invert_alpha, mixed, touched };
+                        interpret_tab(app, ui, &ms, choices);
+                    }
                     let (ok, cancel) = buttons(app, ui);
-                    if ok {
-                        apply = Some(("clip.interpretFootage", json!({"items": items, "colorSpace": color_space})));
+                    // only the settings changed here: the others stay as each item has them
+                    if ok && !touched.is_empty() {
+                        let draft = json!({
+                            "colorSpace": color_space,
+                            "frameRate": assume_rate.map_or(json!("file"), |f| json!(f)),
+                            "ignoreAlpha": *ignore_alpha,
+                            "invertAlpha": *invert_alpha,
+                        });
+                        let mut p = json!({"items": items});
+                        for k in touched.iter() {
+                            p[k.as_str()] = draft[k.as_str()].clone();
+                        }
+                        apply = Some(("clip.interpretFootage", p));
                     }
                     close = ok || cancel;
                 });
@@ -110,7 +382,7 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) {
                 .show(ctx, |ui| {
                     ui.label(egui::RichText::new(tl!("Working Color Space")).strong());
                     for w in WorkingSpace::ALL {
-                        if radio(app, ui, &format!("colorDialog.working.{}", w.id()), working_space == w.id(), w.label()) {
+                        if radio(app, ui, &format!("colorDialog.working.{}", w.id()), working_space == w.id(), w.label(), 0.0) {
                             *working_space = w.id().into();
                         }
                     }
diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs
--- a/crates/ui-egui/src/state.rs
+++ b/crates/ui-egui/src/state.rs
@@ -799,8 +799,29 @@ impl Default for ProjectManagerDraft {
 /// Colour dialogs: drafts are applied with one engine command on OK.
 #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
 pub enum ColorDialog {
-    /// `clip.interpretFootage`: the media items and the chosen colour space id ("auto" = metadata).
-    Interpret { items: Vec<u64>, color_space: String },
+    /// Modify Clip (`clip.interpretFootage`): the media items, the tab shown ("interpret" or
+    /// "color"), the chosen colour space id ("auto" = metadata), the frame rate to assume (None =
+    /// the file's; in frames per second) as typed, and the alpha channel options. The draft starts from the first
+    /// item; `mixed` names the settings (`clip.interpretFootage` parameters) the items differ in,
+    /// and only the settings in `touched` are applied, so the others stay as each item has them.
+    Interpret {
+        items: Vec<u64>,
+        color_space: String,
+        #[serde(default)]
+        assume_rate: Option<f64>,
+        #[serde(default)]
+        tab: String,
+        #[serde(default)]
+        rate_text: String,
+        #[serde(default)]
+        ignore_alpha: bool,
+        #[serde(default)]
+        invert_alpha: bool,
+        #[serde(default)]
+        mixed: Vec<String>,
+        #[serde(default)]
+        touched: Vec<String>,
+    },
     /// `sequence.colorSettings`.
     Sequence { working_space: String, wide_gamut: bool, auto_tone_map: bool },
 }
diff --git a/crates/ui-egui/tests/it/color_ui.rs b/crates/ui-egui/tests/it/color_ui.rs
--- a/crates/ui-egui/tests/it/color_ui.rs
+++ b/crates/ui-egui/tests/it/color_ui.rs
@@ -1,5 +1,5 @@
 //! Headless UI tests of colour features: the Lumetri Color panel's LUT menus and section
-//! switches, Interpret Footage ▸ Color Management and the sequence colour settings, driven by
+//! switches, Interpret Footage ▸ Frame Rate and Color Management and the sequence colour settings, driven by
 //! automation id through the control channel.
 //!
 //! Set `FILMCRAFT_UI_SNAPSHOT_DIR=<dir>` to also render offscreen with wgpu and write `color-*.png`.
@@ -178,6 +178,10 @@ fn interpret_footage_and_sequence_color_dialogs_and_hdr_scopes() {
     let r = d.ok("ui.menu.invoke", json!({"id": "clip.interpretFootage"}));
     assert_eq!(r["dialog"], "interpretFootage");
     d.frames(3);
+    // Modify Clip opens on Interpret Footage; the colour space is on the Color tab
+    assert!(d.element("colorDialog.space.list").is_none());
+    d.click("colorDialog.tab.color");
+    d.click("colorDialog.space.list");
     d.click("colorDialog.space.slog3-sgamut3cine");
     d.snapshot("color-interpret-footage", None);
     d.click("colorDialog.ok");
@@ -204,6 +208,133 @@ fn interpret_footage_and_sequence_color_dialogs_and_hdr_scopes() {
     assert_eq!(info["override"], Value::Null, "{info}");
 }
 
+/// The duration in ticks of project item `item`, from the bin tree of `project.inspect`.
+fn item_duration(d: &mut Driver, item: &Value) -> i64 {
+    fn find(v: &Value, item: &Value) -> Option<i64> {
+        if v.get("item") == Some(item) {
+            return v["duration"].as_i64();
+        }
+        match v {
+            Value::Array(a) => a.iter().find_map(|x| find(x, item)),
+            Value::Object(o) => o.values().find_map(|x| find(x, item)),
+            _ => None,
+        }
+    }
+    let p = d.exec("project.inspect", json!({}));
+    find(&p, item).unwrap_or_else(|| panic!("item {item} not in {p}"))
+}
+
+/// Click the text field `id` and replace its text with `text`.
+fn retype(d: &mut Driver, id: &str, text: &str) {
+    d.click(id);
+    d.ok("ui.key", json!({"key": "Ctrl+A"}));
+    d.ok("ui.type", json!({"text": text}));
+    d.frames(2);
+}
+
+#[test]
+fn interpret_footage_assumes_a_frame_rate() {
+    let mut d = Driver::demo();
+    let clip = select_first_clip(&mut d);
+    let seq = d.exec("sequence.inspect", json!({}));
+    let item = seq["video"][0]["items"].as_array().unwrap().iter().find(|i| i["clip"] == clip).unwrap()["item"].clone();
+    let before = item_duration(&mut d, &item);
+    // Modify Clip ▸ Interpret Footage: the file's 23.976 fps is in use
+    d.ok("ui.menu.invoke", json!({"id": "clip.interpretFootage"}));
+    d.frames(3);
+    let ids = d.ids("colorDialog.");
+    for id in [
+        "colorDialog.tab.color",
+        "colorDialog.tab.interpret",
+        "colorDialog.rate.file",
+        "colorDialog.rate.assume",
+        "colorDialog.rate.value",
+        "colorDialog.rate.list",
+        "colorDialog.duration",
+        "colorDialog.fields.file",
+        "colorDialog.alpha.ignore",
+        "colorDialog.alpha.invert",
+    ] {
+        assert!(ids.iter().any(|i| i == id), "{id} missing: {ids:?}");
+    }
+    d.snapshot("color-interpret-footage-tab", None);
+    // pick 25 fps from the list: the footage plays every frame at 25 fps and gets shorter by 23.976/25
+    d.click("colorDialog.rate.list");
+    d.click("colorDialog.rate.25");
+    d.click("colorDialog.ok");
+    let after = item_duration(&mut d, &item);
+    let want = before as f64 * 24_000.0 / 1001.0 / 25.0;
+    assert!((after as f64 - want).abs() < 254_016_000_000.0 / 25.0, "{before} -> {after}, want ≈{want}");
+    // a typed rate (50)
+    d.ok("ui.menu.invoke", json!({"id": "clip.interpretFootage"}));
+    d.frames(3);
+    retype(&mut d, "colorDialog.rate.value", "50");
+    d.snapshot("color-interpret-typed", None);
+    d.click("colorDialog.ok");
+    let half = before as f64 * 24_000.0 / 1001.0 / 50.0;
+    assert!((item_duration(&mut d, &item) as f64 - half).abs() < 254_016_000_000.0 / 50.0);
+    let info = d.exec("clip.interpretFootage", json!({"items": [item], "colorSpace": "auto"}));
+    assert_eq!(info["frameRate"], "50", "{info}");
+    // and the file's rate back
+    d.ok("ui.menu.invoke", json!({"id": "clip.interpretFootage"}));
+    d.frames(3);
+    d.click("colorDialog.rate.file");
+    d.click("colorDialog.ok");
+    assert_eq!(item_duration(&mut d, &item), before);
+}
+
+/// How item `item` is interpreted: (frame rate override, colour space id).
+fn interpretation(d: &mut Driver, item: u64) -> (Option<String>, Option<String>) {
+    let m = d.app().session.project.item(filmcraft_project::ItemId(item)).and_then(|i| i.as_media()).unwrap().interpret.clone();
+    (m.frame_rate.map(|r| r.label()), m.color_space.map(|c| c.id().to_string()))
+}
+
+#[test]
+fn interpret_footage_changes_several_items_in_what_is_changed_only() {
+    let mut d = Driver::demo();
+    let seq = d.exec("sequence.inspect", json!({}));
+    let mut items: Vec<u64> = seq["video"][0]["items"].as_array().unwrap().iter().filter_map(|i| i["item"].as_u64()).collect();
+    items.dedup();
+    let (a, b) = (items[0], items[1]);
+    // b already plays at 25 fps in S-Log3; a is as its file says
+    d.exec("clip.interpretFootage", json!({"items": [b], "frameRate": 25, "colorSpace": "slog3-sgamut3cine"}));
+    let before_a = item_duration(&mut d, &json!(a));
+    // both selected in the Project panel: the frame rate differs between them, so the dialog says so
+    d.exec("project.select", json!({"items": [a, b]}));
+    d.ok("ui.menu.invoke", json!({"id": "clip.interpretFootage"}));
+    d.frames(3);
+    let dialog = serde_json::to_value(&d.app().ui.color_dialog).unwrap();
+    assert_eq!(dialog["Interpret"]["mixed"], json!(["colorSpace", "frameRate"]), "{dialog}");
+    d.snapshot("color-interpret-several", None);
+    // only the colour space is changed: both get it, each keeps its frame rate
+    d.click("colorDialog.tab.color");
+    d.frames(2);
+    d.click("colorDialog.space.list");
+    d.click("colorDialog.space.slog3-sgamut3cine");
+    d.click("colorDialog.ok");
+    assert_eq!(interpretation(&mut d, a), (None, Some("slog3-sgamut3cine".into())));
+    assert_eq!(interpretation(&mut d, b), (Some("25".into()), Some("slog3-sgamut3cine".into())));
+    assert_eq!(item_duration(&mut d, &json!(a)), before_a);
+    // then the frame rate: both play at 50 fps (one undo step), colour spaces untouched
+    d.exec("clip.interpretFootage", json!({"items": [a], "colorSpace": "auto"}));
+    d.ok("ui.menu.invoke", json!({"id": "clip.interpretFootage"}));
+    d.frames(3);
+    d.click("colorDialog.rate.list");
+    d.click("colorDialog.rate.50");
+    d.click("colorDialog.ok");
+    assert_eq!(interpretation(&mut d, a), (Some("50".into()), None));
+    assert_eq!(interpretation(&mut d, b), (Some("50".into()), Some("slog3-sgamut3cine".into())));
+    d.exec("edit.undo", json!({}));
+    assert_eq!(interpretation(&mut d, a).0, None);
+    assert_eq!(interpretation(&mut d, b).0, Some("25".into()));
+    // OK without changing anything changes nothing
+    let rev = d.app().session.revision;
+    d.ok("ui.menu.invoke", json!({"id": "clip.interpretFootage"}));
+    d.frames(3);
+    d.click("colorDialog.ok");
+    assert_eq!(d.app().session.revision, rev);
+}
+
 #[test]
 fn apply_match_from_the_color_wheels_section() {
     let mut d = Driver::demo();
diff --git a/docs/architecture.md b/docs/architecture.md
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -226,6 +226,17 @@ file ──► codecs (MP4/MOV, MKV, audio)        demux + decode, GOP-aware see
   `audio(start, frames, rate)` in media time. Sources are `Send + Sync` and shared by monitors,
   thumbnails, playback and export. The engine's `MediaPool` creates one per project item, lazily,
   through registered openers (`codecs::openers()`: MP4/MOV, MKV/WebM, audio files).
+- **Assumed frame rate.** Interpret Footage ▸ Assume this frame rate (`Interpretation::frame_rate`,
+  `clip.interpretFootage {frameRate}`) conforms footage: every file frame plays once at the assumed
+  rate, so media time is conformed time (`MediaClip::conform`, `MediaClip::duration`). The
+  `MediaPool` wraps the file's source (or proxy) in `InterpretSource`, which maps picture times
+  frame for frame (`project::retime`) and reads sound at a scaled sample rate (it stays in sync and
+  changes pitch). Proxies are made from the unconformed file. Changing the rate runs
+  `Project::retime_media`: marks, markers, subclips and every clip's source In, frame hold, clip
+  markers and keyframes stay on their frames; clips keep their place and length on the timeline and
+  are shortened only if they would run past the new end. The same wrapper applies Alpha Channel ▸
+  Ignore (opaque) / Invert (`clip.interpretFootage {ignoreAlpha, invertAlpha}`) to footage with
+  alpha.
 - **Pixel aspect ratio.** Demuxers report it in `VideoStreamInfo::par` (MP4/MOV `pasp`, Matroska
   display size, MXF aspect ratio, MPEG sequence header / VUI); Interpret Footage's
   `Interpretation::par` overrides it (`clip.interpretFootage {pixelAspect}`), and
diff --git a/docs/control-protocol.md b/docs/control-protocol.md
--- a/docs/control-protocol.md
+++ b/docs/control-protocol.md
@@ -170,7 +170,7 @@ no media outside its range, so there a kept In is moved to the subclip's first o
 the result's `sourceInClamped: [ids]` (present with `keepSourceIn`) names the clips it was moved for.
 
 **Colour** (`crates/engine/src/color.rs`): `sequence.colorSettings {workingSpace: rec709|rec2100-pq|
-rec2100-hlg, wideGamut, autoToneMap}`, `clip.interpretFootage {items?, colorSpace?: auto|<id>, pixelAspect?: [num, den]|file}`,
+rec2100-hlg, wideGamut, autoToneMap}`, `clip.interpretFootage {items?, colorSpace?: auto|<id>, pixelAspect?: [num, den]|file, frameRate?: fps|"num/den"|file, ignoreAlpha?, invertAlpha?}`,
 `color.spaces`, `media.colorInfo {item}`; LUTs: `lut.import {path, name?}`, `lut.list`,
 `lut.remove {id}`, `lut.export {lut, path, format?}`, `lumetri.setInputLut` / `lumetri.setLook
 {clip?, lut: lib:<id>|builtin:<id>|"", path?}`, `lumetri.setSection {clip?, section, on?}`,
diff --git a/docs/gaps.md b/docs/gaps.md
--- a/docs/gaps.md
+++ b/docs/gaps.md
@@ -291,6 +291,10 @@ platforms** G4, G9, G17; **performance** G16; **ecosystem** G11; **measurement**
   (and occlusion edges are soft), Lumetri HDR-aware maths, multicam paging beyond 16 angles,
   redesigned 26.0 mask tools (a stored, re-editable
   corner radius: rounding bakes fillets into the path, so a rounded corner cannot be re-rounded later).
+  Interlaced footage: decoders weave both fields into one frame and nothing splits, reorders or
+  deinterlaces fields, so Interpret Footage ▸ Field Order ▸ Conform to is shown but disabled (needs
+  field-aware decoding, a field-order override per item and deinterlacing for progressive
+  sequences).
 - **Have:** Optical Flow time interpolation synthesises motion-compensated in-between frames (dense
   coarse-to-fine Lucas–Kanade, CPU; #780); Morph Cut morphs the subject along that optical flow
   instead of cross-dissolving (CPU; #805); Transform shutter angle motion blur (#415); Ultra Key
diff --git a/docs/testing.md b/docs/testing.md
--- a/docs/testing.md
+++ b/docs/testing.md
@@ -228,7 +228,7 @@ delete), the queue panel (Send to Queue, Up/Down, Cancel, Start, Retry, Clear) w
 the Export button and the header's Quick Export popup (`export-*.png` snapshots).
 
 `crates/ui-egui/tests/it/color_ui.rs` drives colour features the same way (`color-*.png` snapshots):
-Lumetri Input/Look LUT menus and section switches, the Interpret Footage ▸ Color Management and
+Lumetri Input/Look LUT menus and section switches, the Interpret Footage ▸ Frame Rate and Color Management and
 Sequence Color Management dialogs, and the HDR scopes of a PQ sequence.
 
 Colour science tests: `filmcraft-color` (curve round trips and published reference values per
