Commit 59fae59

2026-09-12 20:22:19
feat: rename to elelem-diffusion, add persistent context, img2img, video, upscale
Rename elelem-image to elelem-diffusion: the gem wraps stable-diffusion.cpp wholesale, not just still images. Env var renamed ELELEM_SD_MODEL -> ELELEM_SD_IMAGE_MODEL to make room for ELELEM_SD_VIDEO_MODEL/ELELEM_SD_ESRGAN_MODEL as separate checkpoint types land. Cache the sd context by model path instead of opening/closing per call, so image/img2img/video calls can share state and coexist. Guarded by a mutex since sd.cpp contexts aren't thread-safe and el_sd_* calls run with need_gvl: false. Add three new shim entry points and Ruby wrappers/tools: - el_sd_img2img: edit an existing image via init_image + strength - el_sd_video: text-to-video via Wan2.1/2.2's generate_video - el_sd_upscale: ESRGAN upscaling, independent of the sd context Declined exposing the rest of stable-diffusion.cpp's public API as tools: enum/param-struct plumbing isn't agent-facing, controlnet/adetailer need model files this gem doesn't ship, and convert/imatrix tooling is offline quantization work, not something an agent should trigger mid-conversation.
1 parent 238dd5b
ext/elelem/diffusion/elelem.cpp
@@ -0,0 +1,198 @@
+#include "stable-diffusion.h"
+
+#define STB_IMAGE_IMPLEMENTATION
+#define STB_IMAGE_STATIC
+#include "stb_image.h"
+
+#define STB_IMAGE_WRITE_IMPLEMENTATION
+#define STB_IMAGE_WRITE_STATIC
+#include "stb_image_write.h"
+
+#include <cstdio>
+#include <cstring>
+#include <string>
+
+#define EL_API __attribute__((visibility("default")))
+
+extern "C" {
+  struct el_sd_handle {
+    sd_ctx_t *ctx;
+  };
+
+  static void el_sd_log_cb(enum sd_log_level_t, const char *, void *) {
+    // sd.cpp's INFO/progress logging goes straight to this process's stderr
+    // with no subprocess boundary to contain it; drop it here. Callers that
+    // want it can wire sd_set_log_callback themselves.
+  }
+
+  static void el_sd_progress_cb(int, int, float, void *) {
+    // Same rationale as el_sd_log_cb: no subprocess boundary to contain the
+    // progress bar sd.cpp writes straight to stderr.
+  }
+
+  static bool el_sd_write_png(sd_image_t image, const char *output_path) {
+    int written = stbi_write_png(output_path, image.width, image.height,
+                                  image.channel, image.data,
+                                  image.width * image.channel);
+    return written != 0;
+  }
+
+  static bool el_sd_read_png(const char *input_path, sd_image_t *out) {
+    int width, height, channels;
+    unsigned char *data = stbi_load(input_path, &width, &height, &channels, 3);
+    if (!data) return false;
+    out->width = static_cast<uint32_t>(width);
+    out->height = static_cast<uint32_t>(height);
+    out->channel = 3;
+    out->data = data;
+    return true;
+  }
+
+  EL_API void *el_sd_open(const char *model_path, int n_threads) {
+    sd_ctx_params_t params;
+    sd_ctx_params_init(&params);
+    params.model_path = model_path;
+    params.n_threads = n_threads;
+
+    sd_set_log_callback(el_sd_log_cb, nullptr);
+    sd_set_progress_callback(el_sd_progress_cb, nullptr);
+
+    sd_ctx_t *ctx = new_sd_ctx(&params);
+    if (!ctx) return nullptr;
+
+    el_sd_handle *handle = new el_sd_handle{ctx};
+    return handle;
+  }
+
+  EL_API bool el_sd_generate(void *handle_ptr, const char *prompt, const char *negative_prompt,
+                       int width, int height, int steps, int64_t seed, const char *output_path) {
+    el_sd_handle *handle = static_cast<el_sd_handle *>(handle_ptr);
+    if (!handle || !handle->ctx) return false;
+
+    sd_img_gen_params_t params;
+    sd_img_gen_params_init(&params);
+    params.prompt = prompt;
+    params.negative_prompt = negative_prompt;
+    params.width = width;
+    params.height = height;
+    params.sample_params.sample_steps = steps;
+    params.seed = seed;
+    params.batch_count = 1;
+
+    sd_image_t *images = nullptr;
+    int num_images = 0;
+    bool ok = generate_image(handle->ctx, &params, &images, &num_images);
+    if (!ok || num_images < 1 || !images) return false;
+
+    bool written = el_sd_write_png(images[0], output_path);
+
+    free(images[0].data);
+    free(images);
+
+    return written;
+  }
+
+  EL_API bool el_sd_img2img(void *handle_ptr, const char *prompt, const char *negative_prompt,
+                       const char *input_path, float strength, int steps, int64_t seed,
+                       const char *output_path) {
+    el_sd_handle *handle = static_cast<el_sd_handle *>(handle_ptr);
+    if (!handle || !handle->ctx) return false;
+
+    sd_image_t init_image;
+    if (!el_sd_read_png(input_path, &init_image)) return false;
+
+    sd_img_gen_params_t params;
+    sd_img_gen_params_init(&params);
+    params.prompt = prompt;
+    params.negative_prompt = negative_prompt;
+    params.init_image = init_image;
+    params.width = init_image.width;
+    params.height = init_image.height;
+    params.strength = strength;
+    params.sample_params.sample_steps = steps;
+    params.seed = seed;
+    params.batch_count = 1;
+
+    sd_image_t *images = nullptr;
+    int num_images = 0;
+    bool ok = generate_image(handle->ctx, &params, &images, &num_images);
+    free(init_image.data);
+    if (!ok || num_images < 1 || !images) return false;
+
+    bool written = el_sd_write_png(images[0], output_path);
+
+    free(images[0].data);
+    free(images);
+
+    return written;
+  }
+
+  EL_API bool el_sd_upscale(const char *esrgan_path, const char *input_path,
+                       int n_threads, const char *output_path) {
+    sd_image_t input_image;
+    if (!el_sd_read_png(input_path, &input_image)) return false;
+
+    upscaler_ctx_t *upscaler = new_upscaler_ctx(esrgan_path, false, n_threads, 0, nullptr, nullptr);
+    if (!upscaler) {
+      free(input_image.data);
+      return false;
+    }
+
+    int factor = get_upscale_factor(upscaler);
+    sd_image_t *images = nullptr;
+    int num_images = 0;
+    bool ok = upscale(upscaler, input_image, static_cast<uint32_t>(factor), &images, &num_images);
+    free(input_image.data);
+    free_upscaler_ctx(upscaler);
+    if (!ok || num_images < 1 || !images) return false;
+
+    bool written = el_sd_write_png(images[0], output_path);
+    free(images[0].data);
+    free(images);
+
+    return written;
+  }
+
+  EL_API bool el_sd_video(void *handle_ptr, const char *prompt, const char *negative_prompt,
+                     int width, int height, int steps, int64_t seed, int video_frames, int fps,
+                     const char *output_dir) {
+    el_sd_handle *handle = static_cast<el_sd_handle *>(handle_ptr);
+    if (!handle || !handle->ctx) return false;
+
+    sd_vid_gen_params_t params;
+    sd_vid_gen_params_init(&params);
+    params.prompt = prompt;
+    params.negative_prompt = negative_prompt;
+    params.width = width;
+    params.height = height;
+    params.sample_params.sample_steps = steps;
+    params.seed = seed;
+    params.video_frames = video_frames;
+    params.fps = fps;
+
+    sd_image_t *frames = nullptr;
+    int num_frames = 0;
+    sd_audio_t *audio = nullptr;
+    bool ok = generate_video(handle->ctx, &params, &frames, &num_frames, &audio);
+    if (audio) free_sd_audio(audio);
+    if (!ok || num_frames < 1 || !frames) return false;
+
+    bool all_written = true;
+    char frame_path[4096];
+    for (int i = 0; i < num_frames; i++) {
+      snprintf(frame_path, sizeof(frame_path), "%s/frame-%04d.png", output_dir, i);
+      if (!el_sd_write_png(frames[i], frame_path)) all_written = false;
+      free(frames[i].data);
+    }
+    free(frames);
+
+    return all_written;
+  }
+
+  EL_API void el_sd_close(void *handle_ptr) {
+    el_sd_handle *handle = static_cast<el_sd_handle *>(handle_ptr);
+    if (!handle) return;
+    if (handle->ctx) free_sd_ctx(handle->ctx);
+    delete handle;
+  }
+}
ext/elelem/image/extconf.rb → ext/elelem/diffusion/extconf.rb
@@ -6,7 +6,7 @@ EXT_DIR   = __dir__
 GEM_ROOT  = File.expand_path("../../..", EXT_DIR)
 VENDOR    = File.join(GEM_ROOT, "vendor", "stable-diffusion.cpp")
 BUILD_DIR = File.join(EXT_DIR, "build")
-NATIVE    = File.join(GEM_ROOT, "ext", "elelem", "image", "native")
+NATIVE    = File.join(GEM_ROOT, "ext", "elelem", "diffusion", "native")
 DARWIN = RbConfig::CONFIG["host_os"] =~ /darwin/
 
 class BuildError < StandardError; end
@@ -40,7 +40,7 @@ end
 
 def link_shim
   ext = DARWIN ? "dylib" : "so"
-  shim = File.join(NATIVE, "libelelem_image.#{ext}")
+  shim = File.join(NATIVE, "libelelem_diffusion.#{ext}")
   FileUtils.mkdir_p(NATIVE)
 
   sd_archive = find_archive("libstable-diffusion")
@@ -52,7 +52,7 @@ def link_shim
   visibility_flags = DARWIN ? [] : ["-Wl,--exclude-libs,ALL"]
   omp_flags = DARWIN ? [] : ["-fopenmp"]
   omp_link_flags = DARWIN ? [] : ["-lgomp"]
-  link_flags = DARWIN ? ["-Wl,-install_name,@rpath/libelelem_image.dylib"] : []
+  link_flags = DARWIN ? ["-Wl,-install_name,@rpath/libelelem_diffusion.dylib"] : []
 
   run(cxx, "-std=c++17", "-O2", "-Wall", "-Wextra", "-shared", "-fPIC", "-fvisibility=hidden", *omp_flags,
       "-I", File.join(VENDOR, "include"),
ext/elelem/image/elelem.cpp
@@ -1,81 +0,0 @@
-#include "stable-diffusion.h"
-
-#define STB_IMAGE_WRITE_IMPLEMENTATION
-#define STB_IMAGE_WRITE_STATIC
-#include "stb_image_write.h"
-
-#include <cstdio>
-#include <cstring>
-#include <string>
-
-#define EL_API __attribute__((visibility("default")))
-
-extern "C" {
-  struct el_sd_handle {
-    sd_ctx_t *ctx;
-  };
-
-  static void el_sd_log_cb(enum sd_log_level_t, const char *, void *) {
-    // sd.cpp's INFO/progress logging goes straight to this process's stderr
-    // with no subprocess boundary to contain it; drop it here. Callers that
-    // want it can wire sd_set_log_callback themselves.
-  }
-
-  static void el_sd_progress_cb(int, int, float, void *) {
-    // Same rationale as el_sd_log_cb: no subprocess boundary to contain the
-    // progress bar sd.cpp writes straight to stderr.
-  }
-
-  EL_API void *el_sd_open(const char *model_path, int n_threads) {
-    sd_ctx_params_t params;
-    sd_ctx_params_init(&params);
-    params.model_path = model_path;
-    params.n_threads = n_threads;
-
-    sd_set_log_callback(el_sd_log_cb, nullptr);
-    sd_set_progress_callback(el_sd_progress_cb, nullptr);
-
-    sd_ctx_t *ctx = new_sd_ctx(&params);
-    if (!ctx) return nullptr;
-
-    el_sd_handle *handle = new el_sd_handle{ctx};
-    return handle;
-  }
-
-  EL_API bool el_sd_generate(void *handle_ptr, const char *prompt, const char *negative_prompt,
-                       int width, int height, int steps, int64_t seed, const char *output_path) {
-    el_sd_handle *handle = static_cast<el_sd_handle *>(handle_ptr);
-    if (!handle || !handle->ctx) return false;
-
-    sd_img_gen_params_t params;
-    sd_img_gen_params_init(&params);
-    params.prompt = prompt;
-    params.negative_prompt = negative_prompt;
-    params.width = width;
-    params.height = height;
-    params.sample_params.sample_steps = steps;
-    params.seed = seed;
-    params.batch_count = 1;
-
-    sd_image_t *images = nullptr;
-    int num_images = 0;
-    bool ok = generate_image(handle->ctx, &params, &images, &num_images);
-    if (!ok || num_images < 1 || !images) return false;
-
-    int written = stbi_write_png(output_path, images[0].width, images[0].height,
-                                  images[0].channel, images[0].data,
-                                  images[0].width * images[0].channel);
-
-    free(images[0].data);
-    free(images);
-
-    return written != 0;
-  }
-
-  EL_API void el_sd_close(void *handle_ptr) {
-    el_sd_handle *handle = static_cast<el_sd_handle *>(handle_ptr);
-    if (!handle) return;
-    if (handle->ctx) free_sd_ctx(handle->ctx);
-    delete handle;
-  }
-}
lib/elelem/image/version.rb → lib/elelem/diffusion/version.rb
@@ -1,7 +1,7 @@
 # frozen_string_literal: true
 
 module Elelem
-  module Image
+  module Diffusion
     VERSION = "0.1.0"
   end
 end
lib/elelem/diffusion.rb
@@ -0,0 +1,159 @@
+# frozen_string_literal: true
+
+require "elelem"
+require "etc"
+require "fiddle"
+require "fileutils"
+
+require_relative "diffusion/version"
+
+module Elelem
+  module Diffusion
+    NATIVE = File.expand_path("../../ext/elelem/diffusion/native", __dir__)
+    SHIM = File.join(NATIVE, "libelelem_diffusion.#{RbConfig::CONFIG["host_os"] =~ /darwin/ ? "dylib" : "so"}")
+    V = Fiddle::TYPE_VOIDP
+    I = Fiddle::TYPE_INT
+    L = Fiddle::TYPE_LONG_LONG
+    F = Fiddle::TYPE_FLOAT
+
+    MUTEX = Mutex.new
+    CONTEXTS = {}
+
+    def self.available?
+      File.exist?(SHIM)
+    end
+
+    def self.functions
+      @functions ||= begin
+        lib = Fiddle.dlopen(SHIM)
+        {
+          open: Fiddle::Function.new(lib["el_sd_open"], [V, I], V),
+          generate: Fiddle::Function.new(lib["el_sd_generate"], [V, V, V, I, I, I, L, V], I, need_gvl: false),
+          img2img: Fiddle::Function.new(lib["el_sd_img2img"], [V, V, V, V, F, I, L, V], I, need_gvl: false),
+          video: Fiddle::Function.new(lib["el_sd_video"], [V, V, V, I, I, I, L, I, I, V], I, need_gvl: false),
+          upscale: Fiddle::Function.new(lib["el_sd_upscale"], [V, V, I, V], I, need_gvl: false),
+          close: Fiddle::Function.new(lib["el_sd_close"], [V], Fiddle::TYPE_VOID)
+        }
+      end
+    end
+
+    def self.context_for(model, n_threads: nil)
+      MUTEX.synchronize do
+        CONTEXTS[model] ||= functions[:open].call(model, n_threads || Etc.nprocessors)
+      end
+    end
+
+    def self.generate(prompt:, path:, negative_prompt: "", steps: 20, width: 512, height: 512, seed: 42, n_threads: nil)
+      with_context(n_threads: n_threads) do |model, handle|
+        ok = MUTEX.synchronize do
+          functions[:generate].call(handle, prompt, negative_prompt, width, height, steps, seed, path)
+        end
+        ok.zero? ? { ok: false, error: "generation failed" } : { ok: true, path: path }
+      end
+    end
+
+    def self.img2img(prompt:, input_path:, path:, negative_prompt: "", strength: 0.5, steps: 20, seed: 42, n_threads: nil)
+      with_context(n_threads: n_threads) do |model, handle|
+        ok = MUTEX.synchronize do
+          functions[:img2img].call(handle, prompt, negative_prompt, input_path, strength, steps, seed, path)
+        end
+        ok.zero? ? { ok: false, error: "img2img failed" } : { ok: true, path: path }
+      end
+    end
+
+    def self.video(prompt:, output_dir:, negative_prompt: "", steps: 20, width: 512, height: 512, seed: 42,
+                    video_frames: 16, fps: 8, n_threads: nil)
+      with_context(n_threads: n_threads) do |model, handle|
+        FileUtils.mkdir_p(output_dir)
+        ok = MUTEX.synchronize do
+          functions[:video].call(handle, prompt, negative_prompt, width, height, steps, seed, video_frames, fps, output_dir)
+        end
+        ok.zero? ? { ok: false, error: "video generation failed" } : { ok: true, path: output_dir }
+      end
+    end
+
+    def self.upscale(input_path:, path:, n_threads: nil)
+      esrgan = ENV.fetch("ELELEM_SD_ESRGAN_MODEL")
+      ok = MUTEX.synchronize do
+        functions[:upscale].call(esrgan, input_path, n_threads || Etc.nprocessors, path)
+      end
+      ok.zero? ? { ok: false, error: "upscale failed" } : { ok: true, path: path }
+    end
+
+    def self.with_context(n_threads:)
+      model = ENV.fetch("ELELEM_SD_IMAGE_MODEL")
+      handle = context_for(model, n_threads: n_threads)
+      return { ok: false, error: "failed to load model at #{model}" } if handle.null?
+
+      yield model, handle
+    end
+    private_class_method :with_context
+  end
+end
+
+Elelem.configure do |config|
+  config.setup(:diffusion) do |agent|
+    unless Elelem::Diffusion.available?
+      warn "elelem-diffusion: native extension not built, skipping image/video/upscale tools " \
+           "(reinstall the gem or run: cd ext/elelem/diffusion && ruby extconf.rb)"
+      next
+    end
+
+    agent.toolbox.add("image",
+      description: "Generate an image from a text prompt using a local Stable Diffusion model, " \
+                    "or edit an existing image by passing input_path (img2img). " \
+                    "Lower steps/width/height trade image quality for speed.",
+      params: {
+        prompt: { type: "string" },
+        path: { type: "string" },
+        input_path: { type: "string", description: "existing image to edit; omit for a fresh generation" },
+        strength: { type: "number", description: "0.0-1.0, only used with input_path; higher changes more of the original" },
+        steps: { type: "integer", description: "sample steps, default 20; below ~15 often degrades quality" },
+        width: { type: "integer", description: "pixels, default 512, ignored when editing (uses input image size)" },
+        height: { type: "integer", description: "pixels, default 512, ignored when editing (uses input image size)" }
+      },
+      required: ["prompt", "path"]
+    ) do |args|
+      if args["input_path"]
+        Elelem::Diffusion.img2img(
+          prompt: args["prompt"], input_path: args["input_path"], path: args["path"],
+          strength: args["strength"] || 0.5, steps: args["steps"] || 20
+        )
+      else
+        Elelem::Diffusion.generate(
+          prompt: args["prompt"], path: args["path"],
+          steps: args["steps"] || 20, width: args["width"] || 512, height: args["height"] || 512
+        )
+      end
+    end
+
+    agent.toolbox.add("video",
+      description: "Generate a short video from a text prompt using a local Wan2.1/2.2 model. " \
+                    "Writes numbered PNG frames into output_dir. Slow: minutes per clip.",
+      params: {
+        prompt: { type: "string" },
+        output_dir: { type: "string" },
+        video_frames: { type: "integer", description: "default 16" },
+        fps: { type: "integer", description: "default 8" },
+        steps: { type: "integer", description: "sample steps, default 20" }
+      },
+      required: ["prompt", "output_dir"]
+    ) do |args|
+      Elelem::Diffusion.video(
+        prompt: args["prompt"], output_dir: args["output_dir"],
+        video_frames: args["video_frames"] || 16, fps: args["fps"] || 8, steps: args["steps"] || 20
+      )
+    end
+
+    agent.toolbox.add("upscale",
+      description: "Upscale an existing image with a local ESRGAN model.",
+      params: {
+        input_path: { type: "string" },
+        path: { type: "string" }
+      },
+      required: ["input_path", "path"]
+    ) do |args|
+      Elelem::Diffusion.upscale(input_path: args["input_path"], path: args["path"])
+    end
+  end
+end
lib/elelem/image.rb
@@ -1,73 +0,0 @@
-# frozen_string_literal: true
-
-require "elelem"
-require "etc"
-require "fiddle"
-
-require_relative "image/version"
-
-module Elelem
-  module Image
-    NATIVE = File.expand_path("../../ext/elelem/image/native", __dir__)
-    SHIM = File.join(NATIVE, "libelelem_image.#{RbConfig::CONFIG["host_os"] =~ /darwin/ ? "dylib" : "so"}")
-    V = Fiddle::TYPE_VOIDP
-    I = Fiddle::TYPE_INT
-    L = Fiddle::TYPE_LONG_LONG
-
-    def self.available?
-      File.exist?(SHIM)
-    end
-
-    def self.functions
-      @functions ||= begin
-        lib = Fiddle.dlopen(SHIM)
-        {
-          open: Fiddle::Function.new(lib["el_sd_open"], [V, I], V),
-          generate: Fiddle::Function.new(lib["el_sd_generate"], [V, V, V, I, I, I, L, V], I, need_gvl: false),
-          close: Fiddle::Function.new(lib["el_sd_close"], [V], Fiddle::TYPE_VOID)
-        }
-      end
-    end
-
-    def self.generate(prompt:, path:, negative_prompt: "", steps: 20, width: 512, height: 512, seed: 42, n_threads: nil)
-      model = ENV.fetch("ELELEM_SD_MODEL")
-      handle = functions[:open].call(model, n_threads || Etc.nprocessors)
-      return { ok: false, error: "failed to load model at #{model}" } if handle.null?
-
-      begin
-        ok = functions[:generate].call(handle, prompt, negative_prompt, width, height, steps, seed, path)
-        ok.zero? ? { ok: false, error: "generation failed" } : { ok: true, path: path }
-      ensure
-        functions[:close].call(handle)
-      end
-    end
-  end
-end
-
-Elelem.configure do |config|
-  config.setup(:image) do |agent|
-    unless Elelem::Image.available?
-      warn "elelem-image: native extension not built, skipping image tool " \
-           "(reinstall the gem or run: cd ext/elelem/image && ruby extconf.rb)"
-      next
-    end
-
-    agent.toolbox.add("image",
-      description: "Generate an image from a text prompt using a local Stable Diffusion model. " \
-                    "Lower steps/width/height trade image quality for speed.",
-      params: {
-        prompt: { type: "string" },
-        path: { type: "string" },
-        steps: { type: "integer", description: "sample steps, default 20; below ~15 often degrades quality" },
-        width: { type: "integer", description: "pixels, default 512" },
-        height: { type: "integer", description: "pixels, default 512" }
-      },
-      required: ["prompt", "path"]
-    ) do |args|
-      Elelem::Image.generate(
-        prompt: args["prompt"], path: args["path"],
-        steps: args["steps"] || 20, width: args["width"] || 512, height: args["height"] || 512
-      )
-    end
-  end
-end
spec/elelem/diffusion_spec.rb
@@ -0,0 +1,187 @@
+# frozen_string_literal: true
+
+RSpec.describe Elelem::Diffusion do
+  around do |example|
+    original = ENV["ELELEM_SD_IMAGE_MODEL"]
+    ENV["ELELEM_SD_IMAGE_MODEL"] = "/models/sd.gguf"
+    example.run
+  ensure
+    ENV["ELELEM_SD_IMAGE_MODEL"] = original
+  end
+
+  before do
+    described_class::CONTEXTS.clear
+  end
+
+  let(:open_fn) { instance_double(Fiddle::Function, call: handle) }
+  let(:generate_fn) { instance_double(Fiddle::Function) }
+  let(:img2img_fn) { instance_double(Fiddle::Function) }
+  let(:video_fn) { instance_double(Fiddle::Function) }
+  let(:upscale_fn) { instance_double(Fiddle::Function) }
+  let(:close_fn) { instance_double(Fiddle::Function, call: nil) }
+  let(:handle) { instance_double(Fiddle::Pointer, null?: false) }
+
+  before do
+    allow(described_class).to receive(:functions).and_return(
+      open: open_fn, generate: generate_fn, img2img: img2img_fn,
+      video: video_fn, upscale: upscale_fn, close: close_fn
+    )
+  end
+
+  describe ".generate" do
+    it "returns ok and the path on success" do
+      allow(generate_fn).to receive(:call).and_return(1)
+
+      result = described_class.generate(prompt: "a cat", path: "/tmp/cat.png")
+
+      expect(result).to eq(ok: true, path: "/tmp/cat.png")
+    end
+
+    it "returns ok: false when generation fails" do
+      allow(generate_fn).to receive(:call).and_return(0)
+
+      result = described_class.generate(prompt: "a cat", path: "/tmp/cat.png")
+
+      expect(result).to eq(ok: false, error: "generation failed")
+    end
+
+    it "returns ok: false without generating when the model fails to load" do
+      allow(open_fn).to receive(:call).and_return(instance_double(Fiddle::Pointer, null?: true))
+      allow(generate_fn).to receive(:call)
+
+      result = described_class.generate(prompt: "a cat", path: "/tmp/cat.png")
+
+      expect(result).to eq(ok: false, error: "failed to load model at /models/sd.gguf")
+      expect(generate_fn).not_to have_received(:call)
+    end
+
+    it "defaults steps/width/height/negative_prompt/seed and passes them to the shim" do
+      allow(generate_fn).to receive(:call).and_return(1)
+
+      described_class.generate(prompt: "a cat", path: "/tmp/cat.png")
+
+      expect(generate_fn).to have_received(:call).with(handle, "a cat", "", 512, 512, 20, 42, "/tmp/cat.png")
+    end
+
+    it "reuses the same context across calls for the same model" do
+      allow(generate_fn).to receive(:call).and_return(1)
+
+      described_class.generate(prompt: "a cat", path: "/tmp/cat.png")
+      described_class.generate(prompt: "a dog", path: "/tmp/dog.png")
+
+      expect(open_fn).to have_received(:call).once
+    end
+
+    it "does not close the context after generating" do
+      allow(generate_fn).to receive(:call).and_return(1)
+
+      described_class.generate(prompt: "a cat", path: "/tmp/cat.png")
+
+      expect(close_fn).not_to have_received(:call)
+    end
+  end
+
+  describe ".img2img" do
+    it "returns ok and the path on success" do
+      allow(img2img_fn).to receive(:call).and_return(1)
+
+      result = described_class.img2img(prompt: "make it blue", input_path: "/tmp/in.png", path: "/tmp/out.png")
+
+      expect(result).to eq(ok: true, path: "/tmp/out.png")
+    end
+
+    it "passes strength and negative_prompt defaults to the shim" do
+      allow(img2img_fn).to receive(:call).and_return(1)
+
+      described_class.img2img(prompt: "make it blue", input_path: "/tmp/in.png", path: "/tmp/out.png")
+
+      expect(img2img_fn).to have_received(:call).with(handle, "make it blue", "", "/tmp/in.png", 0.5, 20, 42, "/tmp/out.png")
+    end
+
+    it "returns ok: false when img2img fails" do
+      allow(img2img_fn).to receive(:call).and_return(0)
+
+      result = described_class.img2img(prompt: "make it blue", input_path: "/tmp/in.png", path: "/tmp/out.png")
+
+      expect(result).to eq(ok: false, error: "img2img failed")
+    end
+  end
+
+  describe ".video" do
+    it "creates the output directory and returns ok on success" do
+      allow(video_fn).to receive(:call).and_return(1)
+      allow(FileUtils).to receive(:mkdir_p)
+
+      result = described_class.video(prompt: "a bird flying", output_dir: "/tmp/vid")
+
+      expect(FileUtils).to have_received(:mkdir_p).with("/tmp/vid")
+      expect(result).to eq(ok: true, path: "/tmp/vid")
+    end
+
+    it "defaults video_frames/fps/steps and passes them to the shim" do
+      allow(video_fn).to receive(:call).and_return(1)
+      allow(FileUtils).to receive(:mkdir_p)
+
+      described_class.video(prompt: "a bird flying", output_dir: "/tmp/vid")
+
+      expect(video_fn).to have_received(:call).with(handle, "a bird flying", "", 512, 512, 20, 42, 16, 8, "/tmp/vid")
+    end
+
+    it "returns ok: false when generation fails" do
+      allow(video_fn).to receive(:call).and_return(0)
+      allow(FileUtils).to receive(:mkdir_p)
+
+      result = described_class.video(prompt: "a bird flying", output_dir: "/tmp/vid")
+
+      expect(result).to eq(ok: false, error: "video generation failed")
+    end
+  end
+
+  describe ".upscale" do
+    around do |example|
+      original = ENV["ELELEM_SD_ESRGAN_MODEL"]
+      ENV["ELELEM_SD_ESRGAN_MODEL"] = "/models/esrgan.pth"
+      example.run
+    ensure
+      ENV["ELELEM_SD_ESRGAN_MODEL"] = original
+    end
+
+    it "returns ok and the path on success" do
+      allow(upscale_fn).to receive(:call).and_return(1)
+
+      result = described_class.upscale(input_path: "/tmp/in.png", path: "/tmp/out.png")
+
+      expect(result).to eq(ok: true, path: "/tmp/out.png")
+    end
+
+    it "does not require an sd context" do
+      allow(upscale_fn).to receive(:call).and_return(1)
+
+      described_class.upscale(input_path: "/tmp/in.png", path: "/tmp/out.png")
+
+      expect(open_fn).not_to have_received(:call)
+    end
+
+    it "returns ok: false when upscaling fails" do
+      allow(upscale_fn).to receive(:call).and_return(0)
+
+      result = described_class.upscale(input_path: "/tmp/in.png", path: "/tmp/out.png")
+
+      expect(result).to eq(ok: false, error: "upscale failed")
+    end
+  end
+
+  describe ".available?" do
+    it "is true when the compiled shim is present" do
+      allow(File).to receive(:exist?).with(Elelem::Diffusion::SHIM).and_return(true)
+
+      expect(described_class).to be_available
+    end
+
+    it "is false when the compiled shim is missing" do
+      allow(File).to receive(:exist?).with(Elelem::Diffusion::SHIM).and_return(false)
+
+      expect(described_class).not_to be_available
+    end
+  end
+end
spec/elelem/image_spec.rb
@@ -1,78 +0,0 @@
-# frozen_string_literal: true
-
-RSpec.describe Elelem::Image do
-  describe ".generate" do
-    around do |example|
-      original = ENV["ELELEM_SD_MODEL"]
-      ENV["ELELEM_SD_MODEL"] = "/models/sd.gguf"
-      example.run
-    ensure
-      ENV["ELELEM_SD_MODEL"] = original
-    end
-
-    let(:open_fn) { instance_double(Fiddle::Function, call: handle) }
-    let(:generate_fn) { instance_double(Fiddle::Function) }
-    let(:close_fn) { instance_double(Fiddle::Function, call: nil) }
-    let(:handle) { instance_double(Fiddle::Pointer, null?: false) }
-
-    before do
-      allow(described_class).to receive(:functions).and_return(open: open_fn, generate: generate_fn, close: close_fn)
-    end
-
-    it "returns ok and the path on success" do
-      allow(generate_fn).to receive(:call).and_return(1)
-
-      result = described_class.generate(prompt: "a cat", path: "/tmp/cat.png")
-
-      expect(result).to eq(ok: true, path: "/tmp/cat.png")
-    end
-
-    it "returns ok: false when generation fails" do
-      allow(generate_fn).to receive(:call).and_return(0)
-
-      result = described_class.generate(prompt: "a cat", path: "/tmp/cat.png")
-
-      expect(result).to eq(ok: false, error: "generation failed")
-    end
-
-    it "returns ok: false without generating when the model fails to load" do
-      allow(open_fn).to receive(:call).and_return(instance_double(Fiddle::Pointer, null?: true))
-      allow(generate_fn).to receive(:call)
-
-      result = described_class.generate(prompt: "a cat", path: "/tmp/cat.png")
-
-      expect(result).to eq(ok: false, error: "failed to load model at /models/sd.gguf")
-      expect(generate_fn).not_to have_received(:call)
-    end
-
-    it "closes the context even when generation fails" do
-      allow(generate_fn).to receive(:call).and_return(0)
-
-      described_class.generate(prompt: "a cat", path: "/tmp/cat.png")
-
-      expect(close_fn).to have_received(:call).with(handle)
-    end
-
-    it "defaults steps/width/height/negative_prompt/seed and passes them to the shim" do
-      allow(generate_fn).to receive(:call).and_return(1)
-
-      described_class.generate(prompt: "a cat", path: "/tmp/cat.png")
-
-      expect(generate_fn).to have_received(:call).with(handle, "a cat", "", 512, 512, 20, 42, "/tmp/cat.png")
-    end
-  end
-
-  describe ".available?" do
-    it "is true when the compiled shim is present" do
-      allow(File).to receive(:exist?).with(Elelem::Image::SHIM).and_return(true)
-
-      expect(described_class).to be_available
-    end
-
-    it "is false when the compiled shim is missing" do
-      allow(File).to receive(:exist?).with(Elelem::Image::SHIM).and_return(false)
-
-      expect(described_class).not_to be_available
-    end
-  end
-end
spec/spec_helper.rb
@@ -1,6 +1,6 @@
 # frozen_string_literal: true
 
-require_relative "../lib/elelem/image"
+require_relative "../lib/elelem/diffusion"
 
 RSpec.configure do |config|
   config.disable_monkey_patching!
.gitignore
@@ -6,6 +6,6 @@
 /pkg/
 /spec/reports/
 /tmp/
-/ext/elelem/image/build/
-/ext/elelem/image/native/
-/ext/elelem/image/Makefile
+/ext/elelem/diffusion/build/
+/ext/elelem/diffusion/native/
+/ext/elelem/diffusion/Makefile
elelem-image.gemspec → elelem-diffusion.gemspec
@@ -1,16 +1,16 @@
 # frozen_string_literal: true
 
-require_relative "lib/elelem/image/version"
+require_relative "lib/elelem/diffusion/version"
 
 Gem::Specification.new do |spec|
-  spec.name = "elelem-image"
-  spec.version = Elelem::Image::VERSION
+  spec.name = "elelem-diffusion"
+  spec.version = Elelem::Diffusion::VERSION
   spec.authors = ["mo khan"]
   spec.email = ["[email protected]"]
 
-  spec.summary = "Local image generation tool for elelem."
-  spec.description = "Adds an image tool backed by an embedded stable-diffusion.cpp to generate images from text prompts."
-  spec.homepage = "https://src.mokhan.ca/elelem/image"
+  spec.summary = "Local diffusion-model tools for elelem."
+  spec.description = "Adds image, img2img, video, and upscale tools backed by an embedded stable-diffusion.cpp."
+  spec.homepage = "https://src.mokhan.ca/elelem/diffusion"
   spec.license = "MIT"
   spec.required_ruby_version = ">= 4.0.0"
   spec.metadata["allowed_push_host"] = "https://rubygems.org"
@@ -19,10 +19,10 @@ Gem::Specification.new do |spec|
 
   spec.files = [
     "LICENSE.txt",
-    "lib/elelem/image.rb",
-    "lib/elelem/image/version.rb",
-    "ext/elelem/image/elelem.cpp",
-    "ext/elelem/image/extconf.rb",
+    "lib/elelem/diffusion.rb",
+    "lib/elelem/diffusion/version.rb",
+    "ext/elelem/diffusion/elelem.cpp",
+    "ext/elelem/diffusion/extconf.rb",
   ]
   # Only what extconf.rb's cmake build (SD_BUILD_EXAMPLES/WEBP/WEBM all OFF)
   # actually needs to configure and compile. Applies to both stable-diffusion.cpp's
@@ -31,7 +31,7 @@ Gem::Specification.new do |spec|
   spec.files += Dir["vendor/stable-diffusion.cpp/**/*"].select do |path|
     File.file?(path) && path !~ vendor_exclude
   end
-  spec.extensions = ["ext/elelem/image/extconf.rb"]
+  spec.extensions = ["ext/elelem/diffusion/extconf.rb"]
   spec.require_paths = ["lib"]
 
   spec.add_dependency "elelem", "~> 0.11"
Gemfile
@@ -2,7 +2,7 @@
 
 source "https://rubygems.org"
 
-gemspec name: "elelem-image"
+gemspec name: "elelem-diffusion"
 
 gem "irb"
 gem "rake", "~> 13.0"
Gemfile.lock
@@ -1,7 +1,7 @@
 PATH
   remote: .
   specs:
-    elelem-image (0.1.0)
+    elelem-diffusion (0.1.0)
       elelem (~> 0.11)
       fiddle (~> 1.1)
 
@@ -83,7 +83,7 @@ PLATFORMS
   x86_64-linux
 
 DEPENDENCIES
-  elelem-image!
+  elelem-diffusion!
   irb
   rake (~> 13.0)
   rspec (~> 3.0)
@@ -93,7 +93,7 @@ CHECKSUMS
   bundler (4.0.20) sha256=7978a8ac648767f5e635bc522445b79e80a52b907a39a36c2d8085ed6bc762ae
   diff-lcs (1.6.2) sha256=9ae0d2cba7d4df3075fe8cd8602a8604993efc0dfa934cff568969efb1909962
   elelem (0.11.0) sha256=552cafb092320e3896b8e07572d322fe2b94f0f10d965bf7b993b74517a6f744
-  elelem-image (0.1.0)
+  elelem-diffusion (0.1.0)
   erb (6.0.7) sha256=c5ca6dc25b0ef974a44dc8f59fe847577122483b1968a38dec305c60bf91ee92
   fiddle (1.1.8)
   forwardable (1.4.0) sha256=f1cd40cc9812937980e1c76f1aa053660990a7c9b6a98fc37d945468afcce838