diff --git a/crates/color/src/lib.rs b/crates/color/src/lib.rs
--- a/crates/color/src/lib.rs
+++ b/crates/color/src/lib.rs
@@ -20,7 +20,7 @@ pub use grade::GradeSpace;
 pub use log::LogCurve;
 pub use lut::{Lut, Lut1d, Lut3d, LutFormat};
 pub use spaces::{ColorPipeline, ColorSpace, Curve, Gamut, WorkingSpace};
-pub use transform::{DecodeTable, GpuCurve, GpuInputParams, InputTransform, OutputTransform, REFERENCE_WHITE_NITS, ToneMap};
+pub use transform::{DecodeTable, GpuCurve, GpuInputParams, GpuOutputParams, InputTransform, OutputTransform, REFERENCE_WHITE_NITS, ToneMap};
 
 /// YUV ↔ RGB matrix coefficients.
 #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
diff --git a/crates/color/src/transform.rs b/crates/color/src/transform.rs
--- a/crates/color/src/transform.rs
+++ b/crates/color/src/transform.rs
@@ -389,6 +389,37 @@ impl OutputTransform {
             curve => l.map(|v| encode_channel(v as f64, curve, Range::Full).clamp(0.0, 1.0) as f32),
         }
     }
+
+    /// The transform as plain numbers for a shader, which runs [`apply`](Self::apply)'s stages
+    /// (BT.2390 tone mapping, the working → target matrix, desaturation into the target gamut) and
+    /// encodes the target's SDR curve itself. `None` for a target the shader does not encode (an
+    /// HDR export target), which stays on the CPU.
+    pub fn gpu_params(&self) -> Option<GpuOutputParams> {
+        if self.target.curve() != Curve::Sdr {
+            return None;
+        }
+        Some(GpuOutputParams {
+            target: self.target,
+            matrix: (!self.identity_matrix).then_some(self.matrix),
+            tone: self.tone.as_ref().map(|t| (t.src_peak_nits as f32, t.dst_peak_nits as f32)),
+            compress: self.compress,
+        })
+    }
+}
+
+/// [`OutputTransform`] as plain numbers for a shader ([`OutputTransform::gpu_params`]): the stages
+/// after the compositor — tone mapping, working → target matrix, gamut desaturation, in the order
+/// [`OutputTransform::apply`] runs them — with the target's SDR curve left to the shader's resolve.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct GpuOutputParams {
+    /// The target colour space (SDR: the shader's resolve is the sRGB curve).
+    pub target: ColorSpace,
+    /// Working → target primaries (row-major), when they differ.
+    pub matrix: Option<[[f32; 3]; 3]>,
+    /// BT.2390 tone mapping (source peak, target peak) in cd/m², on max(R,G,B) as a ratio.
+    pub tone: Option<(f32, f32)>,
+    /// Target luma weights to desaturate into the target gamut with ([`desaturate_into_gamut`]).
+    pub compress: Option<[f32; 3]>,
 }
 
 /// Convenience: a log colour space → display Rec. 709 (tone mapped), used for the built-in
@@ -550,4 +581,20 @@ mod tests {
         // plain pipeline is an identity
         assert!(OutputTransform::display(&ColorPipeline::REC709).is_identity());
     }
+
+    #[test]
+    fn output_gpu_params_cover_the_display_transform() {
+        // a plain pipeline: the shader's sRGB resolve is the whole transform
+        let plain = OutputTransform::display(&ColorPipeline::REC709).gpu_params().unwrap();
+        assert!(plain.matrix.is_none() && plain.tone.is_none() && plain.compress.is_none());
+        // an HDR working space on a SDR monitor: tone mapping, the working → 709 matrix and the
+        // gamut compression, the stages the resolve pass runs
+        let hdr = OutputTransform::display(&self::hdr(WorkingSpace::Rec2100Hlg)).gpu_params().unwrap();
+        assert_eq!(hdr.target, ColorSpace::Rec709);
+        assert!(hdr.matrix.is_some() && hdr.compress.is_some());
+        assert_eq!(hdr.tone, Some((HDR_PEAK_NITS as f32, REFERENCE_WHITE_NITS as f32)));
+        // an HDR export target has no shader encode yet: it stays on the CPU
+        let export = OutputTransform::new(&self::hdr(WorkingSpace::Rec2100Hlg), ColorSpace::Rec2100Pq);
+        assert!(export.gpu_params().is_none());
+    }
 }
diff --git a/crates/golden/tests/golden.rs b/crates/golden/tests/golden.rs
--- a/crates/golden/tests/golden.rs
+++ b/crates/golden/tests/golden.rs
@@ -775,6 +775,9 @@ fn gpu_matches_cpu_on_golden_scenes() {
         let cpu = render_cpu(&s);
         let plan = filmcraft_render::plan::plan_frame(&s.project, s.seq, t, RenderOptions::default(), &s.sources);
         let kind = if matches!(plan, filmcraft_render::plan::FramePlan::Layers { .. }) { "layers" } else { "cpu image" };
+        // the resolve pass runs the sequence's display transform, as the app sets it (an HDR
+        // working space composites in working linear and is tone mapped there)
+        c.set_display_transform(display_for(&s, &plan));
         c.composite(&plan);
         let (gw, gh, mut px) = c.read_output().expect("GPU readback");
         px.as_chunks_mut::<4>().0.iter_mut().for_each(|p| p[3] = 255);
@@ -793,6 +796,44 @@ fn gpu_matches_cpu_on_golden_scenes() {
     assert!(failures.is_empty(), "GPU differs from the CPU reference:\n{}", failures.join("\n"));
 }
 
+/// The resolve pass's display transform for `plan` of scene `s`, chosen as the app chooses it
+/// (`gpu_present`): none for a CPU-rendered frame, which is already in monitor space.
+fn display_for(s: &Scene, plan: &filmcraft_render::plan::FramePlan) -> Option<filmcraft_color::GpuOutputParams> {
+    let pipe = s.project.sequence(s.seq).map(|q| q.settings.color);
+    filmcraft_gpu::sequence_display(plan, pipe.and_then(|p| filmcraft_render::colorman::display_transform(&p).gpu_params()))
+}
+
+/// A frame of an HDR (Rec. 2100 PQ) sequence that the compositor cannot finish (a Wipe) comes
+/// back as one CPU image, already converted for the monitor: the GPU shows it as it is, without
+/// tone mapping it a second time.
+#[test]
+fn gpu_shows_an_hdr_cpu_frame_as_rendered() {
+    let Some((dev, q)) = device("gpu hdr cpu frame", false) else {
+        return;
+    };
+    let mut b = Builder::new(1);
+    b.p.sequence_mut(b.seq).unwrap().settings.color =
+        filmcraft_color::ColorPipeline { working: filmcraft_color::WorkingSpace::Rec2100Pq, ..filmcraft_color::ColorPipeline::REC709 };
+    let pq = ColorInfo { transfer: Transfer::Pq, primaries: Primaries::Bt2020, ..ColorInfo::SRGB_FULL };
+    let a = b.encoded(DemoScene::CityNight, ColorSpace::Rec2100Pq, 6.0, Some(pq), None);
+    let z = b.demo(DemoScene::Aurora);
+    let ca = b.place(0, a, 0, 24);
+    let cz = b.place(0, z, 24, 24);
+    b.transition(0, "wipe", ca, cz, 24, 12);
+    let s = b.at(24);
+    let t = RATE.tick_of(s.frame);
+    let plan = filmcraft_render::plan::plan_frame(&s.project, s.seq, t, RenderOptions::default(), &s.sources);
+    assert!(matches!(plan, filmcraft_render::plan::FramePlan::Image(_)), "a Wipe is rendered on the CPU");
+    let mut c = filmcraft_gpu::GpuCompositor::new(&dev, &q);
+    c.set_display_transform(display_for(&s, &plan));
+    c.composite(&plan);
+    let (gw, gh, mut px) = c.read_output().expect("GPU readback");
+    px.as_chunks_mut::<4>().0.iter_mut().for_each(|p| p[3] = 255);
+    let d = diff(&render_cpu(&s), &Rgba8::new(gw, gh, px)).unwrap();
+    let mean_rgb = d.mean_abs * 4.0 / 3.0;
+    assert!(d.p99 <= 6 && mean_rgb < 1.5, "HDR CPU frame on the GPU differs from the CPU: {d}");
+}
+
 /// Per-pixel max RGB difference (99th percentile, mean) over the pixels at least 1.5 px from every
 /// plan layer's quad outline: the CPU resampler fades a layer's edge over one more pixel than the
 /// GPU rasterises, and with blend modes those edge pixels are a visible share of a 320×180 frame.
diff --git a/crates/gpu/src/composite.wgsl b/crates/gpu/src/composite.wgsl
--- a/crates/gpu/src/composite.wgsl
+++ b/crates/gpu/src/composite.wgsl
@@ -394,6 +394,21 @@ fn fs_blend(in: VOut) -> @location(0) vec4<f32> {
 
 @group(0) @binding(0) var accum: texture_2d<f32>;
 
+// The resolve pass's display transform (`filmcraft_color::OutputTransform`, #965): the sequence's
+// working linear → monitor. Same stages as `OutputTransform::apply`, in the same order: BT.2390
+// tone mapping (from an HDR working space), the working → target gamut matrix, then desaturation
+// into the target gamut. `a.x` is 0 for a plain sequence (the accumulator is already monitor
+// space) and the sRGB encode below is the whole transform.
+struct O {
+    a: vec4<f32>,   // on, target curve (0 sdr), tone mapping on, matrix on
+    b: vec4<f32>,   // desaturation on, tone source peak cd/m², tone target peak cd/m², reference white cd/m²
+    m0: vec4<f32>,  // matrix row 0, desaturation luma weight 0
+    m1: vec4<f32>,  // matrix row 1, desaturation luma weight 1
+    m2: vec4<f32>,  // matrix row 2, desaturation luma weight 2
+};
+
+@group(0) @binding(1) var<uniform> disp: O;
+
 @vertex
 fn vs_full(@builtin(vertex_index) vi: u32) -> @builtin(position) vec4<f32> {
     let x = f32((vi << 1u) & 2u) * 2.0 - 1.0;
@@ -401,9 +416,44 @@ fn vs_full(@builtin(vertex_index) vi: u32) -> @builtin(position) vec4<f32> {
     return vec4(x, y, 0.0, 1.0);
 }
 
+// Working linear → monitor linear.
+fn display(rgb: vec3<f32>) -> vec3<f32> {
+    if disp.a.x < 0.5 { return rgb; }
+    var c = rgb;
+    if disp.a.z > 0.5 {
+        // `ToneMap::apply`: one scale from max(R,G,B), the EETF sampled in the PQ domain
+        let m = max(max(c.r, c.g), c.b);
+        if m > 0.0 {
+            let nits = m * disp.b.w;
+            var k = 1.0;
+            if nits >= disp.b.y {
+                // above the source peak: clip to the target peak
+                k = disp.b.z / disp.b.w / m;
+            } else if nits > 1e-6 {
+                k = eetf(nits, disp.b.y, disp.b.z) / nits;
+            }
+            c = c * k;
+        }
+    }
+    if disp.a.w > 0.5 { c = vec3(dot(disp.m0.xyz, c), dot(disp.m1.xyz, c), dot(disp.m2.xyz, c)); }
+    if disp.b.x > 0.5 {
+        // `desaturate_into_gamut`: out-of-gamut colours towards their luminance, in-gamut exactly
+        let mn = min(min(c.r, c.g), c.b);
+        if mn < 0.0 {
+            let y = dot(vec3(disp.m0.w, disp.m1.w, disp.m2.w), c);
+            if y <= 0.0 {
+                c = vec3(0.0);
+            } else {
+                c = max(y + (c - y) * (y / (y - mn)), vec3(0.0));
+            }
+        }
+    }
+    return c;
+}
+
 @fragment
 fn fs_full(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
-    let c = clamp(textureLoad(accum, vec2<i32>(pos.xy), 0).rgb, vec3(0.0), vec3(1.0));
+    let c = clamp(display(textureLoad(accum, vec2<i32>(pos.xy), 0).rgb), vec3(0.0), vec3(1.0));
     let lo = c * 12.92;
     let hi = 1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055;
     return vec4(select(hi, lo, c <= vec3(0.0031308)), 1.0);
diff --git a/crates/gpu/src/fx.rs b/crates/gpu/src/fx.rs
--- a/crates/gpu/src/fx.rs
+++ b/crates/gpu/src/fx.rs
@@ -11,10 +11,16 @@
 //! Box blurs (Gaussian Blur, Camera Blur, the blur inside Unsharp / Sharpen) run per pixel for
 //! radii up to [`BOX_PER_PIXEL_MAX`] and as a running sum per row / column above, so a huge
 //! radius costs O(1) per pixel like on the CPU (radii are capped by `gaussian_boxes`).
+//!
+//! Lumetri's Input LUT and Look LUT run as a LUT pass before and after its grade pass. The pass
+//! reads the table from a storage buffer (the 1D shaper's entries, then the cube's), uploaded once
+//! per LUT and kept while frames use it.
 
 use std::collections::{HashMap, HashSet};
+use std::sync::Arc;
 
-use filmcraft_render::gpufx::FxOp;
+use filmcraft_color::{GradeSpace, Lut};
+use filmcraft_render::gpufx::{FxOp, LumetriLut};
 use filmcraft_render::plan::LayerFx;
 
 /// Working texture format.
@@ -52,6 +58,7 @@ const OP_MIRROR: u32 = 28;
 const OP_OFFSET: u32 = 29;
 const OP_VIDEO_LIMITER: u32 = 30;
 const OP_LUMETRI: u32 = 31;
+const OP_LUT: u32 = 32;
 
 type Target = (wgpu::Texture, wgpu::TextureView);
 
@@ -63,6 +70,12 @@ pub(crate) struct FxStage {
     source: wgpu::RenderPipeline,
     pool: HashMap<(u32, u32), Vec<Target>>,
     used: HashSet<(u32, u32)>,
+    /// LUT tables by the address of their `Arc<Lut>`, which the entry keeps alive (so a new LUT
+    /// can't take a freed one's address and be drawn with its table).
+    luts: HashMap<usize, (Arc<Lut>, wgpu::Buffer)>,
+    luts_used: HashSet<usize>,
+    /// Bound as the table of passes that read none.
+    no_lut: wgpu::Buffer,
 }
 
 /// One compute pass of a job.
@@ -93,6 +106,8 @@ struct Step {
     combine: bool,
     /// A blur pass of Unsharp Mask (the original stays untouched until the combine).
     unsharp_blur: bool,
+    /// The table of a LUT pass.
+    lut: Option<Arc<Lut>>,
 }
 
 fn step(code: u32, i1: [u32; 4], p: &[f32]) -> Step {
@@ -100,7 +115,64 @@ fn step(code: u32, i1: [u32; 4], p: &[f32]) -> Step {
     for (d, s) in q.iter_mut().zip(p) {
         *d = *s;
     }
-    Step { run: false, code, i1, p: q, combine: false, unsharp_blur: false }
+    Step { run: false, code, i1, p: q, combine: false, unsharp_blur: false, lut: None }
+}
+
+/// The grading signal as `fx.wgsl`'s `grade_enc` / `grade_dec` read it: mode (0 sRGB, 1 PQ, 2
+/// HLG) and its two numbers (PQ: norm; HLG: white, gamma).
+fn grade_mode(g: GradeSpace) -> (u32, f32, f32) {
+    match g {
+        GradeSpace::Sdr => (0, 0.0, 0.0),
+        GradeSpace::Pq { norm, .. } => (1, norm, 0.0),
+        GradeSpace::Hlg { white, gamma } => (2, white, gamma),
+    }
+}
+
+/// A LUT pass (`LumetriLut::apply`): i1 = cube size, shaper size (0: none), grading mode, mix;
+/// p0 = cube domain min + mix amount, p1 = cube domain max, p2 / p3 = shaper domain, with the
+/// grading numbers in p3.w / p4.w where `grade_enc` reads them.
+fn lut_step(l: &LumetriLut) -> Step {
+    let (mode, g0, g1) = grade_mode(l.grade);
+    let (cube_n, cmin, cmax) = l.lut.cube.as_ref().map_or((0, [0.0; 3], [1.0; 3]), |c| (c.size as u32, c.domain_min, c.domain_max));
+    let (shaper_n, smin, smax) = l.lut.shaper.as_ref().map_or((0, [0.0; 3], [1.0; 3]), |s| (s.data.len() as u32, s.domain_min, s.domain_max));
+    let p = [
+        cmin[0],
+        cmin[1],
+        cmin[2],
+        l.mix.unwrap_or(1.0),
+        cmax[0],
+        cmax[1],
+        cmax[2],
+        0.0,
+        smin[0],
+        smin[1],
+        smin[2],
+        0.0,
+        smax[0],
+        smax[1],
+        smax[2],
+        g0,
+        0.0,
+        0.0,
+        0.0,
+        g1,
+    ];
+    let mut s = step(OP_LUT, [cube_n, shaper_n, mode, l.mix.is_some() as u32], &p);
+    s.lut = Some(l.lut.clone());
+    s
+}
+
+/// A LUT's table as the LUT pass reads it: the shaper's entries, then the cube's (red fastest),
+/// one `vec4` each.
+fn lut_bytes(l: &Lut) -> Vec<u8> {
+    let shaper = l.shaper.iter().flat_map(|s| &s.data);
+    let cube = l.cube.iter().flat_map(|c| &c.data);
+    let mut bytes: Vec<u8> = shaper.chain(cube).flat_map(|c| [c[0], c[1], c[2], 0.0]).flat_map(f32::to_le_bytes).collect();
+    if bytes.is_empty() {
+        // a LUT without tables still binds a buffer (zero-sized bindings are invalid)
+        bytes.resize(16, 0);
+    }
+    bytes
 }
 
 /// Box passes along x (vertical = false) or y for the radii (radius 0 is a no-op on the CPU).
@@ -206,8 +278,17 @@ fn steps(op: &FxOp) -> Vec<Step> {
             vround,
             vfeather,
             aspect,
+            grade,
+            specular,
+            input_lut,
+            look_lut,
             ..
         } => {
+            // the grading signal: mode 0 sRGB, 1 PQ (p3.w = norm), 2 HLG (p3.w = white, p4.w = gamma)
+            let (mode, g0, g1) = grade_mode(*grade);
+            if let Some(l) = input_lut {
+                out.push(lut_step(l));
+            }
             let ge = [gains[0] * exposure, gains[1] * exposure, gains[2] * exposure];
             let b0 = -bl * 0.15;
             let w0 = 1.0 - wh * 0.15;
@@ -233,17 +314,20 @@ fn steps(op: &FxOp) -> Vec<Step> {
                 *vmid,
                 *vfeather,
                 vround_aspect,
-                0.0,
+                g0,
                 st_k[0],
                 st_k[1],
                 st_k[2],
-                0.0,
+                g1,
                 ht_k[0],
                 ht_k[1],
                 ht_k[2],
-                0.0,
+                *specular,
             ];
-            out.push(step(OP_LUMETRI, [*creative_on as u32, *vignette_on as u32, 0, 0], &p));
+            out.push(step(OP_LUMETRI, [*creative_on as u32, *vignette_on as u32, mode, 0], &p));
+            if let Some(l) = look_lut {
+                out.push(lut_step(l));
+            }
         }
     }
     out
@@ -255,6 +339,7 @@ impl FxStage {
     pub(crate) fn supported(device: &wgpu::Device) -> bool {
         let l = device.limits();
         l.max_storage_textures_per_shader_stage >= 1
+            && l.max_storage_buffers_per_shader_stage >= 1
             && l.max_compute_invocations_per_workgroup >= 256
             && l.max_compute_workgroup_size_x >= 64
             && l.max_compute_workgroup_size_y >= 16
@@ -297,6 +382,16 @@ impl FxStage {
                     count: None,
                 },
                 tex(3),
+                wgpu::BindGroupLayoutEntry {
+                    binding: 4,
+                    visibility: wgpu::ShaderStages::COMPUTE,
+                    ty: wgpu::BindingType::Buffer {
+                        ty: wgpu::BufferBindingType::Storage { read_only: true },
+                        has_dynamic_offset: false,
+                        min_binding_size: None,
+                    },
+                    count: None,
+                },
             ],
         });
         let pl = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { label: Some("fx"), bind_group_layouts: &[Some(&bgl)], immediate_size: 0 });
@@ -331,24 +426,66 @@ impl FxStage {
             multiview_mask: None,
             cache: None,
         });
-        Self { px: compute("fx_px"), run: compute("fx_run"), bgl, source, pool: HashMap::new(), used: HashSet::new() }
+        let no_lut =
+            device.create_buffer(&wgpu::BufferDescriptor { label: Some("fx-no-lut"), size: 16, usage: wgpu::BufferUsages::STORAGE, mapped_at_creation: false });
+        Self {
+            px: compute("fx_px"),
+            run: compute("fx_run"),
+            bgl,
+            source,
+            pool: HashMap::new(),
+            used: HashSet::new(),
+            luts: HashMap::new(),
+            luts_used: HashSet::new(),
+            no_lut,
+        }
     }
 
     /// Start a frame: pooled textures of sizes no layer uses by [`end_frame`](Self::end_frame)
     /// are released then.
     pub(crate) fn begin_frame(&mut self) {
         self.used.clear();
+        self.luts_used.clear();
     }
 
     pub(crate) fn end_frame(&mut self) {
         let used = &self.used;
         self.pool.retain(|k, _| used.contains(k));
+        let used = &self.luts_used;
+        self.luts.retain(|k, _| used.contains(k));
     }
 
-    /// No working textures held.
+    /// No working textures (or LUT tables) held.
     #[cfg(test)]
     pub(crate) fn is_idle(&self) -> bool {
-        self.pool.is_empty()
+        self.pool.is_empty() && self.luts.is_empty()
+    }
+
+    /// The storage buffer holding `lut`'s table (uploaded on first use). None when the table is
+    /// larger than the device lets a shader read (`LumetriLut::gpu_ok` keeps such tables on the
+    /// CPU; this guards callers that did not ask it).
+    fn lut_buffer(&mut self, device: &wgpu::Device, queue: &wgpu::Queue, lut: &Arc<Lut>) -> Option<wgpu::Buffer> {
+        let key = Arc::as_ptr(lut) as usize;
+        self.luts_used.insert(key);
+        if let Some((_, buf)) = self.luts.get(&key) {
+            return Some(buf.clone());
+        }
+        let entries = lut.shaper.as_ref().map_or(0, |s| s.data.len()).saturating_add(lut.cube.as_ref().map_or(0, |c| c.data.len()));
+        let limits = device.limits();
+        let max = limits.max_storage_buffer_binding_size.min(limits.max_buffer_size);
+        if (entries as u64).saturating_mul(16) > max {
+            return None;
+        }
+        let bytes = lut_bytes(lut);
+        let buf = device.create_buffer(&wgpu::BufferDescriptor {
+            label: Some("fx-lut"),
+            size: bytes.len() as u64,
+            usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
+            mapped_at_creation: false,
+        });
+        queue.write_buffer(&buf, 0, &bytes);
+        self.luts.insert(key, (lut.clone(), buf.clone()));
+        Some(buf)
     }
 
     fn targets(&mut self, device: &wgpu::Device, size: (u32, u32), n: usize) -> Vec<Target> {
@@ -377,7 +514,8 @@ impl FxStage {
     /// Prepare the passes of `fx`, whose source draw uses `source_bg` (a layer bind group whose
     /// uniforms map the frame onto the working image). None when the working image does not fit
     /// in a texture (the layer is then drawn without its effects — never larger than the source
-    /// frame, which would not have uploaded either).
+    /// frame, which would not have uploaded either) or a LUT table does not fit in a storage
+    /// buffer.
     pub(crate) fn job(
         &mut self,
         device: &wgpu::Device,
@@ -424,6 +562,10 @@ impl FxStage {
                 mapped_at_creation: false,
             });
             queue.write_buffer(&buf, 0, &bytes);
+            let table = match &s.lut {
+                Some(l) => self.lut_buffer(device, queue, l)?,
+                None => self.no_lut.clone(),
+            };
             let bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
                 label: Some("fx"),
                 layout: &self.bgl,
@@ -432,6 +574,7 @@ impl FxStage {
                     wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::TextureView(views.get(cur)?) },
                     wgpu::BindGroupEntry { binding: 2, resource: wgpu::BindingResource::TextureView(views.get(dst)?) },
                     wgpu::BindGroupEntry { binding: 3, resource: wgpu::BindingResource::TextureView(aux) },
+                    wgpu::BindGroupEntry { binding: 4, resource: table.as_entire_binding() },
                 ],
             });
             let groups = if s.run {
diff --git a/crates/gpu/src/fx.wgsl b/crates/gpu/src/fx.wgsl
--- a/crates/gpu/src/fx.wgsl
+++ b/crates/gpu/src/fx.wgsl
@@ -19,6 +19,8 @@ struct U {
 @group(0) @binding(2) var dst: texture_storage_2d<rgba32float, write>;
 // Unsharp Mask: the image before blurring.
 @group(0) @binding(3) var aux: texture_2d<f32>;
+// LUT pass: the table (the 1D shaper's entries, then the cube's, red fastest; rgb in .xyz).
+@group(0) @binding(4) var<storage, read> lut_data: array<vec4<f32>>;
 
 const OP_BRIGHTNESS_CONTRAST: u32 = 1u;
 const OP_PROC_AMP: u32 = 2u;
@@ -50,6 +52,7 @@ const OP_MIRROR: u32 = 28u;
 const OP_OFFSET: u32 = 29u;
 const OP_VIDEO_LIMITER: u32 = 30u;
 const OP_LUMETRI: u32 = 31u;
+const OP_LUT: u32 = 32u;
 
 fn size() -> vec2<i32> {
     return vec2<i32>(i32(u.i0.y), i32(u.i0.z));
@@ -441,6 +444,83 @@ fn color_op(op: u32, c: vec3<f32>) -> vec3<f32> {
     }
 }
 
+// `GradeSpace::encode` / `decode` (filmcraft-color): the signal Lumetri grades, by u.i1.z
+// (0 sRGB, 1 PQ normalised by u.p3.w, 2 HLG of a u.p3.w cd/m² display with system gamma u.p4.w).
+const REFERENCE_WHITE: f32 = 203.0;
+const LUMA2020: vec3<f32> = vec3(0.26270021, 0.67799807, 0.05930172);
+
+fn pq_eotf1(e: f32) -> f32 {
+    let m1 = 0.15930176; let m2 = 78.84375; let c1 = 0.8359375; let c2 = 18.851563; let c3 = 18.6875;
+    let p = powf(max(e, 0.0), 1.0 / m2);
+    return powf(max(p - c1, 0.0) / (c2 - c3 * p), 1.0 / m1);
+}
+
+fn pq_inverse_eotf1(y: f32) -> f32 {
+    let m1 = 0.15930176; let m2 = 78.84375; let c1 = 0.8359375; let c2 = 18.851563; let c3 = 18.6875;
+    let p = powf(max(y, 0.0), m1);
+    return powf((c1 + c2 * p) / (1.0 + c3 * p), m2);
+}
+
+fn hlg_oetf1(l: f32) -> f32 {
+    let a = 0.17883277; let b = 0.28466892; let c = 0.5599107;
+    if l <= 1.0 / 12.0 {
+        return sqrt(3.0 * max(l, 0.0));
+    }
+    return a * log(12.0 * l - b) + c;
+}
+
+fn hlg_inverse_oetf1(e: f32) -> f32 {
+    let a = 0.17883277; let b = 0.28466892; let c = 0.5599107;
+    if e <= 0.5 {
+        return e * e / 3.0;
+    }
+    return (exp((e - c) / a) + b) / 12.0;
+}
+
+fn grade_enc(c: vec3<f32>) -> vec3<f32> {
+    let mode = u.i1.z;
+    if mode == 1u {
+        let norm = u.p3.w;
+        let k = REFERENCE_WHITE / 10000.0;
+        let m = max(c, vec3(0.0)) * k;
+        return vec3(pq_inverse_eotf1(min(m.x, 1.0)), pq_inverse_eotf1(min(m.y, 1.0)), pq_inverse_eotf1(min(m.z, 1.0))) / norm;
+    }
+    if mode == 2u {
+        let white = u.p3.w;
+        let gamma = u.p4.w;
+        let fd = max(c, vec3(0.0)) * (REFERENCE_WHITE / white);
+        let yd = dot(LUMA2020, fd);
+        if yd <= 0.0 {
+            return vec3(0.0);
+        }
+        let k = powf(yd, (1.0 - gamma) / gamma);
+        return vec3(hlg_oetf1(fd.x * k), hlg_oetf1(fd.y * k), hlg_oetf1(fd.z * k));
+    }
+    return enc(c);
+}
+
+fn grade_dec(v: vec3<f32>) -> vec3<f32> {
+    let mode = u.i1.z;
+    if mode == 1u {
+        let norm = u.p3.w;
+        let q = clamp(v * norm, vec3(0.0), vec3(1.0));
+        return vec3(pq_eotf1(q.x), pq_eotf1(q.y), pq_eotf1(q.z)) * (10000.0 / REFERENCE_WHITE);
+    }
+    if mode == 2u {
+        let white = u.p3.w;
+        let gamma = u.p4.w;
+        let q = clamp(v, vec3(0.0), vec3(1.5));
+        let es = vec3(hlg_inverse_oetf1(q.x), hlg_inverse_oetf1(q.y), hlg_inverse_oetf1(q.z));
+        let ys = dot(LUMA2020, es);
+        if ys <= 0.0 {
+            return vec3(0.0);
+        }
+        let k = powf(ys, gamma - 1.0);
+        return es * (k * white / REFERENCE_WHITE);
+    }
+    return dec(v);
+}
+
 fn lumetri_op(c: vec3<f32>, p: vec2<i32>) -> vec3<f32> {
     let s = size();
     let creative_on = u.i1.x != 0u;
@@ -449,7 +529,7 @@ fn lumetri_op(c: vec3<f32>, p: vec2<i32>) -> vec3<f32> {
     // white balance + exposure in linear light
     let ge = u.p0.xyz;
     let lin = c * ge;
-    var v = enc(lin);
+    var v = grade_enc(lin);
 
     // whites / blacks: endpoints
     let b0 = u.p0.w;
@@ -471,17 +551,27 @@ fn lumetri_op(c: vec3<f32>, p: vec2<i32>) -> vec3<f32> {
         v = v * k;
     }
 
-    // contrast: smooth S-curve around mid grey
+    // contrast: smooth S-curve around mid grey (HDR: over 0 … HDR White; speculars above it
+    // keep their distance from white)
     let contrast = u.p1.w;
+    let hdr = u.i1.z != 0u;
     if abs(contrast) > 1e-4 {
         let k = 1.0 + contrast;
         let q = clamp(v, vec3(0.0), vec3(1.0));
         let sc = q * q * (vec3(3.0) - 2.0 * q);
+        var o: vec3<f32>;
         if k >= 1.0 {
-            v = q + (sc - q) * (k - 1.0);
+            o = q + (sc - q) * (k - 1.0);
         } else {
-            v = vec3(0.5) + (q - vec3(0.5)) * k;
+            o = vec3(0.5) + (q - vec3(0.5)) * k;
         }
+        v = select(o, v, vec3(hdr) & (v > vec3(1.0)));
+    }
+
+    // HDR Specular: brightness of the highlights above HDR White
+    let specular = u.p5.w;
+    if abs(specular) > 1e-4 {
+        v = select(v, vec3(1.0) + (v - vec3(1.0)) * max(1.0 + specular, 0.0), v > vec3(1.0));
     }
 
     // faded film: lift blacks and compress
@@ -527,7 +617,89 @@ fn lumetri_op(c: vec3<f32>, p: vec2<i32>) -> vec3<f32> {
         }
     }
 
-    return dec(v);
+    return grade_dec(v);
+}
+
+// The LUT pass (`LumetriLut::apply`): u.i1.x cube size (0: none), u.i1.y shaper size (0: none),
+// u.i1.z grading mode (as `grade_enc`), u.i1.w mix by u.p0.w (the Look's Intensity); cube domain
+// u.p0.xyz … u.p1.xyz, shaper domain u.p2.xyz … u.p3.xyz. Sizes are at least 2 (`gpu_ok`).
+
+// One channel of `Lut1d::apply`.
+fn lut_shaper1(v: f32, lo: f32, hi: f32, ch: u32) -> f32 {
+    let n = u.i1.y - 1u;
+    let t = clamp((v - lo) / (hi - lo), 0.0, 1.0) * f32(n);
+    let i = min(u32(t), n - 1u);
+    let f = t - f32(i);
+    let a = lut_data[i][ch];
+    let b = lut_data[min(i + 1u, n)][ch];
+    return a + (b - a) * f;
+}
+
+fn lut_at(r: u32, g: u32, b: u32) -> vec3<f32> {
+    let n = u.i1.x;
+    return lut_data[u.i1.y + r + g * n + b * n * n].xyz;
+}
+
+// `Lut3d::apply`: tetrahedral interpolation (the arithmetic of `lut.wgsl`).
+fn lut_cube(c: vec3<f32>) -> vec3<f32> {
+    let n = u.i1.x;
+    let m = f32(n - 1u);
+    let t = clamp((c - u.p0.xyz) / (u.p1.xyz - u.p0.xyz), vec3(0.0), vec3(1.0)) * m;
+    let i = min(vec3<u32>(t), vec3(n - 2u));
+    let f = t - vec3<f32>(i);
+    let fr = f.x;
+    let fg = f.y;
+    let fb = f.z;
+    let c000 = lut_at(i.x, i.y, i.z);
+    let c111 = lut_at(i.x + 1u, i.y + 1u, i.z + 1u);
+    var ca: vec3<f32>;
+    var cb: vec3<f32>;
+    var w: vec4<f32>;
+    if fr > fg {
+        if fg > fb {
+            ca = lut_at(i.x + 1u, i.y, i.z); cb = lut_at(i.x + 1u, i.y + 1u, i.z); w = vec4(1.0 - fr, fr - fg, fg - fb, fb);
+        } else if fr > fb {
+            ca = lut_at(i.x + 1u, i.y, i.z); cb = lut_at(i.x + 1u, i.y, i.z + 1u); w = vec4(1.0 - fr, fr - fb, fb - fg, fg);
+        } else {
+            ca = lut_at(i.x, i.y, i.z + 1u); cb = lut_at(i.x + 1u, i.y, i.z + 1u); w = vec4(1.0 - fb, fb - fr, fr - fg, fg);
+        }
+    } else if fb > fg {
+        ca = lut_at(i.x, i.y, i.z + 1u); cb = lut_at(i.x, i.y + 1u, i.z + 1u); w = vec4(1.0 - fb, fb - fg, fg - fr, fr);
+    } else if fb > fr {
+        ca = lut_at(i.x, i.y + 1u, i.z); cb = lut_at(i.x, i.y + 1u, i.z + 1u); w = vec4(1.0 - fg, fg - fb, fb - fr, fr);
+    } else {
+        ca = lut_at(i.x, i.y + 1u, i.z); cb = lut_at(i.x + 1u, i.y + 1u, i.z); w = vec4(1.0 - fg, fg - fr, fr - fb, fb);
+    }
+    return w.x * c000 + w.y * ca + w.z * cb + w.w * c111;
+}
+
+// `Lut::apply`: the shaper, then the cube.
+fn lut_apply(c: vec3<f32>) -> vec3<f32> {
+    var v = c;
+    if u.i1.y >= 2u {
+        v = vec3(lut_shaper1(v.x, u.p2.x, u.p3.x, 0u), lut_shaper1(v.y, u.p2.y, u.p3.y, 1u), lut_shaper1(v.z, u.p2.z, u.p3.z, 2u));
+    }
+    if u.i1.x >= 2u {
+        v = lut_cube(v);
+    }
+    return v;
+}
+
+fn lut_op(c: vec3<f32>) -> vec3<f32> {
+    let v = grade_enc(c);
+    // `effects::within_unit`: SDR clamps to 0…1; HDR scales a signal above 1 into it and back
+    var lk: vec3<f32>;
+    if u.i1.z == 0u {
+        lk = lut_apply(clamp(v, vec3(0.0), vec3(1.0)));
+    } else {
+        let m = max(max(max(v.x, v.y), v.z), 1.0);
+        lk = lut_apply(max(v / m, vec3(0.0))) * m;
+    }
+    var o = lk;
+    if u.i1.w != 0u {
+        o = v + (lk - v) * u.p0.w;
+    }
+    return grade_dec(o);
 }
 
 // One output pixel of every op but the running-sum box blur.
@@ -715,6 +887,14 @@ fn pixel(op: u32, p: vec2<i32>) -> vec4<f32> {
             let res = lumetri_op(c, p);
             return vec4(res * a, a);
         }
+        case OP_LUT: {
+            let o = ld(p);
+            let a = o.a;
+            if a <= 1e-6 {
+                return o;
+            }
+            return vec4(lut_op(o.rgb / a) * a, a);
+        }
         default: {
             let o = ld(p);
             let a = o.a;
diff --git a/crates/gpu/src/fx_tests.rs b/crates/gpu/src/fx_tests.rs
--- a/crates/gpu/src/fx_tests.rs
+++ b/crates/gpu/src/fx_tests.rs
@@ -516,25 +516,41 @@ fn hostile_parameters_are_bounded() {
 #[test]
 fn lumetri_unsupported_sections_fall_back() {
     let (w, h) = (40usize, 24usize);
-    // sharpen > 0 and input LUT (explicitly required by brief)
-    let e = effect("lumetri", &[("sharpen", fl(10.0)), ("input_lut", ParamValue::Text("custom.cube".into()))]);
+    // sharpen > 0 with an input LUT: the LUT runs on the GPU, Sharpen does not
+    let e = effect("lumetri", &[("sharpen", fl(10.0)), ("input_lut", ParamValue::Text("builtin:slog3-sgamut3cine-to-rec709".into()))]);
     let op = FxOp::eval(&e, &cx(Tick::ZERO, 1.0), w, h);
-    assert!(op.as_ref().is_some_and(|o| !o.gpu_ok()), "sharpen > 0 and input_lut must fall back to CPU");
+    assert!(op.as_ref().is_some_and(|o| !o.gpu_ok()), "sharpen > 0 must fall back to CPU");
 
     // sharpen alone
     let e = effect("lumetri", &[("sharpen", fl(20.0))]);
     let op = FxOp::eval(&e, &cx(Tick::ZERO, 1.0), w, h);
     assert!(op.as_ref().is_some_and(|o| !o.gpu_ok()));
 
-    // input_lut alone
-    let e = effect("lumetri", &[("input_lut", ParamValue::Text("test.cube".into()))]);
-    let op = FxOp::eval(&e, &cx(Tick::ZERO, 1.0), w, h);
-    assert!(op.as_ref().is_some_and(|o| !o.gpu_ok()));
+    // LUT references that name no LUT (a file name, a library entry that is gone) apply none, on
+    // the CPU too, so they stay on the GPU
+    for (id, spec) in [("input_lut", "test.cube"), ("look_lut", "look.cube"), ("look_lut", "lib:gone")] {
+        let e = effect("lumetri", &[(id, ParamValue::Text(spec.into()))]);
+        let op = FxOp::eval(&e, &cx(Tick::ZERO, 1.0), w, h);
+        assert!(matches!(&op, Some(o @ FxOp::Lumetri { input_lut: None, look_lut: None, .. }) if o.gpu_ok()), "{id} = {spec:?}: {op:?}");
+    }
 
-    // look_lut
-    let e = effect("lumetri", &[("look_lut", ParamValue::Text("look.cube".into()))]);
-    let op = FxOp::eval(&e, &cx(Tick::ZERO, 1.0), w, h);
-    assert!(op.as_ref().is_some_and(|o| !o.gpu_ok()));
+    // tables the LUT pass does not take: a cube above the GPU cap, a cube whose data does not
+    // match its size, an empty domain, a non-finite Intensity
+    let lumetri_lut = |lut: filmcraft_color::Lut, mix: Option<f32>| filmcraft_render::gpufx::LumetriLut {
+        lut: std::sync::Arc::new(lut),
+        grade: filmcraft_color::GradeSpace::Sdr,
+        mix,
+    };
+    let cube = |c: filmcraft_color::Lut3d| filmcraft_color::Lut::from_cube(c);
+    assert!(lumetri_lut(cube(filmcraft_color::Lut3d::identity(filmcraft_render::gpufx::GPU_LUT_MAX_CUBE)), Some(1.0)).gpu_ok());
+    assert!(!lumetri_lut(cube(filmcraft_color::Lut3d::identity(filmcraft_render::gpufx::GPU_LUT_MAX_CUBE + 1)), None).gpu_ok());
+    let mut short = filmcraft_color::Lut3d::identity(5);
+    short.data.pop();
+    assert!(!lumetri_lut(cube(short), None).gpu_ok());
+    let mut flat = filmcraft_color::Lut3d::identity(5);
+    flat.domain_max = flat.domain_min;
+    assert!(!lumetri_lut(cube(flat), None).gpu_ok());
+    assert!(!lumetri_lut(cube(filmcraft_color::Lut3d::identity(5)), Some(f32::NAN)).gpu_ok());
 
     // look > 0
     let e = effect("lumetri", &[("look", ch(1))]);
@@ -566,12 +582,138 @@ fn lumetri_unsupported_sections_fall_back() {
     let op = FxOp::eval(&e, &cx(Tick::ZERO, 1.0), w, h);
     assert!(op.as_ref().is_some_and(|o| !o.gpu_ok()));
 
-    // HDR working space
-    let e = effect("lumetri", &[]);
+    // HDR still falls back for the sections the shader does not have
     let mut cx_hdr = cx(Tick::ZERO, 1.0);
-    cx_hdr.working = filmcraft_color::WorkingSpace::Rec2100Pq;
-    let op = FxOp::eval(&e, &cx_hdr, w, h);
-    assert!(op.as_ref().is_some_and(|o| !o.gpu_ok()));
+    cx_hdr.working = filmcraft_color::WorkingSpace::Rec2100Hlg;
+    let e = effect("lumetri", &[("sharpen", fl(20.0))]);
+    assert!(FxOp::eval(&e, &cx_hdr, w, h).is_some_and(|o| !o.gpu_ok()));
+}
+
+/// Lumetri's basic, creative and vignette sections in an HDR working space (the PQ / HLG grading
+/// signal, contrast that leaves speculars alone, HDR Specular) match the CPU op on the GPU.
+#[test]
+fn hdr_lumetri_matches_cpu() {
+    let Some((dev, q)) = device() else {
+        eprintln!("no GPU adapter; skipping");
+        return;
+    };
+    let mut c = GpuCompositor::new(&dev, &q);
+    let (w, h) = (67u32, 41u32);
+    let px = picture(w, h);
+    let frame = VideoFrame::rgba_f32(w, h, px.clone());
+    let grades: [&[(&str, ParamValue)]; 4] = [
+        &[("exposure", fl(-0.5)), ("saturation", fl(150.0))],
+        &[("contrast", fl(40.0)), ("highlights", fl(-20.0)), ("shadows", fl(25.0)), ("hdr_specular", fl(-30.0)), ("temperature", fl(15.0))],
+        &[("contrast", fl(-50.0)), ("whites", fl(20.0)), ("blacks", fl(-10.0)), ("hdr_white", fl(200.0)), ("hdr_specular", fl(60.0))],
+        &[
+            ("creative_on", ParamValue::Bool(true)),
+            ("vibrance", fl(30.0)),
+            ("faded_film", fl(20.0)),
+            ("shadow_tint", col(0.4, 0.45, 0.6)),
+            ("vignette_amount", fl(-2.0)),
+        ],
+    ];
+    let mut failures = Vec::new();
+    for working in [filmcraft_color::WorkingSpace::Rec2100Hlg, filmcraft_color::WorkingSpace::Rec2100Pq] {
+        let mut cxh = cx(Tick::ZERO, 1.0);
+        cxh.working = working;
+        for params in grades {
+            let e = effect("lumetri", params);
+            let op = FxOp::eval(&e, &cxh, w as usize, h as usize).expect("lumetri op");
+            assert!(op.gpu_ok(), "{working:?} {params:?}: not GPU-capable");
+            let mut cpu = filmcraft_render::Image { w: w as usize, h: h as usize, px: px.clone() };
+            op.apply(&mut cpu);
+            // HDR Specular only acts above HDR White: the picture must reach it, or the parity
+            // check below would pass without running that branch
+            if params.iter().any(|(k, _)| *k == "hdr_specular") {
+                let off: Vec<(&str, ParamValue)> = params.iter().map(|(k, v)| (*k, if *k == "hdr_specular" { fl(0.0) } else { v.clone() })).collect();
+                let flat = FxOp::eval(&effect("lumetri", &off), &cxh, w as usize, h as usize).expect("lumetri op");
+                let mut plain = filmcraft_render::Image { w: w as usize, h: h as usize, px: px.clone() };
+                flat.apply(&mut plain);
+                assert_ne!(cpu.px, plain.px, "{working:?} {params:?}: HDR Specular changed nothing");
+            }
+            let (_, _, gpu) = c.effect_image(&frame, &LayerFx { size: (w, h), decimation: 1, ops: vec![op] }).expect("effect image");
+            let (worst, flips) = compare(&cpu.px, &gpu);
+            eprintln!("{working:?} {params:?}: max rel diff {worst:.2e}, {:.3}% flipped", flips * 100.0);
+            if worst > EXACT * 10.0 || flips > 0.0 {
+                failures.push(format!("{working:?} {params:?}: {worst:.2e}, flips {flips:.4}"));
+            }
+        }
+    }
+    assert!(failures.is_empty(), "HDR Lumetri differs from the CPU:\n{}", failures.join("\n"));
+}
+
+/// A project whose LUT library holds `lib:shaped`: a 1D shaper over a non-unit domain in front of
+/// a 5³ cube that rotates and bends the channels.
+fn shaped_lut_project() -> filmcraft_project::Project {
+    let shaper = filmcraft_color::Lut1d {
+        domain_min: [-0.1, 0.0, 0.05],
+        domain_max: [1.2, 1.0, 1.1],
+        data: (0..6).map(|i| [(i as f32 / 5.0).powf(0.8), i as f32 / 5.0, (i as f32 / 5.0).powf(1.3)]).collect(),
+    };
+    let cube = filmcraft_color::Lut3d::from_fn(5, |c| [c[1] * 0.9 + 0.05, c[2], c[0].powf(1.2)]);
+    let text = filmcraft_color::Lut { title: "shaped".into(), shaper: Some(shaper), cube: Some(cube) }.to_cube();
+    let mut p = filmcraft_project::Project::new("luts");
+    p.luts.push(filmcraft_project::ProjectLut { id: "shaped".into(), name: "Shaped".into(), source_path: None, format: "cube".into(), text: text.into() });
+    p
+}
+
+/// Lumetri's Input LUT and Look LUT run on the GPU's LUT pass and match the CPU op, in SDR and in
+/// the HDR working spaces: built-in 33³ cubes, a library LUT with a shaper over a non-unit
+/// domain, Intensity below and above 100, with and without a grade around them. The tables are
+/// kept between frames that use them and released after a frame that does not.
+#[test]
+fn lumetri_luts_match_cpu() {
+    let Some((dev, q)) = device() else {
+        eprintln!("no GPU adapter; skipping");
+        return;
+    };
+    let mut c = GpuCompositor::new(&dev, &q);
+    let (w, h) = (67u32, 41u32);
+    let px = picture(w, h);
+    let frame = VideoFrame::rgba_f32(w, h, px.clone());
+    let txt = |s: &str| ParamValue::Text(s.into());
+    let cases: Vec<Vec<(&str, ParamValue)>> = vec![
+        vec![("input_lut", txt("builtin:slog3-sgamut3cine-to-rec709"))],
+        vec![("look_lut", txt("builtin:look-bleach-bypass")), ("look_intensity", fl(70.0))],
+        vec![
+            ("input_lut", txt("builtin:slog3-sgamut3cine-to-rec709")),
+            ("look_lut", txt("builtin:look-bleach-bypass")),
+            ("exposure", fl(-0.5)),
+            ("contrast", fl(15.0)),
+            ("highlights", fl(-15.0)),
+            ("creative_sat", fl(95.0)),
+            ("highlight_tint", col(0.79, 0.54, 0.35)),
+            ("vignette_amount", fl(-1.5)),
+        ],
+        vec![("look_lut", txt("builtin:look-teal-orange")), ("look_intensity", fl(150.0))],
+        vec![("input_lut", txt("lib:shaped")), ("saturation", fl(120.0))],
+        vec![("look_lut", txt("lib:shaped")), ("curves_hdr_range", fl(400.0))],
+    ];
+    let project = shaped_lut_project();
+    let mut failures = Vec::new();
+    for working in [filmcraft_color::WorkingSpace::Rec709, filmcraft_color::WorkingSpace::Rec2100Hlg, filmcraft_color::WorkingSpace::Rec2100Pq] {
+        let cxl = FxCtx { project: Some(&project), working, ..cx(Tick::ZERO, 1.0) };
+        for params in &cases {
+            let op = FxOp::eval(&effect("lumetri", params), &cxl, w as usize, h as usize).expect("lumetri op");
+            assert!(matches!(op, FxOp::Lumetri { input_lut: Some(_), .. } | FxOp::Lumetri { look_lut: Some(_), .. }), "{params:?}: no LUT");
+            assert!(op.gpu_ok(), "{working:?} {params:?}: not GPU-capable");
+            let mut cpu = filmcraft_render::Image { w: w as usize, h: h as usize, px: px.clone() };
+            op.apply(&mut cpu);
+            let (_, _, gpu) = c.effect_image(&frame, &LayerFx { size: (w, h), decimation: 1, ops: vec![op] }).expect("effect image");
+            let (worst, flips) = compare(&cpu.px, &gpu);
+            eprintln!("{working:?} {params:?}: max rel diff {worst:.2e}, {:.3}% flipped", flips * 100.0);
+            if worst > EXACT * 10.0 || flips > 0.0 {
+                failures.push(format!("{working:?} {params:?}: {worst:.2e}, flips {flips:.4}"));
+            }
+        }
+    }
+    assert!(failures.is_empty(), "Lumetri LUTs differ from the CPU:\n{}", failures.join("\n"));
+    // the last frame's table is held; a frame without LUT layers releases it
+    assert!(!c.fx.as_ref().is_some_and(fx::FxStage::is_idle));
+    let plain = FramePlan::Layers { width: 64, height: 36, layers: vec![PlanLayer::new(yuv_frame(64, 36), Affine::IDENTITY, 1.0, Blend::Normal)] };
+    c.composite(&plain);
+    assert!(c.fx.as_ref().is_some_and(fx::FxStage::is_idle));
 }
 
 /// Layers with effect chains in a composited plan — YUV and RGBA sources, a decimated working
@@ -710,6 +852,44 @@ fn layers_with_effects_fall_back_to_the_cpu_without_a_stage() {
     }
 }
 
+/// A colour-managed layer (PQ media) with effects comes out the same with and without the GPU
+/// effect stage: the CPU fallback decodes it through the layer's input transform too.
+#[test]
+fn cpu_effect_fallback_keeps_the_input_transform() {
+    use filmcraft_color::{ColorInfo, ColorPipeline, ColorSpace, InputTransform, Primaries};
+    let Some((dev, q)) = device() else {
+        eprintln!("no GPU adapter; skipping");
+        return;
+    };
+    let (w, h) = (256u32, 4u32);
+    let y: Vec<u16> = (0..h).flat_map(|_| (0..w).map(|x| (64 + x * 876 / 255) as u16)).collect();
+    let n = (w * h) as usize;
+    let data = PixelData::Yuv16 { planes: [Arc::new(y), Arc::new(vec![480; n]), Arc::new(vec![560; n])], chroma: Chroma::C444, bits: 10, alpha: None };
+    let color = ColorInfo { matrix: Matrix::Bt2020Ncl, transfer: Transfer::Pq, primaries: Primaries::Bt2020, range: Range::Limited };
+    let frame = Arc::new(VideoFrame { width: w, height: h, data, color, par: (1, 1), pts: Default::default() });
+    let t = Arc::new(InputTransform::new(ColorSpace::from_info(&color), color.range, &ColorPipeline::REC709, None));
+    let e = effect("brightness_contrast", &[("brightness", fl(-10.0)), ("contrast", fl(20.0))]);
+    let op = FxOp::eval(&e, &cx(Tick::ZERO, 1.0), w as usize, h as usize).expect("brightness / contrast op");
+    let layer = PlanLayer {
+        fx: Some(Arc::new(LayerFx { size: (w, h), decimation: 1, ops: vec![op] })),
+        input: Some(t),
+        ..PlanLayer::new(frame, Affine::IDENTITY, 1.0, Blend::Normal)
+    };
+    let plan = FramePlan::Layers { width: w as usize, height: h as usize, layers: vec![layer] };
+    let cpu = execute_cpu(&plan).over_black_rgba8();
+    for stage in [true, false] {
+        let mut c = GpuCompositor::new(&dev, &q);
+        if !stage {
+            c.fx = None;
+        }
+        c.composite(&plan);
+        let (_, _, gpu) = c.read_output().expect("readback");
+        let (p99, mean, max) = stats8(&cpu, &gpu, &vec![true; n]);
+        eprintln!("PQ layer with effects, effect stage {stage}: p99 {p99}, mean {mean:.2}, max {max}");
+        assert!(p99 <= 6 && mean < 1.5, "stage {stage}: p99 {p99}, mean {mean}, max {max}");
+    }
+}
+
 /// The half-float upload is lossless for this input, so a 1:1 effect source draw must be too.
 /// Odd dimensions expose interpolation drift that can invent opacity before any effect runs.
 #[test]
diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs
--- a/crates/gpu/src/lib.rs
+++ b/crates/gpu/src/lib.rs
@@ -84,6 +84,8 @@ pub struct GpuCompositor {
     accum_readback_buf: Option<(wgpu::Buffer, u64)>,
     uploads: HashMap<(usize, u32, u32), Uploaded>,
     clock: u64,
+    /// The resolve pass's display transform (`set_display_transform`), as plain numbers.
+    display: [f32; DISPLAY_FLOATS],
     dummy: wgpu::TextureView,
     /// The effect stage (None on devices without compute shaders or storage textures: layers
     /// with effects are then rendered on the CPU here).
@@ -253,6 +255,16 @@ pub fn prepare_frame(f: &VideoFrame) -> Option<Prepared> {
     }
 }
 
+/// The display transform to resolve a *sequence* plan with: `display` (the sequence's,
+/// `colorman::display_transform(..).gpu_params()`) for a plan composited in its working space,
+/// none for a [`FramePlan::Image`]. That image is `render_sequence`'s output, already converted
+/// for the monitor, and converting it again would tone map and gamut map it twice. (The CPU
+/// images the compositor makes itself, for a transition it cannot mix, are in working space and
+/// keep the transform: they are drawn inside a working-space plan.)
+pub fn sequence_display(plan: &FramePlan, display: Option<filmcraft_color::GpuOutputParams>) -> Option<filmcraft_color::GpuOutputParams> {
+    if matches!(plan, FramePlan::Image(_)) { None } else { display }
+}
+
 /// Convert every layer of a plan for upload. Thread-safe and GPU-free: run it on a worker.
 pub fn prepare(plan: &FramePlan) -> PreparedPlan {
     match plan {
@@ -314,7 +326,16 @@ impl GpuCompositor {
         });
         let final_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
             label: Some("final"),
-            entries: &[wgpu::BindGroupLayoutEntry { binding: 0, ..tex_entry(0) }],
+            entries: &[
+                wgpu::BindGroupLayoutEntry { binding: 0, ..tex_entry(0) },
+                // the resolve pass's display transform (`set_display_transform`)
+                wgpu::BindGroupLayoutEntry {
+                    binding: 1,
+                    visibility: wgpu::ShaderStages::FRAGMENT,
+                    ty: wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Uniform, has_dynamic_offset: false, min_binding_size: None },
+                    count: None,
+                },
+            ],
         });
         let pl =
             device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { label: Some("layer"), bind_group_layouts: &[Some(&layer_bgl)], immediate_size: 0 });
@@ -420,6 +441,7 @@ impl GpuCompositor {
             accum_readback_buf: None,
             uploads: HashMap::new(),
             clock: 0,
+            display: [0.0; DISPLAY_FLOATS],
             dummy,
             fx,
             transition: transition::PushStage::new(device),
@@ -699,11 +721,58 @@ impl GpuCompositor {
         buf
     }
 
+    /// The resolve pass's display transform uniform ([`set_display_transform`](Self::set_display_transform)).
+    fn display_buffer(&self) -> wgpu::Buffer {
+        let bytes: Vec<u8> = self.display.iter().flat_map(|v| v.to_le_bytes()).collect();
+        let buf = self.device.create_buffer(&wgpu::BufferDescriptor {
+            label: Some("display-u"),
+            size: bytes.len() as u64,
+            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
+            mapped_at_creation: false,
+        });
+        self.queue.write_buffer(&buf, 0, &bytes);
+        buf
+    }
+
     /// Composite a plan; returns the output view (sRGB, over black) and its size.
     pub fn composite(&mut self, plan: &FramePlan) -> (wgpu::TextureView, (u32, u32)) {
         self.composite_prepared(plan, None)
     }
 
+    /// Set the resolve pass's display transform (the sequence's working space → monitor,
+    /// `filmcraft_color::OutputTransform::gpu_params`). Layers are composited in the sequence's
+    /// working linear space, so an HDR / wide-gamut sequence needs it to come out right; `None`
+    /// (the default) leaves the accumulator as the monitor's own space, as for a plain sequence.
+    /// Only the resolve pass uses it: a caller that reads the working-linear accumulator back
+    /// (the export path) is unaffected.
+    pub fn set_display_transform(&mut self, p: Option<filmcraft_color::GpuOutputParams>) {
+        let Some(p) = p else {
+            self.display = [0.0; DISPLAY_FLOATS];
+            return;
+        };
+        let mx = p.matrix.unwrap_or([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]);
+        let (sp, dp) = p.tone.unwrap_or((0.0, 0.0));
+        let dl = p.compress.unwrap_or([0.0; 3]);
+        let on = |b: bool| if b { 1.0 } else { 0.0 };
+        let curve = match p.target.curve() {
+            filmcraft_color::Curve::Sdr | filmcraft_color::Curve::Srgb => 0.0,
+            filmcraft_color::Curve::Linear => 1.0,
+            filmcraft_color::Curve::Pq => 2.0,
+            filmcraft_color::Curve::Hlg => 3.0,
+            // not reachable: `gpu_params` refuses a target the shader cannot encode
+            filmcraft_color::Curve::Log(_) => 0.0,
+        };
+        #[rustfmt::skip]
+        let d = [
+            1.0, curve, on(p.tone.is_some()), on(p.matrix.is_some()),
+            on(p.compress.is_some()), sp, dp, filmcraft_color::REFERENCE_WHITE_NITS as f32,
+            mx[0][0], mx[0][1], mx[0][2], dl[0],
+            mx[1][0], mx[1][1], mx[1][2], dl[1],
+            mx[2][0], mx[2][1], mx[2][2], dl[2],
+        ];
+        self.display = d;
+    }
+
     /// [`composite`](Self::composite) with texel conversions already done by [`prepare`] (the
     /// result is identical; only the upload work on this thread differs).
     pub fn composite_prepared(&mut self, plan: &FramePlan, prep: Option<&PreparedPlan>) -> (wgpu::TextureView, (u32, u32)) {
@@ -749,7 +818,9 @@ impl GpuCompositor {
                 if self.fx.is_some() && lfx.size.0.max(lfx.size.1) <= max_side {
                     return None;
                 }
-                let img = filmcraft_render::plan::effect_image(&l.frame, lfx);
+                // decoded through the layer's input transform, as `execute_cpu` does; the image is
+                // then working-space linear, so the new layer has none
+                let img = filmcraft_render::plan::effect_image_with(&l.frame, lfx, l.input.as_deref());
                 Some(PlanLayer::new(Arc::new(VideoFrame::rgba_f32(img.w as u32, img.h as u32, img.px)), l.matrix, l.opacity, l.blend))
             })
             .collect();
@@ -858,10 +929,14 @@ impl GpuCompositor {
         if let Some(f) = self.fx.as_mut() {
             f.end_frame();
         }
+        let display_buf = self.display_buffer();
         let final_bg = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
             label: Some("final"),
             layout: &self.final_bgl,
-            entries: &[wgpu::BindGroupEntry { binding: 0, resource: wgpu::BindingResource::TextureView(&accum_view) }],
+            entries: &[
+                wgpu::BindGroupEntry { binding: 0, resource: wgpu::BindingResource::TextureView(&accum_view) },
+                wgpu::BindGroupEntry { binding: 1, resource: display_buf.as_entire_binding() },
+            ],
         });
         let mut enc = self.device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("filmcraft-composite") });
         // Runs of fixed-function layers draw in one pass. A layer that reads the destination
@@ -1174,6 +1249,8 @@ struct SrcInfo {
 
 /// Floats in a layer's uniform block (`U` in `composite.wgsl`: 13 vec4).
 const UNIFORM_FLOATS: usize = 52;
+/// Floats in the display transform uniform (`struct O` in `composite.wgsl`): 5 vec4.
+const DISPLAY_FLOATS: usize = 20;
 
 /// Poll a future once: wgpu's native error scopes resolve immediately. `None` if it is still
 /// pending (then the caller assumes the worst).
diff --git a/crates/render/src/colorman.rs b/crates/render/src/colorman.rs
--- a/crates/render/src/colorman.rs
+++ b/crates/render/src/colorman.rs
@@ -55,13 +55,18 @@ pub fn input_transform(cs: ColorSpace, range: Range, pipe: &ColorPipeline, peak_
 }
 
 /// The input transform of a frame the GPU compositor can colour manage itself (#965): a Y'CbCr
-/// frame of display-referred HDR or wide-gamut media (PQ, HLG, BT.2020, P3) on a plain pipeline,
-/// whose working → monitor transform is the identity. None for log media (camera-gamut
-/// compression), RGB frames and other pipelines, which [`decode`] converts on the CPU.
+/// frame whose signal → working space stages the shader runs (PQ, HLG, BT.2020, P3; log curves and
+/// camera-gamut compression are what it cannot). The sequence's working → monitor transform runs in
+/// the compositor's resolve pass (`OutputTransform::gpu_params`), so an HDR working space is fine;
+/// when the resolve pass cannot finish it, the whole frame stays on the CPU. None for log media,
+/// RGB frames and pipelines the shader cannot finish, which [`decode`] converts on the CPU.
 pub fn gpu_input(project: &Project, item: ItemId, pipe: &ColorPipeline, cs: ColorSpace, frame: &VideoFrame) -> Option<Arc<InputTransform>> {
-    if !pipe.is_plain() || !matches!(frame.data, filmcraft_frame::PixelData::Yuv8 { .. } | filmcraft_frame::PixelData::Yuv16 { .. }) {
+    if !matches!(frame.data, filmcraft_frame::PixelData::Yuv8 { .. } | filmcraft_frame::PixelData::Yuv16 { .. }) {
         return None;
     }
+    // The compositor's resolve pass finishes the frame with the sequence's display transform:
+    // when the shader cannot encode that, the whole frame stays on the CPU.
+    display_transform(pipe).gpu_params()?;
     let t = input_transform(cs, frame.color.range, pipe, source_peak_nits(project, item));
     t.gpu_params().map(|_| t)
 }
diff --git a/crates/render/src/effects.rs b/crates/render/src/effects.rs
--- a/crates/render/src/effects.rs
+++ b/crates/render/src/effects.rs
@@ -612,7 +612,7 @@ pub(crate) fn grade_space(e: &EffectInstance, cx: &FxCtx, white_id: &str) -> Gra
 /// into 0…1 first and back afterwards, so operations defined on 0…1 (LUTs, HSL) keep the
 /// highlights instead of clipping them. SDR signals are clamped as before.
 #[inline]
-fn within_unit(v: [f32; 3], hdr: bool, f: impl Fn([f32; 3]) -> [f32; 3]) -> [f32; 3] {
+pub(crate) fn within_unit(v: [f32; 3], hdr: bool, f: impl Fn([f32; 3]) -> [f32; 3]) -> [f32; 3] {
     if !hdr {
         return f(v.map(|q| q.clamp(0.0, 1.0)));
     }
@@ -1558,6 +1558,98 @@ mod lumetri_cpu_parity_tests {
             }
             eprintln!("{name}: max sample diff vs fn lumetri: {max_diff:.2e}");
         }
+
+        // HDR working spaces: the op grades the same PQ / HLG signal as fn lumetri
+        for working in [filmcraft_color::WorkingSpace::Rec2100Hlg, filmcraft_color::WorkingSpace::Rec2100Pq] {
+            let cx = FxCtx { working, ..cx };
+            for (name, params) in cases.iter().chain(&[("specular", vec![("contrast", fl(30.0)), ("hdr_specular", fl(-40.0)), ("exposure", fl(1.0))])]) {
+                let e = effect(params);
+                let mut img_orig = test_picture(w, h);
+                let mut img_copy = img_orig.clone();
+                lumetri(&mut img_orig, &e, &cx);
+                let op = FxOp::eval(&e, &cx, w, h).unwrap_or_else(|| panic!("{name}: eval returned None"));
+                assert!(op.gpu_ok(), "{working:?} {name}: expected gpu_ok == true");
+                op.apply(&mut img_copy);
+                for (i, (&orig, &copy)) in img_orig.px.iter().zip(&img_copy.px).enumerate() {
+                    assert!((orig - copy).abs() <= 1e-6, "{working:?} {name}: sample {i}: orig={orig}, copy={copy}");
+                }
+            }
+        }
+    }
+
+    /// A project whose LUT library holds `lib:shaped`: a 1D shaper over a non-unit domain in front
+    /// of a 5³ cube that rotates and bends the channels (the paths a built-in 33³ cube leaves out).
+    fn shaped_lut_project() -> filmcraft_project::Project {
+        let shaper = filmcraft_color::Lut1d {
+            domain_min: [-0.1, 0.0, 0.05],
+            domain_max: [1.2, 1.0, 1.1],
+            data: (0..6).map(|i| [(i as f32 / 5.0).powf(0.8), i as f32 / 5.0, (i as f32 / 5.0).powf(1.3)]).collect(),
+        };
+        let cube = filmcraft_color::Lut3d::from_fn(5, |c| [c[1] * 0.9 + 0.05, c[2], c[0].powf(1.2)]);
+        let text = filmcraft_color::Lut { title: "shaped".into(), shaper: Some(shaper), cube: Some(cube) }.to_cube();
+        let mut p = filmcraft_project::Project::new("luts");
+        p.luts.push(filmcraft_project::ProjectLut { id: "shaped".into(), name: "Shaped".into(), source_path: None, format: "cube".into(), text: text.into() });
+        p
+    }
+
+    /// The Lumetri op with an Input LUT and / or a Look LUT (built-in cubes and a library LUT with a
+    /// shaper, Intensity below and above 100) is GPU-capable and grades exactly as `fn lumetri`, in
+    /// SDR and in the HDR working spaces.
+    #[test]
+    fn lumetri_lut_op_matches_fn_lumetri() {
+        let txt = |s: &str| ParamValue::Text(s.into());
+        let cases: Vec<(&str, Vec<(&str, ParamValue)>)> = vec![
+            ("input", vec![("input_lut", txt("builtin:slog3-sgamut3cine-to-rec709"))]),
+            ("look", vec![("look_lut", txt("builtin:look-bleach-bypass")), ("look_intensity", fl(70.0)), ("exposure", fl(-0.5))]),
+            (
+                "both",
+                vec![
+                    ("input_lut", txt("builtin:slog3-sgamut3cine-to-rec709")),
+                    ("look_lut", txt("builtin:look-bleach-bypass")),
+                    ("exposure", fl(-0.5)),
+                    ("contrast", fl(15.0)),
+                    ("highlights", fl(-15.0)),
+                    ("creative_sat", fl(95.0)),
+                    ("highlight_tint", col(0.79, 0.54, 0.35)),
+                    ("vignette_amount", fl(-1.5)),
+                ],
+            ),
+            ("look_150", vec![("look_lut", txt("builtin:look-teal-orange")), ("look_intensity", fl(150.0))]),
+            ("shaped_input", vec![("input_lut", txt("lib:shaped")), ("saturation", fl(120.0))]),
+            ("shaped_look", vec![("look_lut", txt("lib:shaped")), ("look_intensity", fl(100.0)), ("curves_hdr_range", fl(400.0))]),
+        ];
+        let project = shaped_lut_project();
+        let (w, h) = (67usize, 41usize);
+        for working in [filmcraft_color::WorkingSpace::Rec709, filmcraft_color::WorkingSpace::Rec2100Hlg, filmcraft_color::WorkingSpace::Rec2100Pq] {
+            let cx = FxCtx {
+                t: Tick::ZERO,
+                px_scale: 1.0,
+                seconds: 0.0,
+                timecode: "",
+                clip_name: "",
+                project: Some(&project),
+                env: None,
+                frame_rate: 24.0,
+                working,
+            };
+            for (name, params) in &cases {
+                let e = effect(params);
+                let mut reference = test_picture(w, h);
+                let mut got = reference.clone();
+                lumetri(&mut reference, &e, &cx);
+                let op = FxOp::eval(&e, &cx, w, h).unwrap_or_else(|| panic!("{name}: eval returned None"));
+                let FxOp::Lumetri { input_lut, look_lut, .. } = &op else { panic!("{name}: {op:?}") };
+                let names = |id: &str| params.iter().any(|(k, _)| *k == id);
+                assert_eq!(input_lut.is_some(), names("input_lut"), "{working:?} {name}: Input LUT");
+                assert_eq!(look_lut.is_some(), names("look_lut"), "{working:?} {name}: Look LUT");
+                assert!(op.gpu_ok(), "{working:?} {name}: expected gpu_ok == true");
+                op.apply(&mut got);
+                assert_ne!(got.px, test_picture(w, h).px, "{working:?} {name}: the grade changed nothing");
+                for (i, (&a, &b)) in reference.px.iter().zip(&got.px).enumerate() {
+                    assert!((a - b).abs() <= 1e-6, "{working:?} {name}: sample {i}: fn lumetri {a}, op {b}");
+                }
+            }
+        }
     }
 
     /// #719: positive Roundness rounds the Lumetri vignette (it used to be ignored), +100
diff --git a/crates/render/src/gpufx.rs b/crates/render/src/gpufx.rs
--- a/crates/render/src/gpufx.rs
+++ b/crates/render/src/gpufx.rs
@@ -9,14 +9,75 @@
 //! op to the GPU when [`FxOp::gpu_ok`] says the shader covers it (finite numbers, no minifying
 //! resample), otherwise the clip is rendered on the CPU as before.
 
-use filmcraft_color::{hsl_to_rgb, linear_to_srgb, luma709, rgb_to_hsl};
+use std::sync::Arc;
+
+use filmcraft_color::{GradeSpace, Lut, hsl_to_rgb, linear_to_srgb, luma709, rgb_to_hsl};
 use filmcraft_geom::Affine;
 use filmcraft_project::EffectInstance;
 use rayon::prelude::*;
 
-use crate::effects::{FxCtx, b, choice, color, dec, enc, f, gaussian_boxes, grade_space, on, point, text};
+use crate::effects::{FxCtx, b, choice, color, dec, enc, f, gaussian_boxes, grade_space, on, point, text, within_unit};
 use crate::image::Image;
 
+/// The largest 3D cube (entries per side) a [`LumetriLut`] takes to the GPU; larger ones (a
+/// 129³ cube is 34 MB of storage per layer) stay on the CPU.
+pub const GPU_LUT_MAX_CUBE: usize = 65;
+/// The largest 1D table (a `.cube` shaper or a 1D-only LUT) a [`LumetriLut`] takes to the GPU.
+pub const GPU_LUT_MAX_SHAPER: usize = 65_536;
+
+/// A LUT of the Lumetri op: the Input LUT (first, on the grading signal of HDR White) or the
+/// Creative ▸ Look LUT (last, on the signal of the curves' HDR Range, mixed in by Intensity).
+/// `apply` is `fn lumetri`'s arithmetic for it, which the GPU's LUT pass reproduces.
+#[derive(Clone, Debug)]
+pub struct LumetriLut {
+    /// The resolved LUT (shared with `luts::resolve`'s cache, so a frame copies no table).
+    pub lut: Arc<Lut>,
+    /// The signal the LUT reads and writes.
+    pub grade: GradeSpace,
+    /// None: the LUT's output replaces the signal (Input LUT); Some(k): it is mixed in by k (the
+    /// Look's Intensity / 100).
+    pub mix: Option<f32>,
+}
+
+impl PartialEq for LumetriLut {
+    // the same table (by identity: comparing tables of 65³ entries per frame would cost more
+    // than the comparison saves) on the same signal
+    fn eq(&self, other: &Self) -> bool {
+        Arc::ptr_eq(&self.lut, &other.lut) && self.grade == other.grade && self.mix == other.mix
+    }
+}
+
+impl LumetriLut {
+    /// Linear (unpremultiplied) colour → the colour after the LUT.
+    #[inline]
+    pub fn apply(&self, c: [f32; 3]) -> [f32; 3] {
+        let v = self.grade.encode(c);
+        let lk = within_unit(v, self.grade.is_hdr(), |u| self.lut.apply(u));
+        let o = match self.mix {
+            None => lk,
+            Some(k) => [v[0] + (lk[0] - v[0]) * k, v[1] + (lk[1] - v[1]) * k, v[2] + (lk[2] - v[2]) * k],
+        };
+        self.grade.decode(o)
+    }
+
+    /// Whether the GPU's LUT pass reproduces [`apply`](Self::apply): tables it can index (sizes
+    /// at least 2 and within the GPU caps, as many entries as the size says), domains that are
+    /// finite and not empty, a finite mix and grading signal.
+    pub fn gpu_ok(&self) -> bool {
+        let domain = |lo: &[f32; 3], hi: &[f32; 3]| lo.iter().zip(hi).all(|(a, b)| a.is_finite() && b.is_finite() && b > a);
+        let shaper_ok = self.lut.shaper.as_ref().is_none_or(|s| (2..=GPU_LUT_MAX_SHAPER).contains(&s.data.len()) && domain(&s.domain_min, &s.domain_max));
+        let cube_ok = self.lut.cube.as_ref().is_none_or(|c| {
+            (2..=GPU_LUT_MAX_CUBE).contains(&c.size) && c.size.checked_pow(3).is_some_and(|n| n == c.data.len()) && domain(&c.domain_min, &c.domain_max)
+        });
+        let grade_ok = match self.grade {
+            GradeSpace::Sdr => true,
+            GradeSpace::Pq { white, norm } => white.is_finite() && norm.is_finite() && norm > 0.0,
+            GradeSpace::Hlg { white, gamma } => white.is_finite() && gamma.is_finite() && gamma > 0.0,
+        };
+        shaper_ok && cube_ok && grade_ok && self.mix.is_none_or(f32::is_finite)
+    }
+}
+
 /// A bilinear resample of the working image through an affine map (`Image::transformed` at a
 /// magnification or mild minification: no mip pre-filter).
 #[derive(Clone, Debug, PartialEq)]
@@ -195,6 +256,15 @@ pub enum FxOp {
         vround: f32,
         vfeather: f32,
         aspect: f32,
+        /// The signal the grade works on: sRGB (SDR) or the PQ / HLG signal normalised to HDR
+        /// White (an HDR working space).
+        grade: GradeSpace,
+        /// HDR Specular (0 for SDR).
+        specular: f32,
+        /// Basic ▸ Input LUT, applied before the grade.
+        input_lut: Option<LumetriLut>,
+        /// Creative ▸ Look LUT, applied after the grade.
+        look_lut: Option<LumetriLut>,
         gpu_capable: bool,
     },
 }
@@ -506,9 +576,11 @@ impl FxOp {
                 let (basic_on, creative_on, vignette_on) = (on(e, "basic_on"), on(e, "creative_on"), on(e, "vignette_on"));
                 let gs = grade_space(e, cx, "hdr_white");
                 let is_hdr = gs.is_hdr();
-                let has_input_lut = basic_on && !text(e, "input_lut").is_empty();
-                let has_look_lut = creative_on && !text(e, "look_lut").is_empty();
-                let look = if creative_on && !has_look_lut { choice(e, "look") } else { 0 };
+                // LUTs as `fn lumetri` resolves them: a reference that names no LUT (a library
+                // entry that is gone) applies none, and then the procedural Look menu applies
+                let input_lut = if basic_on { crate::luts::resolve(cx.project, text(e, "input_lut")) } else { None };
+                let look_lut = if creative_on { crate::luts::resolve(cx.project, text(e, "look_lut")) } else { None };
+                let look = if creative_on && look_lut.is_none() { choice(e, "look") } else { 0 };
                 let sharpen = if creative_on { f(e, "sharpen", cx) / 100.0 } else { 0.0 };
 
                 let curves_on = on(e, "curves_on");
@@ -529,7 +601,7 @@ impl FxOp {
 
                 let hsl_on = b(e, "hsl_on");
 
-                let gpu_capable = !is_hdr && !has_input_lut && !has_look_lut && look == 0 && sharpen.abs() <= 1e-3 && !has_curves && !has_wheels && !hsl_on;
+                let gpu_capable = look == 0 && sharpen.abs() <= 1e-3 && !has_curves && !has_wheels && !hsl_on;
 
                 let bf = |id: &str| if basic_on { f(e, id, cx) } else { 0.0 };
                 let temp = bf("temperature") / 100.0;
@@ -572,6 +644,14 @@ impl FxOp {
                     vround,
                     vfeather,
                     aspect,
+                    grade: gs,
+                    specular: if is_hdr && basic_on { f(e, "hdr_specular", cx) / 100.0 } else { 0.0 },
+                    input_lut: input_lut.map(|lut| LumetriLut { lut, grade: gs, mix: None }),
+                    look_lut: look_lut.map(|lut| LumetriLut {
+                        lut,
+                        grade: grade_space(e, cx, "curves_hdr_range"),
+                        mix: Some(f(e, "look_intensity", cx) / 100.0),
+                    }),
                     gpu_capable,
                 }
             }
@@ -620,8 +700,40 @@ impl FxOp {
             FxOp::AlphaAdjust { opacity, .. } => opacity.is_finite(),
             FxOp::Vignette { amount, midpoint, roundness, feather, target } => fin(&[*amount, *midpoint, *roundness, *feather]) && fin(target),
             FxOp::VideoLimiter { max, comp, axis, warning_color, .. } => *axis <= 3 && max.is_finite() && comp.is_finite() && fin(warning_color),
-            FxOp::Lumetri { gains, exposure, contrast, hl, sh, wh, bl, sat, faded, vib, st, ht, va, vmid, vround, vfeather, aspect, gpu_capable, .. } => {
+            FxOp::Lumetri {
+                gains,
+                exposure,
+                contrast,
+                hl,
+                sh,
+                wh,
+                bl,
+                sat,
+                faded,
+                vib,
+                st,
+                ht,
+                va,
+                vmid,
+                vround,
+                vfeather,
+                aspect,
+                grade,
+                specular,
+                input_lut,
+                look_lut,
+                gpu_capable,
+                ..
+            } => {
                 *gpu_capable
+                    && input_lut.as_ref().is_none_or(LumetriLut::gpu_ok)
+                    && look_lut.as_ref().is_none_or(LumetriLut::gpu_ok)
+                    && specular.is_finite()
+                    && match *grade {
+                        GradeSpace::Sdr => true,
+                        GradeSpace::Pq { white, norm } => white.is_finite() && norm.is_finite() && norm > 0.0,
+                        GradeSpace::Hlg { white, gamma } => white.is_finite() && gamma.is_finite() && gamma > 0.0,
+                    }
                     && fin(gains)
                     && fin(&[*exposure, *contrast, *hl, *sh, *wh, *bl, *sat, *faded, *vib])
                     && fin(st)
@@ -942,17 +1054,28 @@ impl FxOp {
                 vround,
                 vfeather,
                 aspect,
+                grade,
+                specular,
+                input_lut,
+                look_lut,
                 ..
             } => {
+                let (gs, specular) = (*grade, *specular);
+                let (input_lut, look_lut) = (input_lut.as_ref(), look_lut.as_ref());
+                let hdr = gs.is_hdr();
                 let gains = *gains;
                 let (exposure, contrast, hl, sh, wh, bl, sat) = (*exposure, *contrast, *hl, *sh, *wh, *bl, *sat);
                 let (creative_on, faded, vib, st, ht) = (*creative_on, *faded, *vib, *st, *ht);
                 let (vignette_on, va, vmid, vround, vfeather, aspect) = (*vignette_on, *va, *vmid, *vround, *vfeather, *aspect);
                 let (w, h) = (img.w as f32, img.h as f32);
                 img.map_rgb(|c, x, y| {
+                    let c = match input_lut {
+                        Some(l) => l.apply(c),
+                        None => c,
+                    };
                     // white balance + exposure in linear light
                     let lin = [c[0] * gains[0] * exposure, c[1] * gains[1] * exposure, c[2] * gains[2] * exposure];
-                    let mut v = enc(lin);
+                    let mut v = gs.encode(lin);
                     // whites / blacks: endpoints
                     let b0 = -bl * 0.15;
                     let w0 = 1.0 - wh * 0.15;
@@ -966,15 +1089,23 @@ impl FxOp {
                         let k = nl / l;
                         v = v.map(|q| q * k);
                     }
-                    // contrast: smooth S-curve around mid grey
+                    // contrast: smooth S-curve around mid grey (HDR: over 0 … HDR White;
+                    // speculars above it keep their distance from white)
                     if contrast.abs() > 1e-4 {
                         let k = 1.0 + contrast;
                         v = v.map(|q| {
+                            if hdr && q > 1.0 {
+                                return q;
+                            }
                             let q = q.clamp(0.0, 1.0);
                             let s = q * q * (3.0 - 2.0 * q);
                             if k >= 1.0 { q + (s - q) * (k - 1.0) } else { 0.5 + (q - 0.5) * k }
                         });
                     }
+                    // HDR Specular: brightness of the highlights above HDR White
+                    if specular.abs() > 1e-4 {
+                        v = v.map(|q| if q > 1.0 { 1.0 + (q - 1.0) * (1.0 + specular).max(0.0) } else { q });
+                    }
                     // faded film: lift blacks and compress
                     if faded > 0.0 {
                         v = v.map(|q| q * (1.0 - 0.25 * faded) + 0.12 * faded);
@@ -1001,8 +1132,13 @@ impl FxOp {
                         let k = 1.0 + va * 0.2 * e2;
                         v = v.map(|q| if va < 0.0 { q * k.max(0.0) } else { q + (1.0 - q) * (k - 1.0) });
                     }
-                    dec(v)
+                    gs.decode(v)
                 });
+                // the Look is a pass of its own after the grade, as `lumetri_advanced` after
+                // `fn lumetri`'s pass (and the GPU's LUT pass after its grade pass)
+                if let Some(l) = look_lut {
+                    img.map_rgb(|c, _, _| l.apply(c));
+                }
             }
         }
     }
diff --git a/crates/render/src/plan.rs b/crates/render/src/plan.rs
--- a/crates/render/src/plan.rs
+++ b/crates/render/src/plan.rs
@@ -229,7 +229,13 @@ fn gpu_chain<'a>(project: &Project, item: &'a TrackItem, opts: RenderOptions) ->
 pub fn plan_frame(project: &Project, seq_id: ItemId, t: Tick, opts: RenderOptions, sources: &dyn SourceProvider) -> FramePlan {
     let Some(seq) = project.sequence(seq_id) else { return FramePlan::Image(crate::Image::new(1, 1)) };
     let (w, h) = output_size(seq, opts.scale);
-    if whole_frame_on_cpu(project, seq, t) {
+    // An HDR / wide-gamut sequence plans as layers too: its sources are decoded into the working
+    // space (the shader's input transform for display-referred media, `colorman::decode` on the
+    // CPU for log media and camera gamuts) and the compositor's resolve pass runs the sequence's
+    // display transform (working space → monitor). Frames the compositor cannot finish stay on
+    // the CPU: an adjustment layer, a transition it cannot mix, or a display transform the shader
+    // cannot encode (an HDR target).
+    if !layered_at(project, seq, t) || crate::colorman::display_transform(&seq.settings.color).gpu_params().is_none() {
         return FramePlan::Image(crate::render_sequence(project, seq_id, t, opts, sources));
     }
     let mut layers = PlanStack::default();
@@ -256,13 +262,17 @@ pub fn plan_frame(project: &Project, seq_id: ItemId, t: Tick, opts: RenderOption
 /// Whether [`plan_frame`] hands the frame at `t` back as one CPU image ([`FramePlan::Image`]). A
 /// GPU renderer gains nothing on such a frame, so a caller with only a few of them (the export's
 /// pool) can render it on the CPU without taking one.
+/// Whether the frame at `t` needs the CPU compositor: an HDR / wide-gamut sequence, an adjustment
+/// layer, or a transition the compositor cannot mix. Callers that render on the CPU only (the
+/// export's pool) use it to skip taking a GPU plan; [`plan_frame`] itself plans an HDR / wide-gamut
+/// frame as layers whenever the compositor can finish it.
 pub fn is_cpu_frame(project: &Project, seq_id: ItemId, t: Tick) -> bool {
     project.sequence(seq_id).is_none_or(|seq| whole_frame_on_cpu(project, seq, t))
 }
 
-/// The frames [`plan_frame`] does not plan: an HDR / wide-gamut sequence composites and converts
-/// on the CPU, and so does a frame with an adjustment layer or a complex transition anywhere at
-/// `t` (whole-frame fallback).
+/// The frames the CPU-only path ([`is_cpu_frame`]) covers: an HDR / wide-gamut sequence (its
+/// display transform and any log-source decode), an adjustment layer, or a complex transition
+/// anywhere at `t` (whole-frame fallback).
 fn whole_frame_on_cpu(project: &Project, seq: &Sequence, t: Tick) -> bool {
     !seq.settings.color.is_plain() || !layered_at(project, seq, t)
 }
diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs
--- a/crates/render/src/tests.rs
+++ b/crates/render/src/tests.rs
@@ -221,6 +221,55 @@ fn plan_matches_reference_renderer() {
     }
 }
 
+/// A clip whose Lumetri has an Input LUT and a Creative Look LUT plans as a layer with GPU
+/// effects (it used to be pre-rendered on the CPU, 270 ms a frame for a 4K clip), in SDR and HDR
+/// sequences, and the plan's picture is the reference renderer's.
+#[test]
+fn lumetri_luts_plan_as_gpu_effects() {
+    // a Y'CbCr source, as decoders hand over (the compositor colour manages those itself)
+    let (mut p, red, _ocean, seq, mut map) = setup();
+    let matte = GeneratorSource::new(Generator::ColorMatte { color: [0.0, 0.0, 0.0, 1.0] }, 320, 180, FrameRate::FPS_24, Tick(10 * TICKS_PER_SECOND));
+    map.0.insert(red, Arc::new(YuvSource(matte)) as SharedSource);
+    let clip = place(&mut p, seq, 0, red, 0, 48);
+    {
+        let s = p.sequence_mut(seq).unwrap();
+        let (_, it) = s.find_item_mut(clip).unwrap();
+        let mut e = filmcraft_project::find_effect("lumetri").unwrap().instance();
+        for (k, v) in [
+            ("input_lut", ParamValue::Text("builtin:slog3-sgamut3cine-to-rec709".into())),
+            ("look_lut", ParamValue::Text("builtin:look-bleach-bypass".into())),
+            ("exposure", ParamValue::Float(-0.5)),
+            ("contrast", ParamValue::Float(15.0)),
+        ] {
+            e.param_mut(k).unwrap().value = v;
+        }
+        it.effects.push(e);
+    }
+    let r = FrameRate::FPS_24;
+    for working in [filmcraft_color::WorkingSpace::Rec709, filmcraft_color::WorkingSpace::Rec2100Hlg] {
+        p.sequence_mut(seq).unwrap().settings.color.working = working;
+        for f in [0, 20] {
+            let opts = RenderOptions { scale: 0.5, ..Default::default() };
+            let plan = plan::plan_frame(&p, seq, r.tick_of(f), opts, &map);
+            let plan::FramePlan::Layers { layers, .. } = &plan else { panic!("{working:?} frame {f}: not planned as layers") };
+            let fx = layers.iter().find_map(|l| l.fx.as_ref()).unwrap_or_else(|| panic!("{working:?} frame {f}: the Lumetri layer has no GPU effects"));
+            assert!(
+                fx.ops.iter().any(|o| matches!(o, gpufx::FxOp::Lumetri { input_lut: Some(_), look_lut: Some(_), .. })),
+                "{working:?} frame {f}: {:?}",
+                fx.ops
+            );
+            let reference = render_sequence(&p, seq, r.tick_of(f), opts, &map);
+            // the plan composites in the working space; the compositor's resolve pass (here
+            // `to_display`) brings it to the monitor, as `render_sequence` does at its end
+            let mut got = plan::execute_cpu(&plan);
+            colorman::to_display(&mut got, &p.sequence(seq).unwrap().settings.color);
+            assert_eq!((got.w, got.h), (reference.w, reference.h));
+            let max = got.px.iter().zip(&reference.px).map(|(a, b)| (a - b).abs()).fold(0f32, f32::max);
+            assert!(max < 0.02, "{working:?} frame {f}: max diff {max}");
+        }
+    }
+}
+
 #[test]
 fn push_plan_preserves_reference_track_order_and_reverse() {
     let (mut p, red, ocean, seq, map) = setup();
diff --git a/crates/ui-egui/src/app/gpu.rs b/crates/ui-egui/src/app/gpu.rs
--- a/crates/ui-egui/src/app/gpu.rs
+++ b/crates/ui-egui/src/app/gpu.rs
@@ -46,6 +46,15 @@ impl FilmcraftApp {
             self.disable_gpu("device error");
             return None;
         }
+        // The resolve pass finishes a *sequence* frame: the sequence's working space → monitor
+        // (`colorman::display_transform`). A plain sequence's transform is the identity, and item
+        // frames (Source monitor, thumbnails) are already in their item's space.
+        let pipe = match key.target {
+            Target::Sequence(id) | Target::SequencePlan(id) => self.session.project.sequence(id).map(|q| q.settings.color),
+            _ => None,
+        };
+        // A CPU-rendered frame (`FramePlan::Image`) is already in monitor space.
+        let display = filmcraft_gpu::sequence_display(&plan.plan, pipe.and_then(|c| filmcraft_render::colorman::display_transform(&c).gpu_params()));
         let g = self.gpu.as_mut()?;
         if g.last_key == Some(key)
             && let Some(t) = g.texture
@@ -57,6 +66,7 @@ impl FilmcraftApp {
             return None;
         }
         let t0 = web_time::Instant::now();
+        g.compositor.set_display_transform(display);
         let ran = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
             let (view, size) = g.compositor.composite_prepared(&plan.plan, Some(&plan.prepared));
             (view.clone(), size)
diff --git a/docs/architecture.md b/docs/architecture.md
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -561,9 +561,12 @@ frame (Y'CbCr/RGB + metadata) ─► source colour space: Interpret Footage over
 - **Maths** in `filmcraft-color` (`transform`, `log`, `spaces`; formulas and sources in
   [crates/color/README.md](../crates/color/README.md)); the renderer side is `render::colorman`.
 - **Fast path.** A Rec. 709 sequence without wide gamut and media whose metadata says Rec. 709 /
-  sRGB decodes exactly as before, and its layers stay on the GPU. Log, HDR or wide-gamut media,
-  and every layer of an HDR/wide-gamut sequence, are converted on the CPU (the GPU path draws them
-  as pre-rendered images, like layers with effects).
+  sRGB decodes exactly as before, and its layers stay on the GPU. Display-referred HDR or
+  wide-gamut media (PQ, HLG, BT.2020, P3) is converted in the compositor's shader, and an HDR /
+  wide-gamut sequence composites on the GPU too: the resolve pass runs its display transform
+  (working space → monitor, `OutputTransform::gpu_params`); a display transform the shader cannot
+  encode keeps the whole frame on the CPU. Log media and camera gamuts are converted on the CPU
+  (the GPU path draws them as pre-rendered images, like layers with effects).
 - **Outputs.** `RenderOptions::working_output` returns working-space pixels (HDR exports,
   scopes); otherwise the top-level render is converted for an SDR monitor. The other colour
   effects work on display-encoded values clamped to 0..1 (they clip HDR highlights above
@@ -574,8 +577,9 @@ frame (Y'CbCr/RGB + metadata) ─► source colour space: Interpret Footage over
   Range**): `filmcraft_color::GradeSpace` (PQ: `PQ⁻¹(nits/10000) / PQ⁻¹(white/10000)`; HLG: the
   BT.2100 inverse OOTF of a display with that peak, then the OETF). So the sliders and curves span
   0 … HDR White like black … white in SDR, exposure is stops of light, and highlights above HDR
-  White pass through (contrast leaves them alone; **HDR Specular** scales them). Rec. 709
-  sequences grade exactly as before. HSL Secondary ▸ Refine: **Denoise** (median of the key,
+  White pass through (contrast leaves them alone; **HDR Specular** scales them). The GPU Lumetri
+  op (basic, creative, vignette, Input LUT, Look LUT) grades the same signal. Rec. 709 sequences grade exactly as
+  before. HSL Secondary ▸ Refine: **Denoise** (median of the key,
   radius 1–3 px) and **Blur** (Gaussian of the key, σ ≤ 20 px), both scaled with the playback
   resolution.
 - **HDR metadata.** `VideoStreamInfo::hdr` (`HdrMetadata`: ST 2086 mastering luminance, MaxCLL,
@@ -597,7 +601,11 @@ frame (Y'CbCr/RGB + metadata) ─► source colour space: Interpret Footage over
 - **LUTs.** Lumetri Input LUT and Creative Look reference `lib:<id>` (the project's LUT library,
   `Project::luts`, embedded `.cube`/`.3dl` text) or `builtin:<id>` (code-generated camera
   conversions and looks). `filmcraft-gpu::GpuLut` is the WGSL tetrahedral counterpart, tested for
-  parity.
+  parity. In playback and GPU export the Lumetri op carries its resolved LUTs
+  (`render::gpufx::LumetriLut`): the effect stage runs a LUT pass before the grade (Input LUT, on
+  the HDR White signal) and after it (Look LUT mixed by Intensity, on the curves' HDR Range
+  signal), reading the shaper and cube from a storage buffer uploaded once per LUT and kept while
+  frames use it. Cubes above 65³ and shapers above 65536 entries render the effect on the CPU.
 
 ### 5.4 Multi-camera and synchronisation
 
diff --git a/docs/gaps.md b/docs/gaps.md
--- a/docs/gaps.md
+++ b/docs/gaps.md
@@ -1,6 +1,6 @@
 # Where FilmCraft falls short of Premiere Pro
 
-> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (new file: ROADMAP's "Where we are lacking" re-measured against Premiere Pro 26.5.2 and the open issue list) · **Target:** Adobe Premiere Pro 2026 (26.5.2)
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (G5: Lumetri Input LUT and Look LUT on the GPU) · **Target:** Adobe Premiere Pro 2026 (26.5.2)
 
 Every known shortfall, one entry each, ranked by what most stops an editor using FilmCraft for real
 work. This is the work list: agents should prefer it over adding checklist items. Numbers are in
@@ -105,7 +105,7 @@ platforms** G4, G9, G17; **performance** G16; **ecosystem** G11; **measurement**
 - **Have:** hardware decode on macOS (VideoToolbox H.264 / HEVC), Windows (Media Foundation /
   D3D11: H.264, HEVC, VP9, AV1) and Linux (VA-API and NVDEC: H.264, HEVC); hardware encode
   VideoToolbox H.264 / HEVC 8-bit, NVENC H.264 (Windows, Linux) and HEVC incl. Main 10 HDR
-  (Windows); GPU compositing, blend modes, 34 effect ids incl. Lumetri basic / creative / vignette,
+  (Windows); GPU compositing, blend modes, 34 effect ids incl. Lumetri basic / creative / vignette (also in Rec. 2100 PQ / HLG sequences) with its Input LUT and Look LUT (a Lumetri with a procedural Look, sharpen, curves, wheels or HSL set renders on the CPU),
   GPU export rendering (#421).
 - **Missing:** zero-copy decoded frames into wgpu (every frame is read back to the CPU and
   re-uploaded); VideoToolbox Main 10 / HDR HEVC encode and B-frames; ProRes hardware decode /
@@ -323,3 +323,5 @@ platforms** G4, G9, G17; **performance** G16; **ecosystem** G11; **measurement**
 | 2026-10-12 | minor | G10: MP1 (`A_MPEG/L1`) in Matroska imports (#1402) |
 | 2026-10-12 | minor | G10: OpenEXR PXR24 import |
 | 2026-10-24 | minor | G8: dragging the cut on a transition's timeline in Effect Controls moves the cut, and the transition moves with it (#577) |
+| 2026-10-24 | minor | G5: Lumetri basic / creative / vignette on the GPU in Rec. 2100 PQ / HLG sequences too |
+| 2026-10-25 | minor | G5: Lumetri Input LUT and Look LUT on the GPU (SDR and Rec. 2100 PQ / HLG) |
diff --git a/docs/hardware-parity.md b/docs/hardware-parity.md
--- a/docs/hardware-parity.md
+++ b/docs/hardware-parity.md
@@ -1,6 +1,6 @@
 # Hardware parity
 
-> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (new file: hardware decode / encode per platform and codec, GPU effects, devices; re-measured after NVDEC #549 and GPU export #421) · **Target:** Adobe Premiere Pro 2026 (26.5.2)
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (GPU effects: Lumetri Input LUT and Look LUT) · **Target:** Adobe Premiere Pro 2026 (26.5.2)
 
 Each hardware feature Premiere uses, per platform, against ours. Implementation detail lives in
 [`crates/platform/README.md`](../crates/platform/README.md) (decode / encode backends, the one
@@ -50,7 +50,7 @@ against 793 s with our software encoder (SSIM 0.990). NVENC HEVC round trip 48.9
 | Feature | Premiere (Mercury Playback Engine: Metal, CUDA, OpenCL) | Ours (wgpu: Metal, D3D12, Vulkan) | % |
 |---|---|---|---|
 | Compositing, blend modes, transforms | GPU | **GPU** (#32), CPU compositor is the oracle | 90% |
-| Accelerated video effects | ~82 of ~92 effects marked Accelerated (reference list `plan/premiere/10-effects-list.md`) | **34 effect ids** (`render::gpufx::GPU_EFFECTS`): colour, levels, blurs, sharpen, crop, transform, flips, vignette, video limiter, ASC CDL, channel mix, Lumetri basic / creative / vignette | ~40% |
+| Accelerated video effects | ~82 of ~92 effects marked Accelerated (reference list `plan/premiere/10-effects-list.md`) | **34 effect ids** (`render::gpufx::GPU_EFFECTS`): colour, levels, blurs, sharpen, crop, transform, flips, vignette, video limiter, ASC CDL, channel mix, Lumetri basic / creative / vignette with Input LUT and Look LUT (SDR and Rec. 2100 PQ / HLG; with no procedural Look, sharpen, curves, wheels or HSL set) | ~40% |
 | Lumetri curves, wheels, HSL secondary | GPU | CPU | 0% |
 | Keys (Ultra Key), masks, distortions, transitions | GPU | CPU (masks have WGSL parity tests, not in the live path) | 10% |
 | Export rendering on the GPU | yes | **yes** (#421; Export ▸ GPU Rendering, Auto): 2.4× (1080p) to 3.2× (4K) faster on effects-heavy timelines | 80% |
@@ -85,3 +85,5 @@ one encoder ~4–8 h ([target-app-parity.md](target-app-parity.md#calibration)).
 | Date | Change | Summary |
 |---|---|---|
 | 2026-10-10 | major | Created from ROADMAP.md's performance-and-hardware row and `crates/platform/README.md`; added Linux NVDEC (#549), GPU export and Lumetri on the GPU (#421), Premiere's per-platform hardware list, devices and I/O |
+| 2026-10-24 | minor | GPU effects: Lumetri basic / creative / vignette in Rec. 2100 PQ / HLG sequences too |
+| 2026-10-25 | minor | GPU effects: Lumetri Input LUT and Look LUT (cubes up to 65³, shapers up to 65536 entries) |
diff --git a/docs/performance.md b/docs/performance.md
--- a/docs/performance.md
+++ b/docs/performance.md
@@ -80,6 +80,37 @@ What remains per live decoder is unchanged: the H.264 decoder publishes each mac
 picture as its own allocations (`Frame::make_row`, five per row), which is where the 290 MB per
 decoder comes from.
 
+## Results (Lumetri Input LUT and Look LUT on the GPU, before → after)
+
+A Lumetri with an Input LUT or a Look LUT (a built-in Look included) was evaluated on the CPU:
+`gpufx::FxOp::Lumetri` refused LUTs, so the whole layer was pre-rendered by the frame workers (Y'CbCr
+→ working space, the grade, the 3D LUT). The op now carries the resolved LUTs and the effect stage
+runs them as LUT passes (`fx.wgsl` `lut_op`, the tables in a storage buffer kept while frames use
+them).
+
+A real project, measured in the desktop app over the control channel: a 3840×2160 50p Rec. 2100
+HLG sequence with a 20 s HEVC Main 10 S-Log3 clip (150 Mb/s, VA-API decoding) graded by Lumetri
+(exposure, contrast, highlights, creative saturation, highlight tint, vignette and the built-in
+Bleach Bypass Look) plus Crop; for its first 5 s a Color Matte and a graphic (two text and three
+shape layers) with keyframed opacity and scale above it. Program monitor at 1/2, 8 s plays from
+0, 6 and 12 s. AMD Ryzen 7 5800X3D (16 threads), Radeon RX 9070 XT (Mesa 26.2.5, RADV), 31 GB,
+2026-10-25; the before build (the same tree without this change) and the after build alternated
+twice in one session, load average 13 → 7 (1-minute), then a third after run at 4.9 with the
+plays in another order.
+
+| play | shown / dropped before (2 runs) | after (3 runs) | process CPU s per play before → after |
+|---|---|---|---|
+| from 6 s | 14 / 375, 11 / 373 | **395 / 0, 395 / 0, 396 / 0** | 58.6–61 → **25** |
+| from 12 s | 15 / 374, 7 / 375 | **396 / 0, 396 / 0, 395 / 0** | 57–63 → **25** |
+| from 0 s (matte and graphic animating) | 8 / 381, 6 / 367 | 274 / 120, 114 / 274, 121 / 269 and 119 / 273 | 61–63 → 39–49 |
+
+Frame-worker render time per frame (`perf.stats` `frames.renderMs` p50) went from 96–227 ms to 0:
+the grade runs on the GPU when the frame is presented. The first 5 s still drop frames, in any
+order of the plays. Most likely (not measured separately) that is the keyframed graphic above the
+clip, which is rasterised on the CPU for every frame (a graphic's raster is reused only while it
+does not change). A Program monitor frame at 8 s (Full resolution, paused)
+differs from the before build by a mean of 0.05 8-bit levels (99th percentile 1, max 5).
+
 ## Results (CPU compositor: layer rectangles, an opaque bottom layer and recycled images, before → after)
 
 The CPU compositor is what export runs. In a profile of a 9:29 export it took about a third of the
diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md
--- a/docs/target-app-parity.md
+++ b/docs/target-app-parity.md
@@ -1,6 +1,6 @@
 # Parity with Adobe Premiere Pro
 
-> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (full re-measure against Premiere Pro 2026 26.5.2; replaces the scorecard and estimate that lived in ROADMAP.md) · **Target:** Adobe Premiere Pro 2026 (26.5.2, macOS)
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (Colour: Lumetri Input LUT and Look LUT on the GPU) · **Target:** Adobe Premiere Pro 2026 (26.5.2, macOS)
 
 The authoritative assessment of how close FilmCraft is to Premiere Pro, and how much work is left.
 [ROADMAP.md](../ROADMAP.md) summarizes it; [gaps.md](gaps.md) lists every shortfall one at a time.
@@ -112,7 +112,7 @@ agent-hours to close the area to Premiere, sequential.
 | Media import and codecs | 12% | ~75% | ~60% | 150–250 | Own bit-exact decoders for H.264, HEVC, VP9, AV1, ProRes, DNx, APV, MPEG-2, AAC, AC-3, Opus. But H.264 only 8-bit 4:2:0 and HEVC no 4:2:2 in software (most 10-bit cameras, #626); no camera RAW (R3D, ARRIRAW, BRAW, Sony RAW, Canon RAW, ProRes RAW, #345, #383); no E-AC-3 (#647), AVI (#598), WMV, DV, XAVC / P2 metadata; Sony start timecode (#460); WebM black (#432) | [codec-parity.md](codec-parity.md), [file-format-parity.md](file-format-parity.md) |
 | Audio | 10% | ~88% | ~65% | 50–90 | Mixer graph, automation, all 53 effects, Essential Sound, 5.1, voice-over, Remix, multi-stream container audio (#361). Device and playback reports: crackle / cut-outs (#336, #410, #602, #605, #617); Enhance Speech is DSP, not a model; ALSA fallback fixed (#254) | |
 | Panels and UI fidelity | 10% | ~88% | ~60% | 120–200 | All main panels. Missing: on-screen transform handles in the Program monitor (#639), Effect Controls keyframe zoom / divider / row lines (#640–#645), docking rearrangement (#493), multi-monitor windows (#466), filmstrip thumbnails (#614), marquee in Project panel (#578) | [ui-parity.md](ui-parity.md) |
-| Colour | 8% | ~90% | ~70% | 40–70 | Lumetri complete incl. scopes, LUTs, colour management, HDR export. Lumetri on the GPU covers basic, creative and vignette only; HDR-aware Lumetri maths, macOS EDR display, D-Log M; a Lumetri report (#628) | |
+| Colour | 8% | ~90% | ~70% | 40–70 | Lumetri complete incl. scopes, LUTs, colour management, HDR export. Lumetri on the GPU covers basic, creative, vignette, Input LUT and Look LUT only (in SDR and Rec. 2100 PQ / HLG sequences), and only while no procedural Look, sharpen, curves, wheels or HSL is set (those render the effect on the CPU); HDR-aware Lumetri maths, macOS EDR display, D-Log M; a Lumetri report (#628) | |
 | Graphics, titles, captions | 8% | ~85% | ~65% | 50–90 | Text engine with shaping and bidi, shapes, gradients (#514), our `.fcgt` templates (`.mogrt` refused by design), rolls / crawls, all caption formats. Arabic titles reported broken (#395); no 608/708 embedding in the video stream; speech-to-text off in release builds | |
 | Export and delivery | 8% | ~80% | ~65% | 60–110 | H.264 (own encoder), HEVC through hardware only, ProRes, DNxHR, APV, MXF OP1a / OP-Atom, image sequences, GIF, WAV / AIFF, presets, queue, GPU export rendering (#421). Missing: AV1, MPEG-2 / DVD / Blu-ray, DCP, JPEG 2000 / XS MXF, AS-10 / AS-11, XDCAM HD, EXR / DPX / Targa sequences, MP3, AAC-only audio, ProRes 4444 in the UI (#342), interlaced, smart render; bitrate target missed (#371) | [file-format-parity.md](file-format-parity.md) |
 | Project files and interchange | 8% | ~70% | ~45% | 90–160 | `.fcproj` with migrations, auto-save, recovery; FCP7 XML, FCPXML, AAF, OMF, EDL, OTIO, ALE. **Premiere's own `.prproj` cannot be opened** (the main beta blocker); AAF / OMF never validated in Media Composer / Pro Tools; XML import of a Premiere sequence took 97 s (#461) | [file-format-parity.md](file-format-parity.md) |
@@ -354,6 +354,8 @@ control surfaces), and validation in Avid Media Composer / Pro Tools.
 
 | Date | Change | Summary |
 |---|---|---|
+| 2026-10-24 | minor | Colour: Lumetri on the GPU in Rec. 2100 PQ / HLG sequences too |
+| 2026-10-25 | minor | Colour: Lumetri Input LUT and Look LUT on the GPU |
 | 2026-10-10 | minor | Readiness table: hours to ~95% for each audience (full ~1,450–2,600, mainstream ~500–850, essentials ~180–350); full number confirmed as an additive weighted sum (no change) |
 | 2026-10-10 | minor | Added the three readiness numbers: full ready-for-real-work formula written out (~55% -> ~56%, same inputs, now computed rather than rounded down), mainstream practitioner ~55% and essentials user ~60% with written weights and discounts, and counted user evidence (31 people praising, 0 switch reports, ~64 core-path vs ~29 niche open bugs) |
 | 2026-10-10 | minor | Stage checked against the new core-workflow gate (docs/roadmap.md#alpha-gate): passes, stays alpha |
diff --git a/docs/testing.md b/docs/testing.md
--- a/docs/testing.md
+++ b/docs/testing.md
@@ -146,8 +146,9 @@ Each codec README has the full fixture matrix and the measured results.
   to Black at 25 %, Wipe at 50 %, Timecode / Clip Name burn-in text, graphic clips (also a built-in
   graphics template with overridden properties and per-character styles with a pinned box), and colour
   management: S-Log3/S-Gamut3.Cine footage interpreted into Rec. 709 (`log_to_rec709`) and
-  Rec. 2100 PQ footage tone mapped into Rec. 709 (`hdr_tone_map`; the footage is demo frames
-  re-encoded by the test's `Encoded` source). Each frame is compared
+  Rec. 2100 PQ footage tone mapped into Rec. 709 (`hdr_tone_map`), and Lumetri grading in a
+  Rec. 2100 PQ sequence, tone mapped for the monitor (`lumetri_hdr_pq`); the footage of both is
+  demo frames re-encoded by the test's `Encoded` source. Each frame is compared
   with `crates/golden/goldens/<scene>.png` by `filmcraft_testkit::golden`:
   **PSNR ≥ 45 dB, max abs ≤ 12, 99th-percentile per-pixel max channel difference ≤ 2** (8-bit
   sRGB levels; `Tolerance::RENDER`). On failure the actual frame and a ×8 difference image go to
@@ -176,6 +177,9 @@ Each codec README has the full fixture matrix and the measured results.
   This found a real bug: the GPU upload cache was keyed by pixel-buffer address without keeping
   the buffer alive, so a new frame allocated at a freed frame's address was drawn with the stale
   texture (fixed; regression test `upload_cache_keeps_buffers_alive`).
+- `gpu_matches_cpu_on_golden_scenes` sets each scene's display transform as the app does, so the
+  colour-managed scenes (`hdr_tone_map`, and `lumetri_hdr_pq` in a Rec. 2100 PQ sequence) are
+  converted and tone mapped on the GPU too.
 
 ## 4. UI: control channel and screenshots
 
