Commit 59fae59
Changed files (13)
ext
elelem
diffusion
image
lib
elelem
spec
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(¶ms);
+ 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(¶ms);
+ 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(¶ms);
+ 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, ¶ms, &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(¶ms);
+ 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, ¶ms, &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(¶ms);
+ 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, ¶ms, &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(¶ms);
- 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(¶ms);
- 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(¶ms);
- 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, ¶ms, &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