Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 28 additions & 52 deletions src/cli/render.rs
Original file line number Diff line number Diff line change
Expand Up @@ -432,55 +432,20 @@ pub fn run_render(
// Render the resolved sprite
let (mut image, render_warnings) = render_resolved(&render_sprite_data);

// Apply transforms from sprite.transform if present
if let Some(ref transform_specs) = sprite.transform {
use crate::models::TransformSpec;
use crate::transforms::{apply_image_transform, parse_transform_str};

for spec in transform_specs {
let transform_result = match spec {
TransformSpec::String(s) => parse_transform_str(s),
TransformSpec::Object { op, params } => {
// Convert object to JSON and parse
let mut obj = serde_json::Map::new();
obj.insert("op".to_string(), serde_json::Value::String(op.clone()));
for (k, v) in params {
obj.insert(k.clone(), v.clone());
}
crate::transforms::parse_transform_value(&serde_json::Value::Object(
obj,
))
}
};

match transform_result {
Ok(transform) => {
// Skip animation transforms (they don't apply to images)
if crate::transforms::is_animation_transform(&transform) {
continue;
}
match apply_image_transform(&image, &transform, Some(&final_palette)) {
Ok(transformed) => image = transformed,
Err(e) => {
let msg =
format!("sprite '{}': transform error: {}", sprite.name, e);
if strict {
eprintln!("Error: {}", msg);
return ExitCode::from(EXIT_ERROR);
}
all_warnings.push(msg);
}
}
}
Err(e) => {
let msg = format!("sprite '{}': invalid transform: {}", sprite.name, e);
if strict {
eprintln!("Error: {}", msg);
return ExitCode::from(EXIT_ERROR);
}
all_warnings.push(msg);
}
if let Some(ref specs) = sprite.transform {
let (transformed, warnings) = crate::transforms::apply_image_transform_specs(
image,
specs,
Some(&final_palette),
);
image = transformed;
for warning in warnings {
let msg = format!("sprite '{}': {}", sprite.name, warning);
if strict {
eprintln!("Error: {}", msg);
return ExitCode::from(EXIT_ERROR);
}
all_warnings.push(msg);
}
}

Expand Down Expand Up @@ -661,7 +626,7 @@ fn run_composition_render(
}

/// Render a composition to an image buffer
/// TRF-9: Now uses SpriteRegistry to resolve sprites with transforms applied
/// Resolve sprite geometry and apply image transforms before caching composition inputs.
#[allow(clippy::too_many_arguments)]
fn render_composition_to_image(
comp: &Composition,
Expand Down Expand Up @@ -695,7 +660,7 @@ fn render_composition_to_image(
// First check if sprite exists in the raw sprites map (for @include: handling)
let original_sprite = sprites.get(sprite_name);

// Use sprite registry to resolve the sprite with transforms applied
// Resolve inherited sprite geometry; image transforms are applied below.
let resolved_sprite = match sprite_registry.resolve(sprite_name, palette_registry, strict) {
Ok(resolved) => {
// Collect any sprite warnings
Expand Down Expand Up @@ -764,8 +729,19 @@ fn render_composition_to_image(
regions: resolved_sprite.regions.clone(),
};

// Render the resolved sprite (transforms already applied)
let (image, render_warnings) = render_resolved(&render_sprite_data);
// Registry resolution inherits regions and size, not transformed pixels.
let (mut image, render_warnings) = render_resolved(&render_sprite_data);
if let Some(specs) = original_sprite.and_then(|sprite| sprite.transform.as_deref()) {
let (transformed, warnings) = crate::transforms::apply_image_transform_specs(
image,
specs,
Some(&render_sprite_data.palette),
);
image = transformed;
for warning in warnings {
all_warnings.push(format!("sprite '{}': {}", sprite_name, warning));
}
}

// Collect render warnings
for warning in render_warnings {
Expand Down
3 changes: 3 additions & 0 deletions src/composition/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ impl Warning {
#[derive(Debug, Clone, PartialEq, Error)]
#[non_exhaustive]
pub enum CompositionError {
/// A layer transform could not be parsed or applied in strict mode.
#[error("Layer transform in composition '{composition_name}': {message}")]
LayerTransform { composition_name: String, message: String },
/// Sprite dimensions exceed cell size
#[error("Sprite '{sprite_name}' ({sprite_w}x{sprite_h}) exceeds cell size ({cell_w}x{cell_h}) in composition '{composition_name}'", sprite_w = sprite_size.0, sprite_h = sprite_size.1, cell_w = cell_size.0, cell_h = cell_size.1)]
SizeMismatch {
Expand Down
50 changes: 47 additions & 3 deletions src/composition/render.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
use image::{Rgba, RgbaImage};
use std::collections::HashMap;

use crate::models::Composition;
use crate::models::{Composition, CompositionLayer};
use crate::registry::CompositionRegistry;
use crate::variables::VariableRegistry;

Expand All @@ -12,6 +12,34 @@ use super::context::RenderContext;
use super::error::{CompositionError, Warning};
use super::resolve::{resolve_blend_mode, resolve_opacity};

/// Transform a layer's input before placement, without mutating cached sprites
/// or nested compositions reused by other layers.
fn transform_layer_sprite<'a>(
image: &'a RgbaImage,
layer: &CompositionLayer,
composition_name: &str,
strict: bool,
warnings: &mut Vec<Warning>,
) -> Result<std::borrow::Cow<'a, RgbaImage>, CompositionError> {
let Some(specs) = layer.transform.as_deref().filter(|specs| !specs.is_empty()) else {
return Ok(std::borrow::Cow::Borrowed(image));
};
let (image, diagnostics) =
crate::transforms::apply_image_transform_specs(image.clone(), specs, None);
for diagnostic in diagnostics {
let message =
format!("layer '{}': {}", layer.name.as_deref().unwrap_or("unnamed"), diagnostic);
if strict {
return Err(CompositionError::LayerTransform {
composition_name: composition_name.to_string(),
message,
});
}
warnings.push(Warning::new(message));
}
Ok(std::borrow::Cow::Owned(image))
}

/// Render a composition to an RGBA image buffer.
///
/// Takes a composition and a map of sprite name -> rendered image.
Expand Down Expand Up @@ -228,7 +256,15 @@ pub fn render_composition(
}
};

// Check for size mismatch (Task 2.5)
let sprite_image = transform_layer_sprite(
sprite_image,
layer,
&comp.name,
strict,
&mut warnings,
)?;

// Check the transformed dimensions for size mismatch (Task 2.5)
let sprite_width = sprite_image.width();
let sprite_height = sprite_image.height();
if sprite_width > cell_size[0] || sprite_height > cell_size[1] {
Expand All @@ -252,7 +288,7 @@ pub fn render_composition(
let y = (row_idx as u32) * cell_size[1];

// Blit sprite onto canvas with blend mode and opacity (ATF-10)
blit_sprite_blended(&mut canvas, sprite_image, x, y, blend_mode, opacity);
blit_sprite_blended(&mut canvas, &sprite_image, x, y, blend_mode, opacity);
}
}
}
Expand Down Expand Up @@ -437,6 +473,14 @@ fn render_composition_inner(
continue;
};

let sprite_image = transform_layer_sprite(
&sprite_image,
layer,
&comp.name,
strict,
&mut warnings,
)?;

let x = (col_idx as u32) * cell_size[0];
let y = (row_idx as u32) * cell_size[1];

Expand Down
32 changes: 32 additions & 0 deletions src/transforms/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,35 @@ pub use types::{explain_transform, Transform, TransformError};

/// Result type alias for transform operations.
pub type Result<T> = std::result::Result<T, TransformError>;

/// Apply image transform specifications in order, retaining valid operations in
/// lenient rendering. Callers turn the returned diagnostics into strict errors.
pub(crate) fn apply_image_transform_specs(
mut image: image::RgbaImage,
specs: &[crate::models::TransformSpec],
palette: Option<&std::collections::HashMap<String, String>>,
) -> (image::RgbaImage, Vec<String>) {
use crate::models::TransformSpec;

let mut warnings = Vec::new();
for spec in specs {
let parsed = match spec {
TransformSpec::String(s) => parse_transform_str(s),
TransformSpec::Object { op, params } => {
let mut object: serde_json::Map<String, serde_json::Value> =
params.clone().into_iter().collect();
object.insert("op".to_string(), serde_json::Value::String(op.clone()));
parse_transform_value(&serde_json::Value::Object(object))
}
};
match parsed {
Ok(transform) if is_animation_transform(&transform) => continue,
Ok(transform) => match apply_image_transform(&image, &transform, palette) {
Ok(transformed) => image = transformed,
Err(error) => warnings.push(format!("transform error: {}", error)),
},
Err(error) => warnings.push(format!("invalid transform: {}", error)),
}
}
(image, warnings)
}
59 changes: 59 additions & 0 deletions tests/cli_composition_transforms.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
//! Composition transforms must agree with direct sprite rendering in PNG and GIF.

use image::AnimationDecoder;
use std::fs::File;
use std::io::BufReader;
use std::path::Path;
use std::process::Command;

const INPUT: &str = "tests/fixtures/valid/composition_transforms.pxl";

fn render(output: &Path, options: &[&str]) {
let result = Command::new(env!("CARGO_BIN_EXE_pxl"))
.args(["render", INPUT, "--strict"])
.args(options)
.arg("-o")
.arg(output)
.output()
.unwrap();
assert!(result.status.success(), "{}", String::from_utf8_lossy(&result.stderr));
}

fn expected(x: u32, y: u32) -> image::RgbaImage {
let mut image = image::RgbaImage::new(8, 8);
for dy in 0..2 {
for dx in 0..3 {
image.put_pixel(x + dx, y + dy, image::Rgba([227, 218, 201, 255]));
}
}
image
}

#[test]
fn png_compositions_match_direct_transformed_sprites() {
let dir = tempfile::tempdir().unwrap();
render(dir.path(), &[]);
let shifted = image::open(dir.path().join("jaw_shift.png")).unwrap().to_rgba8();
assert_eq!(shifted, expected(2, 4));
for name in ["layer_shift", "source_shift", "base_shift"] {
let actual = image::open(dir.path().join(format!("{name}.png"))).unwrap().to_rgba8();
assert_eq!(actual, shifted, "{name}");
}
let combined = image::open(dir.path().join("combined.png")).unwrap().to_rgba8();
assert_eq!(combined, expected(3, 4));
assert_eq!(combined, image::open(dir.path().join("jaw_combined.png")).unwrap().to_rgba8());
}

#[test]
fn gif_composition_frames_apply_source_then_layer_transforms() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("poses.gif");
render(&path, &["--gif", "--animation", "poses"]);
let decoder =
image::codecs::gif::GifDecoder::new(BufReader::new(File::open(path).unwrap())).unwrap();
let frames = decoder.into_frames().collect_frames().unwrap();
assert_eq!(frames.len(), 3);
for (frame, x) in frames.iter().zip([2, 2, 3]) {
assert_eq!(frame.buffer(), &expected(x, 4));
}
}
Loading
Loading