Commit d253053

2026-09-12 20:28:11
fix: give video its own context, keyed by diffusion/vae/t5xxl paths
Wan2.1/2.2 video loads three separate files (diffusion model, vae, t5xxl text encoder), unlike image generation's single checkpoint. el_sd_open only took one model_path, so video silently reused (and would have corrupted) the image context. Add el_sd_open_video wrapping sd_ctx_params_t's diffusion_model_path/ vae_path/t5xxl_path fields, and cache video contexts separately, keyed by the three paths together, so image and video generation coexist without interfering with each other's context. Verified against a real Wan2.1 T2V 1.3B + wan_2.1_vae + umt5-xxl-encoder (Q3_K_S) checkpoint set: generated 5 real frames of "a lovely cat" in 36s.
1 parent 59fae59
Changed files (3)
ext
elelem
diffusion
lib
spec
ext/elelem/diffusion/elelem.cpp
@@ -64,6 +64,25 @@ extern "C" {
     return handle;
   }
 
+  EL_API void *el_sd_open_video(const char *diffusion_model_path, const char *vae_path,
+                           const char *t5xxl_path, int n_threads) {
+    sd_ctx_params_t params;
+    sd_ctx_params_init(&params);
+    params.diffusion_model_path = diffusion_model_path;
+    params.vae_path = vae_path;
+    params.t5xxl_path = t5xxl_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);
lib/elelem/diffusion.rb
@@ -28,6 +28,7 @@ module Elelem
         lib = Fiddle.dlopen(SHIM)
         {
           open: Fiddle::Function.new(lib["el_sd_open"], [V, I], V),
+          open_video: Fiddle::Function.new(lib["el_sd_open_video"], [V, V, 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),
@@ -43,6 +44,13 @@ module Elelem
       end
     end
 
+    def self.video_context_for(diffusion_model, vae, t5xxl, n_threads: nil)
+      key = [diffusion_model, vae, t5xxl]
+      MUTEX.synchronize do
+        CONTEXTS[key] ||= functions[:open_video].call(diffusion_model, vae, t5xxl, 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
@@ -63,13 +71,17 @@ module Elelem
 
     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 }
+      diffusion_model = ENV.fetch("ELELEM_SD_VIDEO_MODEL")
+      vae = ENV.fetch("ELELEM_SD_VIDEO_VAE")
+      t5xxl = ENV.fetch("ELELEM_SD_VIDEO_T5XXL")
+      handle = video_context_for(diffusion_model, vae, t5xxl, n_threads: n_threads)
+      return { ok: false, error: "failed to load video model at #{diffusion_model}" } if handle.null?
+
+      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
 
     def self.upscale(input_path:, path:, n_threads: nil)
spec/elelem/diffusion_spec.rb
@@ -14,6 +14,7 @@ RSpec.describe Elelem::Diffusion do
   end
 
   let(:open_fn) { instance_double(Fiddle::Function, call: handle) }
+  let(:open_video_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) }
@@ -23,7 +24,7 @@ RSpec.describe Elelem::Diffusion do
 
   before do
     allow(described_class).to receive(:functions).and_return(
-      open: open_fn, generate: generate_fn, img2img: img2img_fn,
+      open: open_fn, open_video: open_video_fn, generate: generate_fn, img2img: img2img_fn,
       video: video_fn, upscale: upscale_fn, close: close_fn
     )
   end
@@ -108,6 +109,18 @@ RSpec.describe Elelem::Diffusion do
   end
 
   describe ".video" do
+    around do |example|
+      original = { model: ENV["ELELEM_SD_VIDEO_MODEL"], vae: ENV["ELELEM_SD_VIDEO_VAE"], t5xxl: ENV["ELELEM_SD_VIDEO_T5XXL"] }
+      ENV["ELELEM_SD_VIDEO_MODEL"] = "/models/wan.safetensors"
+      ENV["ELELEM_SD_VIDEO_VAE"] = "/models/wan_vae.safetensors"
+      ENV["ELELEM_SD_VIDEO_T5XXL"] = "/models/t5xxl.gguf"
+      example.run
+    ensure
+      ENV["ELELEM_SD_VIDEO_MODEL"] = original[:model]
+      ENV["ELELEM_SD_VIDEO_VAE"] = original[:vae]
+      ENV["ELELEM_SD_VIDEO_T5XXL"] = original[:t5xxl]
+    end
+
     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)
@@ -118,6 +131,17 @@ RSpec.describe Elelem::Diffusion do
       expect(result).to eq(ok: true, path: "/tmp/vid")
     end
 
+    it "opens the video context with the diffusion/vae/t5xxl paths" 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(open_video_fn).to have_received(:call).with(
+        "/models/wan.safetensors", "/models/wan_vae.safetensors", "/models/t5xxl.gguf", Etc.nprocessors
+      )
+    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)
@@ -135,6 +159,16 @@ RSpec.describe Elelem::Diffusion do
 
       expect(result).to eq(ok: false, error: "video generation failed")
     end
+
+    it "returns ok: false without generating when the video model fails to load" do
+      allow(open_video_fn).to receive(:call).and_return(instance_double(Fiddle::Pointer, null?: true))
+      allow(video_fn).to receive(:call)
+
+      result = described_class.video(prompt: "a bird flying", output_dir: "/tmp/vid")
+
+      expect(result).to eq(ok: false, error: "failed to load video model at /models/wan.safetensors")
+      expect(video_fn).not_to have_received(:call)
+    end
   end
 
   describe ".upscale" do