Skip to content

Add Kandinsky S2V 1.0 (speech-to-video) as a modular pipeline - #15164

Merged
yiyixuxu merged 12 commits into
huggingface:mainfrom
nvsm:kandinsky-s2v-1.0
Nov 3, 2026
Merged

yiyixuxu merged 12 commits into
huggingface:mainfrom
nvsm:kandinsky-s2v-1.0

Conversation

@nvsm

@nvsm nvsm commented Oct 31, 2026 •

Copy link
Copy Markdown
Contributor

What does this PR do?

Adds Kandinsky S2V 1.0, a speech-to-video model. Given a reference frame and a speech track, the
model animates the person in the frame to match the speech: the whole figure, not only the
head. Audio is a condition, not an output.

It was reviewed and approved in the staging repository:
https://github.com/huggingface/diffusers-new-model-addition-kandinskyX-avatar/pull/3

Since that approval, one commit addresses the self-review below (compile-friendly speech
attention, the full set of model testers, workflow and guider contracts, dead code):
9ba233b
Everything else is the approved code, rebased on current main.

It is versioned apart from the other Kandinsky models, so it gets files of its own, and it is
integrated as a modular pipeline only — no DiffusionPipeline class, nothing in pipelines/:

  • KandinskyS2VTransformer3DModel — models/transformers/transformer_kandinsky_s2v.py
  • the distilled checkpoints use the existing PiflowScheduler, fixed to match the reference
    sampler in kandinskylab/kandinsky-6 bit for bit (it differed on the final step and on the
    raw-time walk)
  • KandinskyS2VAutoBlocks / KandinskyS2VModularPipeline and
    KandinskyS2VDistilledAutoBlocks / KandinskyS2VDistilledModularPipeline —
    modular_pipelines/kandinsky_s2v/

The pipeline takes a PIL image and a mono 16 kHz waveform and reads no files. Its top-level
blocks — text_encoder, vae_encoder, denoise and decode — each run on their own; the text
encoder plans the chunks from the speech, and declares no guider.

A clip is generated chunk by chunk (5 s each) with FramePack memory carried between chunks.
The chunk loop is a LoopSequentialPipelineBlocks; the timestep loop is a plain loop inside
one block, as in Helios, since loop blocks cannot nest. Base and distilled are separate block
sets rather than a switch: the distilled scheduler and 16-step default need component specs of
their own, and the distilled blocks have no guider and no negative prompt, since guidance is
distilled into the student. Each checkpoint names its blocks in modular_model_index.json, so
ModularPipeline.from_pretrained picks the variant from the checkpoint alone.

Docs: a pipeline page and a transformer page; the PiflowScheduler page mentions S2V.

Usage

import librosa
import torch
from diffusers import ModularPipeline
from diffusers.utils import encode_video, load_image

pipe = ModularPipeline.from_pretrained("kandinskylab/Kandinsky-S2V-1.0-Lite-distill-Diffusers")
pipe.load_components(dtype=torch.bfloat16)
pipe.to("cuda")

image = load_image("reference.png")
audio, _ = librosa.load("speech.wav", sr=16000)  # a mono waveform at 16 kHz, for the model
speech, sample_rate = librosa.load("speech.wav", sr=None)  # the original track, added to the output video

video = pipe(image=image, audio=audio, output="videos")[0]
encode_video(
    video,
    fps=25,
    output_path="avatar.mp4",
    audio=torch.from_numpy(speech)[None],
    audio_sample_rate=sample_rate,
)

Pro does not fit an 80 GB card fully resident; the pipeline page shows offloading through a
ComponentsManager with memory_reserve_margin="40GiB" (50.4 GiB peak on Pro distilled).

Checkpoints

Four bundles, one per configuration, each with a modular_model_index.json:
kandinskylab/Kandinsky-S2V-1.0-{Lite,Pro,Lite-distill,Pro-distill}-Diffusers.

They are private until release; the reviewers of the staging PR have access. To run the usage
example above, set HF_TOKEN to a token with access to kandinskylab. The tests do not need
it: the two tiny repositories they load are public.

Tests

  • the transformer, with the testers utils/generate_model_tests.py emits: model, memory,
    compile (including dynamic shapes and repeated blocks), training and attention;
  • for base and distilled, a tester config with the fast, loading, memory and workflow mixins
    (expected_workflow_defaults pins each one's components, inputs and defaults), plus the
    guider mixin for base; each loads a tiny public repository —
    kandinskylab/tiny-kandinsky-s2v-modular-pipe and
    kandinskylab/tiny-kandinsky-s2v-distilled-modular-pipe (happy to move them under
    hf-internal-testing);
  • that memory carries over two chunks, that a track longer than the chunks is cut to them, and
    that speech past the encoder limit is rejected;
  • that the standalone text encoder encodes a negative prompt only when one is passed;
  • that a multichannel, empty or integer waveform is rejected;
  • fp16 inference with the pipeline loaded in fp16, which keeps _keep_in_fp32_modules in fp32;
  • that the RoPE tables stay fp32 after .to(torch.bfloat16);
  • PiflowScheduler's final step against a hand-rolled policy and rollout.

Skipped, each for a property of the model:

  • batching and num_images_per_prompt, for base and distilled: one prompt, one video per call;
  • test_fuse_unfuse_qkv_projections: the projections are named to_query/to_key/to_value
    after the training checkpoints, as in Kandinsky 6;
  • test_layerwise_casting_memory: the affine normalisations take fp32 input, as the
    checkpoints keep them in fp32, and the test first casts the whole model with .to();
  • test_compile_works_with_aot: AOTInductor lowers the lookup into the RoPE tables, plain fp32
    attributes so that casting the model keeps them fp32, to a CPU kernel on a CUDA tensor.

On one H100, on this branch (rebased on current main): 112 passed, 16 skipped (the above, one
test that needs several GPUs, and the workflow-map tests for a pipeline with a single
workflow). The Kandinsky 6 tests give the same result as on main: 166 passed, and the same 3
failures (test_pipeline_kandinsky6_sr::test_inference_batch_single_identical and
test_pipeline_kandinsky6_ti2va::…::test_group_offloading_inference_block_level, both
variants). make quality (with the pinned ruff 0.9.10), check_copies, check_dummies,
check_doc_toc, check_support_list, check_forward_call_docstrings,
check_return_annotations and modular_auto_docstring pass.

Self-review notes

I ran the self-review skill, with the rubric from current main, in two rounds and fixed
what it reported as blocking:

  • FromOriginalModelMixin without single-file loading wired up: removed;
  • model tests now compose every tester generate_model_tests.py emits;
  • the speech cross-attention broke torch.compile(fullgraph=True) on data-dependent shapes: it
    now computes the per-frame audio groups as Python ints and gathers them into a padded, masked
    batch. One forward stays bit-identical to the original model on all four checkpoints;
  • expected_workflow_defaults for both variants, and the guider tester for base.

Left as they are, for your call:

  • prompt is optional and defaults to the prompt the checkpoints were trained with (an empty
    prompt gives no text tokens). The default is long, so it shows in the auto docstrings.
  • There is no latents input: noise is drawn per chunk from generator, as in wan_animate_2.
  • In the base pipeline, num_inference_steps=N gives N−1 Euler updates: the raw grid is the
    one the original sampler uses, and the block description says so.
  • The VAE decode runs under bf16 autocast on CUDA, as in the original pipeline.
  • The text encoder reads requires_unconditional_embeds without declaring a guider, as
    wan_animate_2 does, so that it runs on its own.
  • The PiflowScheduler fix changes Kandinsky 6 distilled outputs slightly. No Kandinsky 6 test
    pins output slices, and they pass as on main.
  • The speech adapter always passes a padding mask, so attention backends that reject masks
    (flash, _flash_3, sage, aiter) fail in those blocks; native and the varlen backends
    work.
  • test_layerwise_casting_memory and test_compile_works_with_aot are skipped with a reason;
    strict xfail would also fit. _skip_layerwise_casting_patterns is not set.
  • The tiny test repositories live under kandinskylab.

Before submitting

  • Did you use an AI agent (Claude Code, Codex, Cursor, etc.) to help with this PR? If so:
    • Did you read the Coding with AI agents guide?
    • Did you run the self-review skill on the diff?
    • Did you share the final self-review notes in the PR description or a comment?
  • Did you read the contributor guideline?
  • Did you read our philosophy doc? (important for complex PRs)
  • Was this discussed/approved via a GitHub issue or the forum? Please add a link to it if that's the case.
  • Did you make sure to update the documentation with your changes? Here are the
    documentation guidelines, and
    here are tips on formatting docstrings.
  • Did you write any new necessary tests?
  • Are you the author (or part of the team) of the model/pipeline (only applicable for model/pipeline related PRs)?

Who can review?

@yiyixuxu @sayakpaul

nvsm added 10 commits November 1, 2026 00:38
…e inputs

- Drop KandinskyS2VPiflowScheduler; the distilled blocks use PiflowScheduler.
- PiflowScheduler: on the final step, pass the nominal segment to the policy
  and clamp only the rollout end to eps, as the native K6 and S2V samplers do.
- Keep the S2V RoPE tables as plain attributes so `.to(torch.bfloat16)` leaves
  them in fp32.
- Raise when only one of negative_prompt_embeds / negative_pooled_prompt_embeds
  is passed, and when the Euler path gets num_inference_steps < 2.
…tional deps

- KandinskyS2VModulation calls its layer instead of reading the weight, and
  keeps the modulation layers in fp32 via _keep_in_fp32_modules (as in K6).
- Remove the transformer's `duration` argument; it can only be the frame count.
- Import librosa, soundfile and torchvision behind is_*_available().
- PiflowScheduler: walk raw time one segment at a time and give the policy the
  nominal segment on every step, as the native samplers do.
- Update the Pro offload peak in the docs (about 50 GiB).
- Fold the one-line helpers into their callers, inline the memory embedding,
  name the projection layers explicitly and spell out the block arguments.
- Accept `audio` as a mono 16 kHz float waveform (tensor or array) besides a path.
- Run the fp16 test with the pipeline loaded in fp16; drop the variant test.
- Docs: offload Pro with memory_reserve_margin="40GiB" and show a full run.
- Take `image` as a PIL image and `audio` as a 16 kHz waveform; the docs load them.
- Pass components to the encode helpers explicitly.
- Drop the guider from the text encoder: standalone it encodes a negative
  prompt only when passed; in the pipeline it follows the guider.
- Remove the input step: the text encoder plans the chunks, the VAE encoder
  picks the resolution.
- Group prepare and the chunk loop into one `denoise` step.
- Distilled blocks: no guider and no negative prompt.
…ssor

- Inline the grouped attention into the processor.
- `prompt` defaults to DEFAULT_PROMPT in its signature; check_inputs only validates.
- Add auto docstrings to the distilled text encoder and prepare steps.
- Drop `output_type="latent"`; the latents are in the state as `latent_chunks`.
- Preprocess the reference frame with the video processor.
- Remove three tests the review asked to drop.
- encode_prompt_and_speech and shared_transformer_kwargs take their components
  and values explicitly and return their results.
- The negative prompt gets its own prepare block in the base pipeline; the
  distilled pipeline uses the shared prepare step.
- check_inputs of the prepare steps take explicit values.
main checks that the denoise group leaves latents in one canonical form
(test_latents_output_in_canonical_form). Point it at latent_chunks: one
chunk per entry, channels last.
…sters

- Speech cross-attention keeps static shapes: the per-frame audio groups
  are computed from shapes as Python ints and gathered into a padded,
  masked batch, so the transformer compiles with fullgraph=True. One
  forward is bit-identical to the original model on all four checkpoints.
- Drop FromOriginalModelMixin: single-file loading is not wired up.
- Model tests compose the testers generate_model_tests.py emits (memory,
  compile incl. dynamic shapes, training, attention); skips name their
  reason.
- Modular tests pin expected_workflow_defaults for base and distilled and
  add the guider tester for base.
- Remove dead code: unread attributes, the batched visual-embedding
  branch, unused helper parameters.
@github-actions github-actions Bot added documentation Improvements or additions to documentation models tests modular-pipelines utils schedulers size/L PR with diff > 200 LOC labels Oct 31, 2026
@HuggingFaceDocBuilderDev

Copy link
Copy Markdown

The docs for this PR live here. All of your documentation changes will be reflected on that endpoint. The docs are available until 30 days after the last update.

def hook(_module, _inputs, output):
captured["features"] = output.last_hidden_state

handle = text_encoder.audio_tower.register_forward_hook(hook)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we use the official api get_audio_features? it should not make that big noticeable ifference on performance, no?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks! Switched to get_audio_features in df2dca1, the features are bit-identical. As you
expected, the cost is negligible (+20 ms on a 12 s track).

The speech features now come from the Thinker's public
get_audio_features rather than a forward hook on its audio tower. The
tower runs twice per call, once inside the Thinker forward for the text
hidden states and once for the features: +20 ms on a 12 s track (bf16,
H100). The features and the text embeddings are bit-identical to before.
Co-authored-by: YiYi Xu <yixu310@gmail.com>

@yiyixuxu yiyixuxu left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thanks

@yiyixuxu
yiyixuxu merged commit 9904951 into huggingface:main Nov 3, 2026
16 checks passed
@nvsm

nvsm commented Nov 3, 2026

Copy link
Copy Markdown
Contributor Author

thanks a lot @yiyixuxu and @sayakpaul for all the reviews here and in the staging repo, and for
the help getting Kandinsky S2V into Diffusers!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation models modular-pipelines schedulers size/L PR with diff > 200 LOC tests utils

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants