mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-06 18:41:26 +00:00
Follow-up to #2911: B44 at float is labelled uncompressed, half holds over-range samples
B44 and B44A pack only half floats, so at 32-bit float the menu and the render log say the samples stay uncompressed. Half-float frames hold samples above 65 504 at that limit instead of writing infinity. The EXR parameters move into their own helper; exr_out is public for other writers. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
b5fe54c08b
commit
51d7e530a0
8 files changed
+104
-25
No files matched your search
@@ -314,6 +314,17 @@ fn apply_resize(om: &mut OutputModule, p: &Value, cmd: &str) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// OpenEXR `exrCompression` and `exrBitDepth`.
|
||||
fn apply_exr(om: &mut OutputModule, p: &Value, cmd: &str) -> Result<()> {
|
||||
if let Some(c) = enum_p(p, "exrCompression", cmd, ExrCompression::from_name, EXR_COMPRESSIONS)? {
|
||||
om.exr_compression = c;
|
||||
}
|
||||
if let Some(d) = enum_p(p, "exrBitDepth", cmd, ExrBitDepth::from_name, "half|float")? {
|
||||
om.exr_bit_depth = d;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Apply Output Module parameters (`template` first). `roi`: the viewer's region of interest
|
||||
/// (captured by `crop.useRoi`).
|
||||
fn apply_output(om: &mut OutputModule, templates: &RenderTemplates, roi: Option<[f64; 4]>, p: &Value, cmd: &str) -> Result<bool> {
|
||||
@@ -347,12 +358,7 @@ fn apply_output(om: &mut OutputModule, templates: &RenderTemplates, roi: Option<
|
||||
if let Some(pp) = str_p(p, "proresProfile") {
|
||||
om.prores_profile = ProResProfile::from_name(pp).ok_or_else(|| bad(cmd, "proresProfile: proxy|lt|standard|hq|4444|4444xq"))?;
|
||||
}
|
||||
if let Some(c) = enum_p(p, "exrCompression", cmd, ExrCompression::from_name, EXR_COMPRESSIONS)? {
|
||||
om.exr_compression = c;
|
||||
}
|
||||
if let Some(d) = enum_p(p, "exrBitDepth", cmd, ExrBitDepth::from_name, "half|float")? {
|
||||
om.exr_bit_depth = d;
|
||||
}
|
||||
apply_exr(om, p, cmd)?;
|
||||
apply_crop(om, roi, p, cmd)?;
|
||||
apply_resize(om, p, cmd)?;
|
||||
if let Some(a) = p.get("audio") {
|
||||
|
||||
@@ -221,3 +221,20 @@ fn exr_compression_and_bit_depth_options() {
|
||||
assert_eq!((q[1].output.exr_compression, q[1].output.exr_bit_depth), (ExrCompression::Rle, ExrBitDepth::Half));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Settings ▸ Export ▸ Append Bit Depth to File Name names a half-float OpenEXR module's frames
|
||||
/// `_16bpc` and a 32-bit one's `_32bpc` (#2911).
|
||||
#[test]
|
||||
fn append_bit_depth_follows_the_exr_bit_depth() {
|
||||
use effectcraft_project::render_queue::{ExrBitDepth, RenderQueueItem};
|
||||
let mut s = session();
|
||||
s.execute("prefs.set", json!({"values": {"export.appendBitsToName": true}})).unwrap();
|
||||
let mut item = RenderQueueItem::new(1, s.active_comp_id().unwrap());
|
||||
item.output = effectcraft_project::render_queue::OutputModule::for_format(OutputFormat::ExrSequence);
|
||||
item.output.output = "/out/Main_[#####].exr".into();
|
||||
for (depth, tag) in [(ExrBitDepth::Half, "_16bpc"), (ExrBitDepth::Float, "_32bpc")] {
|
||||
item.output.exr_bit_depth = depth;
|
||||
let outs = s.outputs_of(&item, None).unwrap();
|
||||
assert!(outs.iter().all(|(_, p)| p.contains(tag)), "{depth:?}: {outs:?}");
|
||||
}
|
||||
}
|
||||
@@ -7,15 +7,25 @@
|
||||
//! `exr` crate's default levels for ZIP and DWA. Half-float samples are rounded from the 32-bit
|
||||
//! render to the nearest half, ties to even. The lossy methods (PXR24 on float, B44 / B44A on half,
|
||||
//! DWAA / DWAB at either depth) keep every linear sample within 0.03 + 6 % of its value in the
|
||||
//! tests below. DWAA / DWAB files come out about the same size at either depth.
|
||||
//! tests below. Half float holds at most ±65 504: larger samples are written as that limit, not
|
||||
//! as infinity.
|
||||
|
||||
use std::io::{Seek, Write};
|
||||
|
||||
use effectcraft_project::render_queue::{ExrBitDepth, ExrCompression};
|
||||
use exr::prelude::{Blocks, Compression, Encoding, Image, Layer, LayerAttributes, LineOrder, SpecificChannels, Vec2, WritableImage, f16};
|
||||
|
||||
/// The largest finite half float.
|
||||
const HALF_MAX: f32 = 65_504.0;
|
||||
|
||||
/// A sample as half float: rounded to the nearest half (ties to even), held to ±65 504 so an
|
||||
/// over-range render doesn't turn into infinity. NaN stays NaN.
|
||||
fn to_half(v: f32) -> f16 {
|
||||
f16::from_f32(v.clamp(-HALF_MAX, HALF_MAX))
|
||||
}
|
||||
|
||||
/// The `exr` crate's compression for an Output Module setting.
|
||||
pub(crate) fn compression(c: ExrCompression) -> Compression {
|
||||
pub fn compression(c: ExrCompression) -> Compression {
|
||||
match c {
|
||||
ExrCompression::None => Compression::Uncompressed,
|
||||
ExrCompression::Rle => Compression::RLE,
|
||||
@@ -35,15 +45,10 @@ fn encoding(c: ExrCompression) -> Encoding {
|
||||
Encoding { compression: compression(c), blocks: Blocks::ScanLines, line_order: LineOrder::Increasing }
|
||||
}
|
||||
|
||||
/// Write `w`×`h` RGBA pixels (`data`: four linear, premultiplied samples per pixel, rows top to
|
||||
/// bottom) as one OpenEXR frame, its blocks compressed in parallel as before. A pixel `data`
|
||||
/// Write `w`×`h` RGBA pixels (`data`: four samples per pixel as they are to be written,
|
||||
/// premultiplied, rows top to bottom) as one OpenEXR frame, its blocks compressed in parallel as before. A pixel `data`
|
||||
/// doesn't cover is written as transparent black.
|
||||
pub(crate) fn write_rgba<W: Write + Seek>(
|
||||
file: &mut W,
|
||||
data: &[f32],
|
||||
(w, h): (usize, usize),
|
||||
(c, depth): (ExrCompression, ExrBitDepth),
|
||||
) -> exr::error::UnitResult {
|
||||
pub fn write_rgba<W: Write + Seek>(file: &mut W, data: &[f32], (w, h): (usize, usize), (c, depth): (ExrCompression, ExrBitDepth)) -> exr::error::UnitResult {
|
||||
let row = w.saturating_mul(4);
|
||||
let px = move |p: Vec2<usize>| -> [f32; 4] {
|
||||
let o = p.y().saturating_mul(row).saturating_add(p.x().saturating_mul(4));
|
||||
@@ -71,7 +76,7 @@ pub(crate) fn write_rgba<W: Write + Seek>(
|
||||
LayerAttributes::default(),
|
||||
encoding(c),
|
||||
SpecificChannels::rgba(move |p: Vec2<usize>| {
|
||||
let [r, g, b, a] = px(p).map(f16::from_f32);
|
||||
let [r, g, b, a] = px(p).map(to_half);
|
||||
(r, g, b, a)
|
||||
}),
|
||||
));
|
||||
@@ -152,7 +157,11 @@ mod tests {
|
||||
let ty = if depth == ExrBitDepth::Half { SampleType::F16 } else { SampleType::F32 };
|
||||
for c in ExrCompression::ALL {
|
||||
assert_eq!(c.is_lossy(depth), !compression(c).is_lossless_for(ty), "{c:?} {depth:?}");
|
||||
let (got, stored) = read_back(&write(&src, (w, h), c, depth));
|
||||
let bytes = write(&src, (w, h), c, depth);
|
||||
if c.is_lossy(depth) {
|
||||
assert!(bytes.len() < write(&src, (w, h), ExrCompression::None, depth).len(), "{c:?} {depth:?}: smaller than None");
|
||||
}
|
||||
let (got, stored) = read_back(&bytes);
|
||||
assert_eq!(stored, ty, "{c:?} {depth:?}");
|
||||
assert_eq!(got.len(), want.len());
|
||||
if c.is_lossy(depth) {
|
||||
@@ -204,6 +213,18 @@ mod tests {
|
||||
assert_eq!(write(&src, (w, h), default.0, default.1), before.into_inner());
|
||||
}
|
||||
|
||||
/// Half-float files hold samples above the half range at its largest finite value, not as
|
||||
/// infinity; the 32-bit file keeps them.
|
||||
#[test]
|
||||
fn half_clamps_over_range_samples_to_the_largest_half() {
|
||||
let data = [1.0e6, 70_000.0, 65_504.0, 1.0, -1.0e6, 0.5, f32::NAN, 1.0];
|
||||
let (half, _) = read_back(&write(&data, (2, 1), ExrCompression::Zip, ExrBitDepth::Half));
|
||||
assert_eq!(&half[..6], &[65_504.0, 65_504.0, 65_504.0, 1.0, -65_504.0, 0.5]);
|
||||
assert!(half[6].is_nan() && half.iter().all(|v| !v.is_infinite()));
|
||||
let (float, _) = read_back(&write(&data, (2, 1), ExrCompression::Zip, ExrBitDepth::Float));
|
||||
assert_eq!(&float[..2], &[1.0e6, 70_000.0]);
|
||||
}
|
||||
|
||||
/// Data shorter than the frame (never produced by the Render Queue) writes transparent black
|
||||
/// for the missing pixels instead of panicking.
|
||||
#[test]
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
mod encode;
|
||||
mod exr_out;
|
||||
pub mod exr_out;
|
||||
mod hevc_av1;
|
||||
mod out;
|
||||
mod pipeline;
|
||||
|
||||
@@ -972,6 +972,11 @@ impl ExrCompression {
|
||||
ExrCompression::Dwaa | ExrCompression::Dwab => true,
|
||||
}
|
||||
}
|
||||
/// Whether the method leaves the samples uncompressed at `depth` (B44 and B44A pack only half
|
||||
/// floats).
|
||||
pub fn stores_uncompressed(self, depth: ExrBitDepth) -> bool {
|
||||
self == ExrCompression::None || (matches!(self, ExrCompression::B44 | ExrCompression::B44a) && depth == ExrBitDepth::Float)
|
||||
}
|
||||
/// Parse a compression name: `none`/`uncompressed`, `rle`, `zips`/`zip1` (Nuke's "Zip (1
|
||||
/// scanline)"), `zip`/`zip16`,
|
||||
/// `piz`, `pxr24`, `b44`, `b44a`, `dwaa`, `dwab` (any case).
|
||||
@@ -1355,10 +1360,17 @@ impl OutputModule {
|
||||
}
|
||||
self.output = format!("{stem}.{}", format.extension());
|
||||
}
|
||||
/// The OpenEXR compression's label, marked when it is lossy at the module's bit depth.
|
||||
/// The OpenEXR compression's label, marked when it is lossy at the module's bit depth or, for
|
||||
/// B44 / B44A at 32-bit float, stores the samples uncompressed.
|
||||
pub fn exr_compression_label(&self) -> String {
|
||||
let c = self.exr_compression;
|
||||
if c.is_lossy(self.exr_bit_depth) { format!("{} (lossy)", c.label()) } else { c.label().into() }
|
||||
let (c, d) = (self.exr_compression, self.exr_bit_depth);
|
||||
if c.is_lossy(d) {
|
||||
format!("{} (lossy)", c.label())
|
||||
} else if c != ExrCompression::None && c.stores_uncompressed(d) {
|
||||
format!("{} (uncompressed at float)", c.label())
|
||||
} else {
|
||||
c.label().into()
|
||||
}
|
||||
}
|
||||
/// Whether this module can write alpha (the format can, and WebM uses VP9).
|
||||
pub fn supports_alpha(&self) -> bool {
|
||||
@@ -2074,6 +2086,11 @@ mod tests {
|
||||
assert!(m.describe().contains(&("Bit Depth", "16-bit Half Float".to_string())));
|
||||
let json = serde_json::to_string(&m).unwrap();
|
||||
assert_eq!(serde_json::from_str::<OutputModule>(&json).unwrap(), m);
|
||||
m.exr_compression = ExrCompression::B44a;
|
||||
m.exr_bit_depth = ExrBitDepth::Float;
|
||||
assert_eq!(m.exr_compression_label(), "B44A (uncompressed at float)");
|
||||
m.exr_bit_depth = ExrBitDepth::Half;
|
||||
assert_eq!(m.exr_compression_label(), "B44A (lossy)");
|
||||
let old: OutputModule = serde_json::from_str(r#"{"format":"ExrSequence","channels":"Rgba"}"#).unwrap();
|
||||
assert_eq!((old.exr_compression, old.exr_bit_depth), (ExrCompression::Zip, ExrBitDepth::Float));
|
||||
}
|
||||
|
||||
@@ -332,6 +332,7 @@ const JAPANESE: &[(&str, &str)] = &[
|
||||
("EXR Bit Depth: {}", "EXR ビット深度:{}"),
|
||||
("EXR Compression: {}", "EXR 圧縮:{}"),
|
||||
("EXR Compression: {} (lossy)", "EXR 圧縮:{}(非可逆)"),
|
||||
("EXR Compression: {} (uncompressed at float)", "EXR 圧縮:{}(float では非圧縮)"),
|
||||
("Empty folder", "空のフォルダー"),
|
||||
("Enable Depth of Field", "被写界深度を有効化"),
|
||||
("Enable Expression", "エクスプレッションを有効化"),
|
||||
@@ -1457,6 +1458,7 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[
|
||||
("EXR Bit Depth: {}", "EXR 位深度:{}"),
|
||||
("EXR Compression: {}", "EXR 压缩:{}"),
|
||||
("EXR Compression: {} (lossy)", "EXR 压缩:{}(有损)"),
|
||||
("EXR Compression: {} (uncompressed at float)", "EXR 压缩:{}(浮点下不压缩)"),
|
||||
("Empty folder", "空文件夹"),
|
||||
("Enable Depth of Field", "启用景深"),
|
||||
("Enable Expression", "启用表达式"),
|
||||
@@ -2567,6 +2569,7 @@ const SWEDISH: &[(&str, &str)] = &[
|
||||
("EXR Bit Depth: {}", "EXR-bitdjup: {}"),
|
||||
("EXR Compression: {}", "EXR-komprimering: {}"),
|
||||
("EXR Compression: {} (lossy)", "EXR-komprimering: {} (förstörande)"),
|
||||
("EXR Compression: {} (uncompressed at float)", "EXR-komprimering: {} (okomprimerad vid float)"),
|
||||
("Empty folder", "Tom mapp"),
|
||||
("Enable Depth of Field", "Aktivera skärpedjup"),
|
||||
("Enable Expression", "Aktivera uttryck"),
|
||||
@@ -3828,6 +3831,7 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[
|
||||
("EXR Bit Depth: {}", "EXR 位元深度:{}"),
|
||||
("EXR Compression: {}", "EXR 壓縮:{}"),
|
||||
("EXR Compression: {} (lossy)", "EXR 壓縮:{}(失真)"),
|
||||
("EXR Compression: {} (uncompressed at float)", "EXR 壓縮:{}(浮點時不壓縮)"),
|
||||
("Empty folder", "空資料夾"),
|
||||
("Enable Depth of Field", "啟用景深"),
|
||||
("Enable Expression", "啟用表達式"),
|
||||
@@ -4892,6 +4896,7 @@ const UKRAINIAN: &[(&str, &str)] = &[
|
||||
("EXR Bit Depth: {}", "Бітова глибина EXR: {}"),
|
||||
("EXR Compression: {}", "Стиснення EXR: {}"),
|
||||
("EXR Compression: {} (lossy)", "Стиснення EXR: {} (із втратами)"),
|
||||
("EXR Compression: {} (uncompressed at float)", "Стиснення EXR: {} (без стиснення для float)"),
|
||||
("Empty folder", "Порожня тека"),
|
||||
("Enable Depth of Field", "Увімкнути глибину різкості"),
|
||||
("Enable Expression", "Увімкнути вираз"),
|
||||
@@ -5458,6 +5463,7 @@ const PORTUGUESE: &[(&str, &str)] = &[
|
||||
("EXR Bit Depth: {}", "Profundidade de bits EXR: {}"),
|
||||
("EXR Compression: {}", "Compactação EXR: {}"),
|
||||
("EXR Compression: {} (lossy)", "Compactação EXR: {} (com perdas)"),
|
||||
("EXR Compression: {} (uncompressed at float)", "Compactação EXR: {} (sem compactação em float)"),
|
||||
("Empty folder", "Pasta vazia"),
|
||||
("Enable Depth of Field", "Ativar profundidade de campo"),
|
||||
("Enable Expression", "Ativar expressão"),
|
||||
@@ -6221,6 +6227,7 @@ const POLISH: &[(&str, &str)] = &[
|
||||
("EXR Bit Depth: {}", "Głębia bitowa EXR: {}"),
|
||||
("EXR Compression: {}", "Kompresja EXR: {}"),
|
||||
("EXR Compression: {} (lossy)", "Kompresja EXR: {} (stratna)"),
|
||||
("EXR Compression: {} (uncompressed at float)", "Kompresja EXR: {} (bez kompresji dla float)"),
|
||||
("Empty folder", "Pusty folder"),
|
||||
("Enable Depth of Field", "Włącz głębię ostrości"),
|
||||
("Enable Expression", "Włącz wyrażenie"),
|
||||
@@ -6970,6 +6977,7 @@ const CZECH: &[(&str, &str)] = &[
|
||||
("EXR Bit Depth: {}", "Bitová hloubka EXR: {}"),
|
||||
("EXR Compression: {}", "Komprese EXR: {}"),
|
||||
("EXR Compression: {} (lossy)", "Komprese EXR: {} (ztrátová)"),
|
||||
("EXR Compression: {} (uncompressed at float)", "Komprese EXR: {} (u float bez komprese)"),
|
||||
("Empty folder", "Prázdná složka"),
|
||||
("Enable Depth of Field", "Povolit hloubku ostrosti"),
|
||||
("Enable Expression", "Povolit výraz"),
|
||||
@@ -7538,6 +7546,7 @@ const SPANISH: &[(&str, &str)] = &[
|
||||
("EXR Bit Depth: {}", "Profundidad de bits EXR: {}"),
|
||||
("EXR Compression: {}", "Compresión EXR: {}"),
|
||||
("EXR Compression: {} (lossy)", "Compresión EXR: {} (con pérdida)"),
|
||||
("EXR Compression: {} (uncompressed at float)", "Compresión EXR: {} (sin compresión en float)"),
|
||||
("Empty folder", "Carpeta vacía"),
|
||||
("Enable Depth of Field", "Habilitar profundidad de campo"),
|
||||
("Enable Expression", "Habilitar expresión"),
|
||||
@@ -8094,6 +8103,7 @@ const GEORGIAN: &[(&str, &str)] = &[
|
||||
("EXR Bit Depth: {}", "EXR ბიტის სიღრმე: {}"),
|
||||
("EXR Compression: {}", "EXR შეკუმშვა: {}"),
|
||||
("EXR Compression: {} (lossy)", "EXR შეკუმშვა: {} (დანაკარგით)"),
|
||||
("EXR Compression: {} (uncompressed at float)", "EXR შეკუმშვა: {} (float-ზე შეკუმშვის გარეშე)"),
|
||||
("Empty folder", "ცარიელი საქაღალდე"),
|
||||
("Enable Depth of Field", "ველის სიღრმის ჩართვა"),
|
||||
("Enable Expression", "ექსპრესიის ჩართვა"),
|
||||
@@ -9062,6 +9072,7 @@ const FRENCH: &[(&str, &str)] = &[
|
||||
("EXR Bit Depth: {}", "Profondeur de bits EXR : {}"),
|
||||
("EXR Compression: {}", "Compression EXR : {}"),
|
||||
("EXR Compression: {} (lossy)", "Compression EXR : {} (avec perte)"),
|
||||
("EXR Compression: {} (uncompressed at float)", "Compression EXR : {} (non compressé en float)"),
|
||||
("Empty folder", "Dossier vide"),
|
||||
("Enable Depth of Field", "Activer la profondeur de champ"),
|
||||
("Enable Expression", "Activer l’expression"),
|
||||
|
||||
@@ -278,7 +278,14 @@ fn output_menu(it: &RenderQueueItem, available: &[OutputFormat], templates: &Ren
|
||||
fn exr_menu(o: &effectcraft_engine::project::render_queue::OutputModule, v: &mut Vec<(String, Value)>) {
|
||||
v.push(("-".into(), Value::Null));
|
||||
for c in ExrCompression::ALL {
|
||||
let label = if c.is_lossy(o.exr_bit_depth) { "EXR Compression: {} (lossy)" } else { "EXR Compression: {}" };
|
||||
let d = o.exr_bit_depth;
|
||||
let label = if c.is_lossy(d) {
|
||||
"EXR Compression: {} (lossy)"
|
||||
} else if c != ExrCompression::None && c.stores_uncompressed(d) {
|
||||
"EXR Compression: {} (uncompressed at float)"
|
||||
} else {
|
||||
"EXR Compression: {}"
|
||||
};
|
||||
v.push((mark(o.exr_compression == c, &tr_args(label, &[&(c.label())])), json!({"exrCompression": c.name()})));
|
||||
}
|
||||
v.push(("-".into(), Value::Null));
|
||||
@@ -1109,7 +1116,7 @@ mod tests {
|
||||
let m = menu(&it);
|
||||
assert_eq!(find(&m, "EXR Compression: ZIP (16 scan lines)").unwrap().0, "✓ EXR Compression: ZIP (16 scan lines)");
|
||||
assert_eq!(find(&m, "EXR Compression: PIZ").unwrap().1, serde_json::json!({"exrCompression": "piz"}));
|
||||
assert!(find(&m, "EXR Compression: PXR24 (lossy)").is_some() && find(&m, "EXR Compression: B44").is_some());
|
||||
assert!(find(&m, "EXR Compression: PXR24 (lossy)").is_some() && find(&m, "EXR Compression: B44 (uncompressed at float)").is_some());
|
||||
assert_eq!(find(&m, "EXR Compression: ZIPS (1 scan line)").unwrap().1, serde_json::json!({"exrCompression": "zips"}));
|
||||
assert_eq!(find(&m, "EXR Bit Depth: 32-bit Float").unwrap().0, "✓ EXR Bit Depth: 32-bit Float");
|
||||
assert_eq!(find(&m, "EXR Bit Depth: 16-bit Half Float").unwrap().1, serde_json::json!({"exrBitDepth": "half"}));
|
||||
|
||||
+1
-1
@@ -325,7 +325,7 @@ build real projects through these interfaces; they are worked examples of everyt
|
||||
pipelines default to) and `exrBitDepth: half|float` (default `float`); `renderQueue.formats`
|
||||
lists them.
|
||||
PXR24 on float, B44 / B44A on half and DWAA / DWAB are lossy (in the tests every linear sample
|
||||
stays within 0.03 + 6 % of its value); B44 / B44A store 32-bit float uncompressed. Half rounds each sample to the nearest half float.
|
||||
stays within 0.03 + 6 % of its value); B44 / B44A store 32-bit float uncompressed. Half rounds each sample to the nearest half float and holds it to ±65 504.
|
||||
**A render never writes one file twice either:** when two outputs of the job (two items, a
|
||||
`renderQueue.duplicate` copy, output modules, segments) name the same file, the whole render is
|
||||
refused before anything is written, naming both items. Sequences clash only on frames both
|
||||
|
||||
Reference in new issue
Block a user