Skip to main content
nika:image_fx is the deterministic sibling of nika:image_generate: a pure pixel transform — no provider, no network, no clock. Style a PNG through an ordered pipeline of effects and get an artifact that is byte-identical forever for identical (input, args). That determinism is the point, not a detail. The artifact’s sha256 joins the run’s hash-chained trace, re-rendering the recipe is the tamper check, and the full recipe (contract tag image_fx/v1 · input sha256 · seed · ops) travels inside the PNG as a nika tEXt chunk — no timestamp, ever. Your workflow draws its own receipts.

The 30-second pipeline

nika: v1
workflow: styled-hero
permits:
  tools: ["nika:image_generate", "nika:image_fx"]
  fs:
    read: ["out/**"]
    write: ["out/**"]
tasks:
  - id: generate
    invoke:
      tool: "nika:image_generate"
      args:
        provider: mock            # offline dry-run · flip to local/openai/gemini/xai
        prompt: "a monarch butterfly over a nebula"
        output_dir: out
        format: png
  - id: style
    depends_on: [generate]
    invoke:
      tool: "nika:image_fx"
      args:
        input: "${{ tasks.generate.output.images[0].path }}"
        out: out/hero-gameboy.png
        seed: 42                  # the seed IS the style — same seed, same bytes
        ops:
          - resize: { width: 320 }
          - dither: { mode: floyd_steinberg, palette: gameboy }
          - grain: { intensity: 32 }
          - scanlines: { strength: 110, period: 4 }
          - vignette: { strength: 140 }
Run it twice: the second run reports skipped_existing: true and the artifact hash never moves. Mutate one byte of the PNG and a re-render no longer matches — that mismatch is the tamper detection.

The op vocabulary (closed · v1)

ops: is an ordered list of single-key maps — a linear pipeline by design. Branching and fan-out belong to the workflow (depends_on · for_each), never inside the builtin.
OpWhat it doesKey knobs
resizeLinear-light resamplewidth/height (one may be omitted) · filter: auto|nearest|bilinear
cropExact rectanglex · y · width · height
levelsBrightness / contrastbrightness −255..255 · contrast −128..128
grayscaleLinear-light luma
palette_mapNearest palette color (Oklab distance)palette
ditherQuantize through a dithermode: bayer2|bayer4|bayer8|blue_noise|ign|floyd_steinberg|atkinson|jjn · palette
duotoneTwo-stop gradient map (Oklab ramp)dark · light (#rrggbb or [r,g,b])
pixelateBlock mosaicblock 2..256
halftoneRotated clustered-dot screen (print look)cell 3..64 · angle: 0|15|45|75
grainLuminance-shaped film grain (seeded)intensity 0..128
vignetteNatural cos⁴ falloffstrength 0..255
chromatic_aberrationPer-channel radial splitshift 1..16
scanlinesCRT row darkeningstrength 0..255 · period 2..64
glitchSeeded databendingline_shift ≤64 · channel_shift ≤16 · blocks ≤64
asciiCharacter-grid render — must be the last opcols 2..1024 · emit: png|text|ansi
Palettes: presets bw · gray4 · gameboy · cga · okabe_ito, or an inline list of 2–256 colors. Unknown op parameters are rejected loudly (NIKA-BUILTIN-IMAGE_FX-001) — a typo’d knob never silently lies about the style.
Input is PNG in v1 (depth 8 · no interlace). Non-PNG fails with a typed hint: produce PNG upstream (image_generate with format: png) or convert once via exec:. Decode is budget-gated from the header before any decompression — hostile dimension claims are rejected, never allocated.

Why deterministic styling matters

Every incumbent styling path is nondeterministic by default — image tools timestamp their outputs, and generative models can’t reproduce bytes across hardware at all. That makes styled assets un-verifiable: you can sign them, but you can’t re-derive them. image_fx inverts that: integer/fixed-point pixel math, a seeded noise stream, zero wall-clock. So the artifact can be:
  • chained — its sha256 lands in the trace (nika trace verify covers it);
  • re-rendered — the same input + the recipe reproduces the exact bytes;
  • carried — the recipe rides the artifact itself, so any holder of the input can verify the styling.
One recipe, one hash, forever. Batch-style a hundred campaign images with for_each and every one of them is a receipt.
  - id: variants
    depends_on: [generate]
    for_each: ["bw", "gameboy", "cga"]
    invoke:
      tool: "nika:image_fx"
      args:
        input: "${{ tasks.generate.output.images[0].path }}"
        out: "out/variant-${{ item }}.png"
        ops:
          - dither: { mode: bayer4, palette: "${{ item }}" }

Output contract

{
  "input": "out/hero.png",
  "input_sha256": "…",
  "path": "out/hero-gameboy.png",
  "sha256": "…",
  "size_bytes": 323953,
  "width": 320,
  "height": 200,
  "format": "png",
  "ops_applied": 5,
  "seed": 42,
  "skipped_existing": false
}
Artifact bytes never ride outputs (the disk law). Text artifacts (ascii with emit: text|ansi) land as .txt/.ans, report width/height 0, and are sha256’d like any other artifact. Errors are typed NIKA-BUILTIN-IMAGE_FX-001..006 — invalid args · input read · unsupported format · decode · pixel budget · save — plus the boundary NIKA-SEC-004 when a path resolves outside permits.fs.