hugging-apps/diffsynth-music-demo
4
1import os2 3os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True")4 5import spaces # noqa: E402 (must precede torch / CUDA-touching imports)6 7import math # noqa: E4028import tempfile # noqa: E4029import time # noqa: E40210 11import gradio as gr # noqa: E40212import librosa # noqa: E40213import numpy as np # noqa: E40214import soundfile as sf # noqa: E40215import torch # noqa: E40216from einops import repeat # noqa: E40217from huggingface_hub import snapshot_download # noqa: E40218from scipy.signal import butter, filtfilt # noqa: E40219 20from diffsynth.diffusion.template import TemplatePipeline # noqa: E40221from diffsynth.pipelines.diffsynth_music import ( # noqa: E40222 DiffSynthMusicPipeline,23 ModelConfig,24)25from diffsynth.utils.music_tools import generate_click # noqa: E40226 27REPO_ID = "DiffSynth-Studio/DiffSynth-Music"28SAMPLE_RATE = 4800029DIVISION_FACTOR = 3840 # 1920 (VAE hop) x 2 (latent token pairing)30MAX_REFERENCE_SECONDS = 60 # reference template scans for its loudest window31 32# Template model ids, matching the order they are registered in TemplatePipeline below.33TEMPLATE_CONTROL = 034TEMPLATE_PROSODY = 135TEMPLATE_REFERENCE = 236 37MODE_TEXT = "Text to Music"38MODE_BEATS = "Beats"39MODE_VOCALS = "Vocals"40MODE_ACCOMP = "Accompaniment"41MODE_PROSODY = "Prosody"42MODE_REFERENCE = "Reference"43MODES = [MODE_TEXT, MODE_BEATS, MODE_VOCALS, MODE_ACCOMP, MODE_PROSODY, MODE_REFERENCE]44AUDIO_MODES = (MODE_VOCALS, MODE_ACCOMP, MODE_PROSODY, MODE_REFERENCE)45 46# ----------------------------------------------------------------------------------47# Model loading (module scope, then eagerly onto "cuda" -- ZeroGPU packs the weights)48#49# DiffSynth's loader hardcodes `use_disk_map=True` and hands the *computation device*50# straight to `safetensors.safe_open(..., device=...)`, which allocates on CUDA51# outside of any patchable torch call and blows up with "No CUDA GPUs are available"52# in the ZeroGPU main process. The VAE's `remove_weight_norm()` is real arithmetic on53# the loaded weights and has the same problem. So: build both pipelines on CPU, then54# move them to CUDA through `.to()`, which is the call ZeroGPU intercepts.55# ----------------------------------------------------------------------------------56 57MODEL_DIR = snapshot_download(58 REPO_ID,59 allow_patterns=[60 "transformer/*",61 "conditioner/*",62 "text_encoder/*",63 "vae/*",64 "track_separator/*",65 "template_control/*",66 "template_prosody/*",67 "template_reference/*",68 ],69)70print(f"Weights downloaded to {MODEL_DIR}", flush=True)71 72 73def _p(*parts):74 return os.path.join(MODEL_DIR, *parts)75 76 77pipe = DiffSynthMusicPipeline.from_pretrained(78 torch_dtype=torch.bfloat16,79 device="cpu",80 model_configs=[81 ModelConfig(path=_p("transformer", "model.safetensors")),82 ModelConfig(path=_p("conditioner", "model.safetensors")),83 ModelConfig(path=_p("text_encoder", "model.safetensors")),84 ModelConfig(path=_p("vae", "model.safetensors")),85 ModelConfig(path=_p("track_separator", "model.safetensors"), computation_dtype=torch.float32),86 ],87 tokenizer_config=ModelConfig(path=_p("text_encoder")),88)89template = TemplatePipeline.from_pretrained(90 torch_dtype=torch.bfloat16,91 device="cpu",92 model_configs=[93 ModelConfig(path=_p("template_control")),94 ModelConfig(path=_p("template_prosody")),95 ModelConfig(path=_p("template_reference")),96 ],97)98print("Models built on CPU, moving to CUDA...", flush=True)99 100# `BasePipeline.to` keeps `self.device` in sync but not `self.device_type`; the plain101# `TemplatePipeline` tracks neither, so both are fixed up by hand. Note the dtype is102# deliberately left alone: the Demucs track separator is loaded in float32.103pipe.to("cuda")104pipe.device = "cuda"105pipe.device_type = "cuda"106template.models.to("cuda")107template.device = "cuda"108 109DEFAULT_NEGATIVE_PROMPT = pipe.default_negative_prompt110print("All models loaded.", flush=True)111 112 113# ----------------------------------------------------------------------------------114# Audio helpers115# ----------------------------------------------------------------------------------116 117 118def load_audio(path: str, max_seconds: float = None) -> torch.Tensor:119 """Load an audio file as a stereo 48 kHz float tensor of shape [2, L]."""120 wav, _ = librosa.load(path, sr=SAMPLE_RATE, mono=False)121 wav = np.atleast_2d(wav)122 if wav.shape[0] == 1:123 wav = np.repeat(wav, 2, axis=0)124 elif wav.shape[0] > 2:125 wav = wav[:2]126 audio = torch.from_numpy(np.ascontiguousarray(wav)).float()127 if max_seconds is not None:128 audio = audio[:, : int(max_seconds * SAMPLE_RATE)]129 return audio130 131 132def align_length(audio: torch.Tensor, max_seconds: float = None) -> torch.Tensor:133 """Crop to `max_seconds` and to a whole number of VAE frames."""134 if max_seconds is not None:135 audio = audio[:, : int(max_seconds * SAMPLE_RATE)]136 length = audio.shape[1] // DIVISION_FACTOR * DIVISION_FACTOR137 return audio[:, :length]138 139 140def crop_loudest(audio: torch.Tensor, max_seconds: float = None) -> torch.Tensor:141 """Crop to the loudest `max_seconds` window, aligned to whole VAE frames.142 143 Songs -- and isolated stems especially -- routinely open with a near-silent intro:144 the vocal stem of the authors' own reference track sits at -49 dBFS over its first145 20 s. That matters because `fuse_track` balances the generated audio down to the146 control track's RMS, so naively keeping the *first* N seconds can scale an entire147 render into inaudibility. Picking the loudest window keeps short renders usable.148 """149 total = audio.shape[1] // DIVISION_FACTOR * DIVISION_FACTOR150 audio = audio[:, :total]151 if max_seconds is None or total == 0:152 return audio153 want = int(max_seconds * SAMPLE_RATE) // DIVISION_FACTOR * DIVISION_FACTOR154 if want <= 0 or total <= want:155 return audio156 n = want // DIVISION_FACTOR157 frames = audio.pow(2).mean(dim=0).reshape(-1, DIVISION_FACTOR).mean(dim=1)158 cumulative = torch.cat([torch.zeros(1), frames.cumsum(0)])159 energy = cumulative[n:] - cumulative[:-n]160 start = int(torch.argmax(energy)) * DIVISION_FACTOR161 return audio[:, start : start + want]162 163 164def to_gradio_audio(audio: torch.Tensor):165 return SAMPLE_RATE, audio.float().cpu().numpy().T166 167 168def save_audio(audio: torch.Tensor) -> str:169 path = tempfile.NamedTemporaryFile(suffix=".wav", delete=False).name170 sf.write(path, audio.float().cpu().numpy().T, SAMPLE_RATE)171 return path172 173 174def extract_prosody(audio_tensor: torch.Tensor, sr: int = SAMPLE_RATE) -> torch.Tensor:175 """Prosody signal: every syllable's pronunciation is blurred out, timing + pitch kept.176 177 Port of `diffsynth.utils.music_tools.extract_prosody`. The only change is that the178 pYIN pitch track is estimated on a 16 kHz down-sample (identical f0 range, ~10x179 faster) before being interpolated back onto the 48 kHz grid -- the envelope and the180 synthesised carrier still run at the full 48 kHz.181 """182 audio_tensor = audio_tensor.squeeze()183 if audio_tensor.dim() > 1:184 audio_tensor = audio_tensor.mean(dim=0)185 y = audio_tensor.float().cpu().numpy()186 187 # Smooth amplitude envelope (identical to the reference implementation).188 abs_y = np.abs(y)189 nyq = 0.5 * sr190 b1, a1 = butter(4, 30.0 / nyq, btype="low")191 env = filtfilt(b1, a1, abs_y)192 b2, a2 = butter(2, 80.0 / nyq, btype="low")193 env = filtfilt(b2, a2, env)194 envelope = env / (np.max(env) + 1e-8)195 196 # f0 track.197 pyin_sr = 16000198 y_small = librosa.resample(y, orig_sr=sr, target_sr=pyin_sr)199 hop_length = 160200 f0, _, _ = librosa.pyin(201 y_small, fmin=65, fmax=1000, sr=pyin_sr, hop_length=hop_length, frame_length=1024202 )203 # forward/backward fill of unvoiced frames204 mask = np.isnan(f0)205 if np.any(mask):206 idx = np.where(~mask, np.arange(mask.shape[0]), 0)207 np.maximum.accumulate(idx, out=idx)208 f0 = f0[idx]209 mask2 = np.isnan(f0)210 if np.any(mask2):211 idx2 = np.where(~mask2, np.arange(mask2.shape[0]), mask2.shape[0] - 1)212 np.minimum.accumulate(idx2[::-1], out=idx2[::-1])213 f0 = f0[idx2]214 if np.all(np.isnan(f0)):215 f0 = np.full_like(f0, 220.0)216 217 t_frames = librosa.frames_to_time(np.arange(len(f0)), sr=pyin_sr, hop_length=hop_length)218 t_samples = np.arange(len(y)) / sr219 f0_samples = np.interp(t_samples, t_frames, f0)220 221 phase = 2 * np.pi * np.cumsum(f0_samples) / sr222 carrier = np.sin(phase)223 out = carrier * envelope224 out = out * (np.max(np.abs(y)) / (np.max(np.abs(out)) + 1e-8))225 out = torch.from_numpy(out.astype(np.float32))226 return repeat(out, "l -> n l", n=2)227 228 229# ----------------------------------------------------------------------------------230# Step 1 (optional): turn a full song into a control signal231# ----------------------------------------------------------------------------------232 233 234# ZeroGPU reservations below are fitted to times measured on this Space (see the235# constants' comments) and then given the recommended 1.4x margin, so visitors are236# only charged quota for what the call actually costs.237GPU_ENTRY_OVERHEAD = 5.0 # streaming the packed weights into VRAM on a cold worker238GPU_MARGIN = 1.4239 240 241def _reserve(core_seconds: float, floor: int, cap: int) -> int:242 return int(min(cap, max(floor, math.ceil((core_seconds + GPU_ENTRY_OVERHEAD) * GPU_MARGIN))))243 244 245def _prepare_duration(mode=MODE_VOCALS, source_audio=None, duration=60, *args, **kwargs):246 duration = int(duration or 60)247 core = 2.0 + 0.02 * duration # Demucs separation248 if mode == MODE_PROSODY:249 core += 0.05 * duration # + pYIN / envelope extraction250 return _reserve(core, floor=20, cap=90)251 252 253@spaces.GPU(duration=_prepare_duration)254def prepare_control(mode: str, source_audio: str, duration: int = 60):255 """Turn a full song into the control signal for the selected control mode.256 257 Runs Demucs source separation (Vocals / Accompaniment / Prosody) and, for Prosody,258 the pitch+envelope extraction on top of the isolated vocal track.259 """260 if mode not in AUDIO_MODES:261 raise gr.Error(262 f"'{mode}' does not take a control audio. Use Vocals, Accompaniment, "263 "Prosody or Reference."264 )265 if not source_audio:266 raise gr.Error("Please upload a song first.")267 268 if mode == MODE_REFERENCE:269 audio = crop_loudest(load_audio(source_audio), MAX_REFERENCE_SECONDS)270 return to_gradio_audio(audio)271 272 audio = crop_loudest(load_audio(source_audio), duration)273 if mode == MODE_ACCOMP:274 control = pipe.extract_track(audio, track=["drums", "bass", "other"])275 else:276 control = pipe.extract_track(audio, track="vocals")277 if mode == MODE_PROSODY:278 control = extract_prosody(control)279 control = align_length(control.float().cpu(), duration)280 return to_gradio_audio(control)281 282 283# ----------------------------------------------------------------------------------284# Step 2: generation285# ----------------------------------------------------------------------------------286 287 288def _generate_duration(289 mode=MODE_TEXT,290 prompt="",291 lyrics="",292 duration=60,293 bpm=120,294 control_audio=None,295 num_inference_steps=50,296 *args,297 **kwargs,298):299 duration = int(duration or 60)300 steps = int(num_inference_steps or 50)301 # Denoising dominates and scales with steps x sequence length. Measured on this302 # Space: 3.4s at 20 steps/20s, 9.5s at 50/60, 33.9s at 100/150 -> ~0.00215 s per303 # step-second on top of ~3s of fixed template + VAE work.304 core = 3.0 + 0.00215 * steps * duration305 if mode in (MODE_VOCALS, MODE_ACCOMP):306 core += 0.07 * duration # fuse_track runs Demucs twice (+9s at 150s measured)307 elif mode == MODE_PROSODY:308 core += 0.01 * duration309 return _reserve(core, floor=25, cap=120)310 311 312def _run(313 mode,314 prompt,315 lyrics,316 duration,317 bpm,318 control_audio,319 num_inference_steps,320 cfg_scale,321 seed,322 negative_prompt,323):324 t0 = time.perf_counter()325 duration = int(duration)326 num_inference_steps = int(num_inference_steps)327 seed = int(seed)328 negative_prompt = negative_prompt if negative_prompt is not None else ""329 330 kwargs = dict(331 prompt=prompt,332 negative_prompt=negative_prompt,333 lyrics=lyrics or "",334 duration=duration,335 seed=seed,336 tiled=True,337 cfg_scale=float(cfg_scale),338 num_inference_steps=num_inference_steps,339 )340 341 if mode == MODE_TEXT:342 pass343 344 elif mode == MODE_BEATS:345 beats = generate_click(int(bpm), duration=duration)346 kwargs.update(347 bpm=int(bpm),348 template_inputs=[{"model_id": TEMPLATE_CONTROL, "audio": beats}],349 negative_template_inputs=[{"model_id": TEMPLATE_CONTROL, "audio": beats * 0}],350 )351 352 elif mode in (MODE_VOCALS, MODE_ACCOMP, MODE_PROSODY, MODE_REFERENCE):353 if not control_audio:354 raise gr.Error(355 f"The '{mode}' mode needs a control audio. Upload one, or use "356 "'Make a control signal from a song' to build it from a full track."357 )358 if mode == MODE_REFERENCE:359 control = crop_loudest(load_audio(control_audio), MAX_REFERENCE_SECONDS)360 # The reference template carries the timbre, so the reference recipe drops361 # the negative prompt entirely.362 kwargs.update(363 negative_prompt="",364 template_inputs=[{"model_id": TEMPLATE_REFERENCE, "audio": control}],365 )366 else:367 control = crop_loudest(load_audio(control_audio), duration)368 if control.shape[1] < DIVISION_FACTOR:369 raise gr.Error("The control audio is too short.")370 kwargs["duration"] = control.shape[1] / SAMPLE_RATE371 model_id = TEMPLATE_PROSODY if mode == MODE_PROSODY else TEMPLATE_CONTROL372 kwargs.update(373 template_inputs=[{"model_id": model_id, "audio": control}],374 negative_template_inputs=[{"model_id": model_id, "audio": control}],375 )376 if mode == MODE_VOCALS:377 kwargs.update(target_audio=control, target_track="vocals")378 elif mode == MODE_ACCOMP:379 kwargs.update(target_audio=control, target_track=["drums", "bass", "other"])380 else:381 raise gr.Error(f"Unknown mode: {mode}")382 383 audio = template(pipe, **kwargs)384 print(f"[{mode}] generated in {time.perf_counter() - t0:.1f}s", flush=True)385 return save_audio(audio)386 387 388@spaces.GPU(duration=_generate_duration)389def generate(390 mode: str = MODE_TEXT,391 prompt: str = "",392 lyrics: str = "",393 duration: int = 60,394 bpm: int = 120,395 control_audio: str = None,396 num_inference_steps: int = 50,397 cfg_scale: float = 4.0,398 seed: int = 42,399 negative_prompt: str = DEFAULT_NEGATIVE_PROMPT,400 progress=gr.Progress(track_tqdm=True),401) -> str:402 """Generate music with DiffSynth-Music.403 404 Args:405 mode: control mode -- Text to Music, Beats, Vocals, Accompaniment, Prosody or Reference.406 prompt: text description of the music to generate.407 lyrics: lyrics, optionally with [Intro] / [Verse] / [Chorus] structure tags.408 duration: length of the generated track in seconds.409 bpm: beats per minute, used by the Beats mode to build the click track.410 control_audio: path to the control audio (Vocals / Accompaniment / Prosody / Reference).411 num_inference_steps: number of flow-matching steps.412 cfg_scale: classifier-free guidance scale.413 seed: random seed.414 negative_prompt: text description of what to avoid.415 416 Returns:417 Path to the generated 48 kHz stereo audio file.418 """419 return _run(420 mode, prompt, lyrics, duration, bpm, control_audio,421 num_inference_steps, cfg_scale, seed, negative_prompt,422 )423 424 425# Dedicated entry points for `gr.Examples`. Gradio fills example values positionally426# and then inserts its Progress object at the index where it is declared, so an427# example set has to line up with a signature of exactly its own arity.428 429 430@spaces.GPU(duration=_generate_duration)431def generate_from_text_example(432 mode: str = MODE_TEXT,433 prompt: str = "",434 lyrics: str = "",435 duration: int = 60,436 bpm: int = 120,437 progress=gr.Progress(track_tqdm=True),438) -> str:439 """Generate music from a prompt, lyrics and (for the Beats mode) a BPM."""440 return _run(mode, prompt, lyrics, duration, bpm, None, 50, 4.0, 42, DEFAULT_NEGATIVE_PROMPT)441 442 443@spaces.GPU(duration=_generate_duration)444def generate_from_audio_example(445 mode: str = MODE_VOCALS,446 prompt: str = "",447 lyrics: str = "",448 duration: int = 60,449 bpm: int = 120,450 control_audio: str = None,451 progress=gr.Progress(track_tqdm=True),452) -> str:453 """Generate music conditioned on a control audio track."""454 return _run(455 mode, prompt, lyrics, duration, bpm, control_audio, 50, 4.0, 42, DEFAULT_NEGATIVE_PROMPT456 )457 458 459# ----------------------------------------------------------------------------------460# UI461# ----------------------------------------------------------------------------------462 463LYRICS_ZH = """[Intro]464 465清新海风里有我们旅途466漆黑海浪上有帆依呀远征467风暴的咆哮不把恐惧藏水手的胸襟468祈祷你像无畏的领航人469懂也不懂的守护航程470你在甲板上留下的刻痕471是我梦的风景472 473我要送你永不沉的信念474升起代表勇的黑旗幡475我要送你永不沉的誓言476锚连着锚把七海踏遍477你就是烈焰478你就是烈焰479我的血未寒480不灭的烽火燃在你身边481我的血未寒482 483怒海的狂涛总是起了又平484凝望指着罗盘的星辰485我要把酒全都灌进骨里486陪我一起远行"""487 488LYRICS_EN = """[Verse]489Neon rain on a midnight street490Every puddle keeps a second sky491I am counting every heartbeat492Waiting for the dark to say goodbye493 494[Chorus]495Hold the line, hold the line496We are running out of night497Hold the line, hold the line498Till the morning gets it right"""499 500PROMPT_ZH = "An explosive, high-energy pop-rock track with a strong anime theme song feel."501PROMPT_EN = "A dreamy synth-pop ballad with warm analog pads, a soft female voice and a steady 4/4 groove."502 503CSS = """504.small-note { font-size: 0.9em; opacity: 0.75; }505.dark .gradio-container { color: var(--body-text-color); }506"""507 508with gr.Blocks(title="DiffSynth-Music") as demo:509 gr.Markdown(510 """511 # 🎼 DiffSynth-Music — controllable music generation512 513 [DiffSynth-Music](https://huggingface.co/DiffSynth-Studio/DiffSynth-Music) adds514 **audio-conditioned KV-cache adapters** ("Diffusion Templates") on top of515 ACE-Step-1.5, so a reference audio track can steer generation without touching516 the backbone. Pick a control mode, write a prompt and lyrics, and generate.517 """518 )519 520 with gr.Row():521 with gr.Column(scale=1):522 mode = gr.Radio(MODES, value=MODE_TEXT, label="Control mode")523 prompt = gr.Textbox(524 label="Prompt",525 value=PROMPT_EN,526 lines=3,527 placeholder="A dreamy synth-pop ballad with warm analog pads…",528 )529 lyrics = gr.Textbox(530 label="Lyrics",531 value=LYRICS_EN,532 lines=8,533 placeholder="[Verse]\n…\n\n[Chorus]\n…",534 )535 duration = gr.Slider(536 20, 150, value=60, step=10, label="Duration (seconds)",537 info="For Vocals / Accompaniment / Prosody the control audio is cropped to this length.",538 )539 bpm = gr.Slider(60, 200, value=120, step=1, label="BPM (Beats mode)")540 541 with gr.Column(scale=1):542 control_audio = gr.Audio(543 label="Control audio (Vocals / Accompaniment / Prosody / Reference)",544 type="filepath",545 sources=["upload", "microphone"],546 )547 with gr.Accordion("Make a control signal from a full song", open=False):548 gr.Markdown(549 "Upload a complete song and let Demucs isolate the track the "550 "selected mode needs (vocals, accompaniment, or the prosody "551 "contour of the vocals). The result lands in the control-audio "552 "box above.",553 elem_classes="small-note",554 )555 source_audio = gr.Audio(label="Full song", type="filepath", sources=["upload"])556 prepare_btn = gr.Button("Extract control signal", variant="secondary")557 558 run_btn = gr.Button("🎵 Generate music", variant="primary", size="lg")559 output_audio = gr.Audio(label="Generated music", type="filepath")560 561 with gr.Accordion("Advanced options", open=False):562 with gr.Row():563 num_inference_steps = gr.Slider(20, 100, value=50, step=5, label="Inference steps")564 cfg_scale = gr.Slider(1.0, 8.0, value=4.0, step=0.5, label="CFG scale")565 seed = gr.Number(value=42, precision=0, label="Seed")566 negative_prompt = gr.Textbox(567 label="Negative prompt",568 value=DEFAULT_NEGATIVE_PROMPT,569 lines=3,570 )571 572 gen_inputs = [573 mode,574 prompt,575 lyrics,576 duration,577 bpm,578 control_audio,579 num_inference_steps,580 cfg_scale,581 seed,582 negative_prompt,583 ]584 585 run_btn.click(fn=generate, inputs=gen_inputs, outputs=output_audio)586 prepare_btn.click(587 fn=prepare_control,588 inputs=[mode, source_audio, duration],589 outputs=control_audio,590 )591 592 gr.Markdown("### Examples")593 gr.Markdown(594 "Text and beat driven generation — no control audio needed.",595 elem_classes="small-note",596 )597 gr.Examples(598 examples=[599 [MODE_TEXT, PROMPT_ZH, LYRICS_ZH, 60, 120],600 [MODE_TEXT, PROMPT_EN, LYRICS_EN, 60, 120],601 [MODE_BEATS, PROMPT_ZH, LYRICS_ZH, 60, 120],602 ],603 inputs=[mode, prompt, lyrics, duration, bpm],604 outputs=output_audio,605 fn=generate_from_text_example,606 cache_examples=True,607 cache_mode="lazy",608 label="Text / Beats",609 )610 gr.Markdown(611 "Audio-conditioned generation. The control clips below are the authors' own "612 "examples from the model card (Apache-2.0).",613 elem_classes="small-note",614 )615 gr.Examples(616 examples=[617 [MODE_VOCALS, PROMPT_ZH, "", 60, 120, "examples/audio_3_input.mp3"],618 [MODE_ACCOMP, PROMPT_ZH, LYRICS_ZH, 60, 120, "examples/audio_4_input.mp3"],619 [MODE_PROSODY, PROMPT_ZH, LYRICS_ZH, 60, 120, "examples/audio_5_input.mp3"],620 [MODE_REFERENCE, "Music", LYRICS_ZH, 60, 120, "examples/audio_reference.mp3"],621 ],622 inputs=[mode, prompt, lyrics, duration, bpm, control_audio],623 outputs=output_audio,624 fn=generate_from_audio_example,625 cache_examples=True,626 cache_mode="lazy",627 label="Vocals / Accompaniment / Prosody / Reference",628 )629 630 gr.Markdown(631 "Model: [DiffSynth-Studio/DiffSynth-Music](https://huggingface.co/DiffSynth-Studio/DiffSynth-Music) · "632 "Paper: [arXiv:2609.12774](https://huggingface.co/papers/2609.12774) · "633 "Code: [DiffSynth-Studio](https://github.com/modelscope/DiffSynth-Studio)",634 elem_classes="small-note",635 )636 637demo.launch(mcp_server=True, theme=gr.themes.Citrus(), css=CSS)638 