Upload folder using huggingface_hub
Browse files- README.md +53 -6
- app.py +227 -0
- composer.py +212 -0
- pipeline.py +201 -0
- requirements.txt +19 -0
- video_utils.py +185 -0
README.md
CHANGED
|
@@ -1,13 +1,60 @@
|
|
| 1 |
---
|
| 2 |
title: BFS Best Face Swap Video
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
colorTo: blue
|
| 6 |
sdk: gradio
|
| 7 |
-
sdk_version:
|
| 8 |
-
python_version: '3.13'
|
| 9 |
app_file: app.py
|
| 10 |
-
pinned:
|
|
|
|
|
|
|
|
|
|
| 11 |
---
|
| 12 |
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
title: BFS Best Face Swap Video
|
| 3 |
+
emoji: 🎭
|
| 4 |
+
colorFrom: purple
|
| 5 |
colorTo: blue
|
| 6 |
sdk: gradio
|
| 7 |
+
sdk_version: "5.0.0"
|
|
|
|
| 8 |
app_file: app.py
|
| 9 |
+
pinned: true
|
| 10 |
+
license: other
|
| 11 |
+
hardware: a100-large
|
| 12 |
+
suggested_hardware: a100-large
|
| 13 |
---
|
| 14 |
|
| 15 |
+
# BFS — Best Face Swap Video
|
| 16 |
+
|
| 17 |
+
Hugging Face Space for the **V3 persistent-template** head-swap workflow built on [LTX-2.3](https://huggingface.co/Lightricks/LTX-2.3).
|
| 18 |
+
|
| 19 |
+
## How it works
|
| 20 |
+
|
| 21 |
+
1. Upload a **guide video** (the body/performance you want to keep)
|
| 22 |
+
2. Upload a **reference face image** (the identity to transfer)
|
| 23 |
+
3. Write or paste a **text prompt** in the structured format below
|
| 24 |
+
4. Click **Generate**
|
| 25 |
+
|
| 26 |
+
The reference face is composited into a green chroma side-strip that persists across all frames during diffusion, giving the model continuous identity conditioning. The strip is cropped from the final output automatically.
|
| 27 |
+
|
| 28 |
+
### Prompt format
|
| 29 |
+
|
| 30 |
+
```
|
| 31 |
+
head_swap:
|
| 32 |
+
FACE: <detailed identity description from the reference image>
|
| 33 |
+
ACTION: <performance description from the guide video — body, clothing, movement only>
|
| 34 |
+
```
|
| 35 |
+
|
| 36 |
+
**Example:**
|
| 37 |
+
```
|
| 38 |
+
head_swap:
|
| 39 |
+
FACE: Female, fair skin, approximately 25-30 years old, long wavy auburn hair,
|
| 40 |
+
bright green eyes, no facial hair, smooth skin, small silver earrings.
|
| 41 |
+
|
| 42 |
+
ACTION: A person in a grey hoodie walks toward the camera in an indoor hallway,
|
| 43 |
+
holds a coffee cup in their right hand, glances to the left.
|
| 44 |
+
```
|
| 45 |
+
|
| 46 |
+
## Models used
|
| 47 |
+
|
| 48 |
+
| Component | Source |
|
| 49 |
+
|-----------|--------|
|
| 50 |
+
| Transformer (22B, FP8) | [Kijai/LTX2.3_comfy](https://huggingface.co/Kijai/LTX2.3_comfy) |
|
| 51 |
+
| Video VAE | [Kijai/LTX2.3_comfy](https://huggingface.co/Kijai/LTX2.3_comfy) |
|
| 52 |
+
| Text encoder (Gemma 3 12B, FP8) | [Comfy-Org/ltx-2](https://huggingface.co/Comfy-Org/ltx-2) |
|
| 53 |
+
| Motion LoRA | [Kijai/LTX2.3_comfy](https://huggingface.co/Kijai/LTX2.3_comfy) |
|
| 54 |
+
| BFS head-swap LoRA | [Alissonerdx/BFS-Best-Face-Swap-Video](https://huggingface.co/Alissonerdx/BFS-Best-Face-Swap-Video) |
|
| 55 |
+
|
| 56 |
+
## Ethical use
|
| 57 |
+
|
| 58 |
+
This tool is intended for **filmmakers, VFX artists, and researchers**.
|
| 59 |
+
You must have explicit consent from any person whose likeness you process.
|
| 60 |
+
See the [full model card](https://huggingface.co/Alissonerdx/BFS-Best-Face-Swap-Video) for the complete terms.
|
app.py
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
BFS — Best Face Swap Video · Hugging Face Space
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
from __future__ import annotations
|
| 6 |
+
|
| 7 |
+
import os
|
| 8 |
+
import tempfile
|
| 9 |
+
|
| 10 |
+
import gradio as gr
|
| 11 |
+
import numpy as np
|
| 12 |
+
from PIL import Image
|
| 13 |
+
|
| 14 |
+
from composer import compose_frames, crop_reserved_region
|
| 15 |
+
from video_utils import (
|
| 16 |
+
compute_target_size,
|
| 17 |
+
extract_audio,
|
| 18 |
+
frames_for_duration,
|
| 19 |
+
load_video_frames,
|
| 20 |
+
resize_frames,
|
| 21 |
+
save_video,
|
| 22 |
+
)
|
| 23 |
+
|
| 24 |
+
# ---------------------------------------------------------------------------
|
| 25 |
+
# GPU decorator — no-op locally, activates the ZeroGPU grant on HF Spaces
|
| 26 |
+
# ---------------------------------------------------------------------------
|
| 27 |
+
try:
|
| 28 |
+
import spaces
|
| 29 |
+
GPU = spaces.GPU
|
| 30 |
+
except ImportError:
|
| 31 |
+
def GPU(fn=None, **kwargs): # type: ignore
|
| 32 |
+
return fn if fn is not None else lambda f: f
|
| 33 |
+
|
| 34 |
+
# ---------------------------------------------------------------------------
|
| 35 |
+
# Global model state (loaded once per worker)
|
| 36 |
+
# ---------------------------------------------------------------------------
|
| 37 |
+
_pipeline_state: dict | None = None
|
| 38 |
+
|
| 39 |
+
REGION_SIZE = 256
|
| 40 |
+
DEFAULT_FPS = 24.0
|
| 41 |
+
DEFAULT_DURATION = 5.0
|
| 42 |
+
DEFAULT_RESOLUTION = 768
|
| 43 |
+
|
| 44 |
+
# ---------------------------------------------------------------------------
|
| 45 |
+
# Core processing function
|
| 46 |
+
# ---------------------------------------------------------------------------
|
| 47 |
+
|
| 48 |
+
@GPU(duration=300)
|
| 49 |
+
def generate(
|
| 50 |
+
guide_video_path: str,
|
| 51 |
+
face_image: Image.Image,
|
| 52 |
+
prompt: str,
|
| 53 |
+
duration: float,
|
| 54 |
+
fps: float,
|
| 55 |
+
lora_strength: float,
|
| 56 |
+
seed: int,
|
| 57 |
+
hf_token: str = "",
|
| 58 |
+
progress: gr.Progress = gr.Progress(track_tqdm=True),
|
| 59 |
+
) -> tuple[str, str]:
|
| 60 |
+
"""
|
| 61 |
+
Full head-swap pipeline:
|
| 62 |
+
1. Load + resize guide video frames
|
| 63 |
+
2. Compose chroma face strip (ReservedRegionFrameComposer)
|
| 64 |
+
3. Run LTX-2.3 diffusion
|
| 65 |
+
4. Crop face strip from output
|
| 66 |
+
5. Mux original audio back in
|
| 67 |
+
|
| 68 |
+
Returns (output_video_path, status_message).
|
| 69 |
+
"""
|
| 70 |
+
global _pipeline_state
|
| 71 |
+
|
| 72 |
+
# ---- validate inputs early ----
|
| 73 |
+
if guide_video_path is None:
|
| 74 |
+
return "", "Please upload a guide video."
|
| 75 |
+
if face_image is None:
|
| 76 |
+
return "", "Please upload a reference face image."
|
| 77 |
+
if not prompt.strip():
|
| 78 |
+
return "", "Please enter a text prompt."
|
| 79 |
+
|
| 80 |
+
# ---- lazy model load ----
|
| 81 |
+
if _pipeline_state is None:
|
| 82 |
+
from pipeline import load_pipeline
|
| 83 |
+
progress(0, desc="Loading models (first run only — ~5 min)…")
|
| 84 |
+
_pipeline_state = load_pipeline(
|
| 85 |
+
token=hf_token.strip() or None,
|
| 86 |
+
progress_cb=lambda msg: progress(0, desc=msg),
|
| 87 |
+
)
|
| 88 |
+
|
| 89 |
+
progress(0.05, desc="Loading guide video…")
|
| 90 |
+
frames, source_fps = load_video_frames(guide_video_path)
|
| 91 |
+
if len(frames) == 0:
|
| 92 |
+
return "", "Could not read frames from the guide video."
|
| 93 |
+
|
| 94 |
+
# ---- extract audio before we do anything else ----
|
| 95 |
+
audio_tmp = tempfile.mktemp(suffix=".wav")
|
| 96 |
+
has_audio = extract_audio(guide_video_path, audio_tmp)
|
| 97 |
+
|
| 98 |
+
# ---- resize frames ----
|
| 99 |
+
progress(0.10, desc="Resizing frames…")
|
| 100 |
+
orig_h, orig_w = frames.shape[1], frames.shape[2]
|
| 101 |
+
target_w, target_h = compute_target_size(orig_w, orig_h, DEFAULT_RESOLUTION)
|
| 102 |
+
frames = resize_frames(frames, target_w, target_h)
|
| 103 |
+
|
| 104 |
+
# ---- trim / pad to requested duration ----
|
| 105 |
+
n_frames = frames_for_duration(fps, duration)
|
| 106 |
+
if len(frames) >= n_frames:
|
| 107 |
+
frames = frames[:n_frames]
|
| 108 |
+
else:
|
| 109 |
+
# loop last frame
|
| 110 |
+
pad = np.stack([frames[-1]] * (n_frames - len(frames)))
|
| 111 |
+
frames = np.concatenate([frames, pad], axis=0)
|
| 112 |
+
|
| 113 |
+
# ---- compose chroma strip ----
|
| 114 |
+
progress(0.15, desc="Compositing reference face strip…")
|
| 115 |
+
composed = compose_frames(
|
| 116 |
+
frames,
|
| 117 |
+
face_image,
|
| 118 |
+
region_position="left",
|
| 119 |
+
region_size_px=REGION_SIZE,
|
| 120 |
+
)
|
| 121 |
+
|
| 122 |
+
# ---- run diffusion ----
|
| 123 |
+
progress(0.20, desc="Running LTX-2.3 diffusion…")
|
| 124 |
+
from pipeline import run_inference
|
| 125 |
+
generated = run_inference(
|
| 126 |
+
_pipeline_state,
|
| 127 |
+
composed,
|
| 128 |
+
prompt=prompt,
|
| 129 |
+
fps=fps,
|
| 130 |
+
lora_strength=lora_strength,
|
| 131 |
+
seed=int(seed),
|
| 132 |
+
progress_cb=lambda msg: progress(0.20, desc=msg),
|
| 133 |
+
)
|
| 134 |
+
|
| 135 |
+
# ---- crop face strip from output ----
|
| 136 |
+
progress(0.90, desc="Cropping reserved region…")
|
| 137 |
+
cropped = crop_reserved_region(
|
| 138 |
+
generated,
|
| 139 |
+
region_position="left",
|
| 140 |
+
region_size_px=REGION_SIZE,
|
| 141 |
+
output_size=(target_w, target_h),
|
| 142 |
+
)
|
| 143 |
+
|
| 144 |
+
# ---- save output video with audio ----
|
| 145 |
+
progress(0.95, desc="Encoding output video…")
|
| 146 |
+
out_path = tempfile.mktemp(suffix=".mp4")
|
| 147 |
+
save_video(
|
| 148 |
+
cropped,
|
| 149 |
+
fps=fps,
|
| 150 |
+
output_path=out_path,
|
| 151 |
+
audio_path=audio_tmp if has_audio else None,
|
| 152 |
+
audio_duration=duration,
|
| 153 |
+
)
|
| 154 |
+
|
| 155 |
+
progress(1.0, desc="Done.")
|
| 156 |
+
return out_path, "Generation complete."
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
# ---------------------------------------------------------------------------
|
| 160 |
+
# Gradio UI
|
| 161 |
+
# ---------------------------------------------------------------------------
|
| 162 |
+
|
| 163 |
+
DESCRIPTION = """
|
| 164 |
+
# BFS — Best Face Swap Video
|
| 165 |
+
|
| 166 |
+
Swap the identity in any video using the **V3 persistent-template** technique.
|
| 167 |
+
The reference face is placed in a green chroma side-strip that persists across
|
| 168 |
+
all frames, giving the model continuous identity conditioning throughout generation.
|
| 169 |
+
|
| 170 |
+
**Prompt format:**
|
| 171 |
+
```
|
| 172 |
+
head_swap:
|
| 173 |
+
FACE: Female, fair skin, ~25 years old, long wavy auburn hair, green eyes…
|
| 174 |
+
ACTION: A person in a grey hoodie walks toward the camera indoors…
|
| 175 |
+
```
|
| 176 |
+
"""
|
| 177 |
+
|
| 178 |
+
EXAMPLES: list[list] = [
|
| 179 |
+
# [guide_video, face_image, prompt, duration, fps, lora_strength, seed]
|
| 180 |
+
]
|
| 181 |
+
|
| 182 |
+
with gr.Blocks(title="BFS — Best Face Swap Video") as demo:
|
| 183 |
+
gr.Markdown(DESCRIPTION)
|
| 184 |
+
|
| 185 |
+
with gr.Row():
|
| 186 |
+
with gr.Column(scale=1):
|
| 187 |
+
guide_video = gr.Video(label="Guide Video", sources=["upload"])
|
| 188 |
+
face_image = gr.Image(label="Reference Face", type="pil", sources=["upload"])
|
| 189 |
+
prompt = gr.Textbox(
|
| 190 |
+
label="Text Prompt",
|
| 191 |
+
placeholder="head_swap:\nFACE: ...\nACTION: ...",
|
| 192 |
+
lines=6,
|
| 193 |
+
)
|
| 194 |
+
|
| 195 |
+
with gr.Accordion("Parameters", open=False):
|
| 196 |
+
duration = gr.Slider(1, 15, value=DEFAULT_DURATION, step=0.5, label="Duration (seconds)")
|
| 197 |
+
fps = gr.Slider(8, 30, value=DEFAULT_FPS, step=1.0, label="FPS")
|
| 198 |
+
lora_strength = gr.Slider(0.5, 1.5, value=1.2, step=0.05, label="Face Swap Strength")
|
| 199 |
+
seed = gr.Number(value=42, label="Seed", precision=0)
|
| 200 |
+
hf_token = gr.Textbox(
|
| 201 |
+
label="HF Token (optional)",
|
| 202 |
+
type="password",
|
| 203 |
+
placeholder="hf_… — only needed if the Space owner's token has no access to a gated model",
|
| 204 |
+
)
|
| 205 |
+
|
| 206 |
+
run_btn = gr.Button("Generate", variant="primary")
|
| 207 |
+
|
| 208 |
+
with gr.Column(scale=1):
|
| 209 |
+
output_video = gr.Video(label="Result", interactive=False)
|
| 210 |
+
status_text = gr.Textbox(label="Status", interactive=False)
|
| 211 |
+
|
| 212 |
+
run_btn.click(
|
| 213 |
+
fn=generate,
|
| 214 |
+
inputs=[guide_video, face_image, prompt, duration, fps, lora_strength, seed, hf_token],
|
| 215 |
+
outputs=[output_video, status_text],
|
| 216 |
+
)
|
| 217 |
+
|
| 218 |
+
gr.Markdown("""
|
| 219 |
+
---
|
| 220 |
+
**Hardware:** A100 80 GB GPU required.
|
| 221 |
+
**Model:** [Alissonerdx/BFS-Best-Face-Swap-Video](https://huggingface.co/Alissonerdx/BFS-Best-Face-Swap-Video) · Built on [LTX-2.3](https://huggingface.co/Lightricks/LTX-2.3)
|
| 222 |
+
**License:** For research and professional VFX use only. You must have explicit consent for any likeness you process.
|
| 223 |
+
""")
|
| 224 |
+
|
| 225 |
+
|
| 226 |
+
if __name__ == "__main__":
|
| 227 |
+
demo.launch()
|
composer.py
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
ReservedRegionFrameComposer — pure-Python port of the ComfyUI-BFSNodes node.
|
| 3 |
+
|
| 4 |
+
Adds a chroma-key side strip to every frame, placing the reference face inside
|
| 5 |
+
it so the LTX-2.3 model can use it as a persistent identity template throughout
|
| 6 |
+
generation. After generation, call crop_reserved_region() to remove the strip.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
import math
|
| 10 |
+
from typing import Literal
|
| 11 |
+
|
| 12 |
+
import numpy as np
|
| 13 |
+
from PIL import Image
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
# ---------------------------------------------------------------------------
|
| 17 |
+
# Low-level helpers (ported from ComfyUI-BFSNodes/util.py)
|
| 18 |
+
# ---------------------------------------------------------------------------
|
| 19 |
+
|
| 20 |
+
def _fit_inside(src_w: int, src_h: int, max_w: int, max_h: int) -> tuple[int, int]:
|
| 21 |
+
if src_w <= 0 or src_h <= 0:
|
| 22 |
+
return 1, 1
|
| 23 |
+
scale = min(max_w / src_w, max_h / src_h)
|
| 24 |
+
return max(1, int(round(src_w * scale))), max(1, int(round(src_h * scale)))
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def _aligned_offset(container: int, content: int, align: str) -> int:
|
| 28 |
+
if align == "start":
|
| 29 |
+
return 0
|
| 30 |
+
if align == "end":
|
| 31 |
+
return max(0, container - content)
|
| 32 |
+
return max(0, (container - content) // 2)
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def _paste_with_alpha(dst: Image.Image, src: Image.Image, xy: tuple[int, int]) -> None:
|
| 36 |
+
if src.mode == "RGBA":
|
| 37 |
+
dst.paste(src, xy, src.split()[-1])
|
| 38 |
+
else:
|
| 39 |
+
dst.paste(src, xy)
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def _add_padding(img: Image.Image, pad: int = 16) -> Image.Image:
|
| 43 |
+
canvas = Image.new("RGBA", (img.width + pad * 2, img.height + pad * 2), (255, 255, 255, 255))
|
| 44 |
+
canvas.paste(img.convert("RGBA"), (pad, pad))
|
| 45 |
+
return canvas
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
# ---------------------------------------------------------------------------
|
| 49 |
+
# Face layout helpers
|
| 50 |
+
# ---------------------------------------------------------------------------
|
| 51 |
+
|
| 52 |
+
def _layout_faces(
|
| 53 |
+
faces: list[Image.Image],
|
| 54 |
+
region_w: int,
|
| 55 |
+
region_h: int,
|
| 56 |
+
scale_pct: float,
|
| 57 |
+
padding: int,
|
| 58 |
+
gap: int,
|
| 59 |
+
stack: str,
|
| 60 |
+
align_main: str,
|
| 61 |
+
align_cross: str,
|
| 62 |
+
) -> Image.Image:
|
| 63 |
+
"""Composite all faces into a single region_w x region_h RGBA tile."""
|
| 64 |
+
canvas = Image.new("RGBA", (region_w, region_h), (0, 0, 0, 0))
|
| 65 |
+
if not faces:
|
| 66 |
+
return canvas
|
| 67 |
+
|
| 68 |
+
n = len(faces)
|
| 69 |
+
avail_w = region_w - 2 * padding
|
| 70 |
+
avail_h = region_h - 2 * padding
|
| 71 |
+
|
| 72 |
+
# Determine grid shape
|
| 73 |
+
if stack == "horizontal" or (stack == "auto" and region_w >= region_h):
|
| 74 |
+
cols, rows = n, 1
|
| 75 |
+
elif stack == "vertical" or (stack == "auto" and region_h > region_w):
|
| 76 |
+
cols, rows = 1, n
|
| 77 |
+
else: # grid
|
| 78 |
+
cols = math.ceil(math.sqrt(n))
|
| 79 |
+
rows = math.ceil(n / cols)
|
| 80 |
+
|
| 81 |
+
cell_w = max(1, (avail_w - gap * (cols - 1)) // cols)
|
| 82 |
+
cell_h = max(1, (avail_h - gap * (rows - 1)) // rows)
|
| 83 |
+
|
| 84 |
+
for i, face in enumerate(faces):
|
| 85 |
+
col, row = i % cols, i // cols
|
| 86 |
+
fw, fh = _fit_inside(face.width, face.height, int(cell_w * scale_pct / 100), int(cell_h * scale_pct / 100))
|
| 87 |
+
resized = face.resize((fw, fh), Image.LANCZOS)
|
| 88 |
+
|
| 89 |
+
cx = padding + col * (cell_w + gap) + _aligned_offset(cell_w, fw, align_cross)
|
| 90 |
+
cy = padding + row * (cell_h + gap) + _aligned_offset(cell_h, fh, align_main)
|
| 91 |
+
_paste_with_alpha(canvas, resized, (cx, cy))
|
| 92 |
+
|
| 93 |
+
return canvas
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
# ---------------------------------------------------------------------------
|
| 97 |
+
# Public API
|
| 98 |
+
# ---------------------------------------------------------------------------
|
| 99 |
+
|
| 100 |
+
def compose_frames(
|
| 101 |
+
frames: np.ndarray,
|
| 102 |
+
face_image: Image.Image,
|
| 103 |
+
region_position: Literal["left", "right", "top", "bottom"] = "left",
|
| 104 |
+
region_size_px: int = 256,
|
| 105 |
+
face_scale_pct: float = 100.0,
|
| 106 |
+
face_padding_px: int = 12,
|
| 107 |
+
face_gap_px: int = 12,
|
| 108 |
+
face_align_main: str = "center",
|
| 109 |
+
face_align_cross: str = "center",
|
| 110 |
+
chroma_rgb: tuple[int, int, int] = (0, 255, 0),
|
| 111 |
+
) -> np.ndarray:
|
| 112 |
+
"""
|
| 113 |
+
Args:
|
| 114 |
+
frames: uint8 numpy array [N, H, W, 3]
|
| 115 |
+
face_image: PIL Image — the reference face
|
| 116 |
+
region_*: strip geometry and face placement
|
| 117 |
+
chroma_rgb: background colour of the reserved strip
|
| 118 |
+
|
| 119 |
+
Returns:
|
| 120 |
+
uint8 numpy array [N, H, W, 3] with the chroma strip composited in.
|
| 121 |
+
The original content is shrunk to fill the remaining area; total
|
| 122 |
+
resolution is unchanged (matches the input WxH).
|
| 123 |
+
"""
|
| 124 |
+
N, H, W, C = frames.shape
|
| 125 |
+
face_pil = _add_padding(face_image.convert("RGBA"), 16)
|
| 126 |
+
|
| 127 |
+
vertical = region_position in ("top", "bottom")
|
| 128 |
+
if vertical:
|
| 129 |
+
content_h = H - region_size_px
|
| 130 |
+
content_w = W
|
| 131 |
+
region_w, region_h = W, region_size_px
|
| 132 |
+
else:
|
| 133 |
+
content_w = W - region_size_px
|
| 134 |
+
content_h = H
|
| 135 |
+
region_w, region_h = region_size_px, H
|
| 136 |
+
|
| 137 |
+
# Pre-render the face tile (same for every frame)
|
| 138 |
+
face_tile = _layout_faces(
|
| 139 |
+
[face_pil],
|
| 140 |
+
region_w, region_h,
|
| 141 |
+
face_scale_pct, face_padding_px, face_gap_px,
|
| 142 |
+
"auto", face_align_main, face_align_cross,
|
| 143 |
+
)
|
| 144 |
+
chroma_bg = Image.new("RGB", (region_w, region_h), chroma_rgb)
|
| 145 |
+
chroma_bg.paste(face_tile, (0, 0), face_tile) # alpha-composite face onto chroma
|
| 146 |
+
|
| 147 |
+
out = np.empty_like(frames)
|
| 148 |
+
|
| 149 |
+
for i in range(N):
|
| 150 |
+
frame_pil = Image.fromarray(frames[i], "RGB")
|
| 151 |
+
|
| 152 |
+
# Resize original content to fit the non-reserved area
|
| 153 |
+
content_pil = frame_pil.resize((content_w, content_h), Image.LANCZOS)
|
| 154 |
+
|
| 155 |
+
# Build full-size canvas
|
| 156 |
+
canvas = Image.new("RGB", (W, H))
|
| 157 |
+
if region_position == "left":
|
| 158 |
+
canvas.paste(chroma_bg, (0, 0))
|
| 159 |
+
canvas.paste(content_pil, (region_size_px, 0))
|
| 160 |
+
elif region_position == "right":
|
| 161 |
+
canvas.paste(content_pil, (0, 0))
|
| 162 |
+
canvas.paste(chroma_bg, (content_w, 0))
|
| 163 |
+
elif region_position == "top":
|
| 164 |
+
canvas.paste(chroma_bg, (0, 0))
|
| 165 |
+
canvas.paste(content_pil, (0, region_size_px))
|
| 166 |
+
else: # bottom
|
| 167 |
+
canvas.paste(content_pil, (0, 0))
|
| 168 |
+
canvas.paste(chroma_bg, (0, content_h))
|
| 169 |
+
|
| 170 |
+
out[i] = np.array(canvas)
|
| 171 |
+
|
| 172 |
+
return out
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
def crop_reserved_region(
|
| 176 |
+
frames: np.ndarray,
|
| 177 |
+
region_position: Literal["left", "right", "top", "bottom"] = "left",
|
| 178 |
+
region_size_px: int = 256,
|
| 179 |
+
output_size: tuple[int, int] | None = None,
|
| 180 |
+
) -> np.ndarray:
|
| 181 |
+
"""
|
| 182 |
+
Remove the reserved strip from generated frames and resize back to
|
| 183 |
+
output_size (W, H). If output_size is None, resize to fill the full
|
| 184 |
+
original frame dimensions.
|
| 185 |
+
|
| 186 |
+
Args:
|
| 187 |
+
frames: uint8 [N, H, W, 3]
|
| 188 |
+
output_size: (W, H) to resize to after cropping, or None for original size
|
| 189 |
+
|
| 190 |
+
Returns:
|
| 191 |
+
uint8 [N, out_H, out_W, 3]
|
| 192 |
+
"""
|
| 193 |
+
N, H, W, _ = frames.shape
|
| 194 |
+
target_w = output_size[0] if output_size else W
|
| 195 |
+
target_h = output_size[1] if output_size else H
|
| 196 |
+
|
| 197 |
+
if region_position == "left":
|
| 198 |
+
crop = frames[:, :, region_size_px:, :]
|
| 199 |
+
elif region_position == "right":
|
| 200 |
+
crop = frames[:, :, :W - region_size_px, :]
|
| 201 |
+
elif region_position == "top":
|
| 202 |
+
crop = frames[:, region_size_px:, :, :]
|
| 203 |
+
else: # bottom
|
| 204 |
+
crop = frames[:, :H - region_size_px, :, :]
|
| 205 |
+
|
| 206 |
+
if crop.shape[2] == target_w and crop.shape[1] == target_h:
|
| 207 |
+
return crop
|
| 208 |
+
|
| 209 |
+
out = np.empty((N, target_h, target_w, 3), dtype=np.uint8)
|
| 210 |
+
for i in range(N):
|
| 211 |
+
out[i] = np.array(Image.fromarray(crop[i]).resize((target_w, target_h), Image.LANCZOS))
|
| 212 |
+
return out
|
pipeline.py
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
LTX-2.3 inference pipeline for BFS head-swap.
|
| 3 |
+
|
| 4 |
+
Model sources:
|
| 5 |
+
Transformer : Kijai/LTX2.3_comfy — diffusion_models/ltx-2.3-22b-distilled_transformer_only_fp8_input_scaled_v3.safetensors
|
| 6 |
+
Video VAE : Kijai/LTX2.3_comfy — vae/LTX23_video_vae_bf16.safetensors
|
| 7 |
+
Pipeline cfg: Lightricks/LTX-Video (text encoder, scheduler, tokenizer via from_pretrained)
|
| 8 |
+
LoRA motion : Kijai/LTX2.3_comfy — loras/ltx-2.3-22b-distilled-lora-dynamic_fro09_avg_rank_105_bf16.safetensors
|
| 9 |
+
LoRA BFS : Alissonerdx/BFS-Best-Face-Swap-Video — ltx-2.3/head_swap_v3_rank_adaptive_fro_098.safetensors
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
import gc
|
| 15 |
+
import os
|
| 16 |
+
import tempfile
|
| 17 |
+
from pathlib import Path
|
| 18 |
+
from typing import Callable
|
| 19 |
+
|
| 20 |
+
import numpy as np
|
| 21 |
+
import torch
|
| 22 |
+
from huggingface_hub import hf_hub_download
|
| 23 |
+
from PIL import Image
|
| 24 |
+
|
| 25 |
+
# ---------------------------------------------------------------------------
|
| 26 |
+
# Model file specs
|
| 27 |
+
# ---------------------------------------------------------------------------
|
| 28 |
+
|
| 29 |
+
_HF_CACHE = os.environ.get("HF_HOME", os.path.expanduser("~/.cache/huggingface"))
|
| 30 |
+
|
| 31 |
+
MODELS = {
|
| 32 |
+
"transformer": ("Kijai/LTX2.3_comfy", "diffusion_models/ltx-2.3-22b-distilled_transformer_only_fp8_input_scaled_v3.safetensors"),
|
| 33 |
+
"video_vae": ("Kijai/LTX2.3_comfy", "vae/LTX23_video_vae_bf16.safetensors"),
|
| 34 |
+
"lora_motion": ("Kijai/LTX2.3_comfy", "loras/ltx-2.3-22b-distilled-lora-dynamic_fro09_avg_rank_105_bf16.safetensors"),
|
| 35 |
+
"lora_bfs": ("Alissonerdx/BFS-Best-Face-Swap-Video", "ltx-2.3/head_swap_v3_rank_adaptive_fro_098.safetensors"),
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
# Distilled sigmas from the workflow (BasicScheduler bong_tangent, 8 steps)
|
| 39 |
+
DISTILLED_SIGMAS = [1.0, 0.99375, 0.9875, 0.98125, 0.975, 0.909375, 0.725, 0.421875, 0.0]
|
| 40 |
+
|
| 41 |
+
NEGATIVE_PROMPT = (
|
| 42 |
+
"pc game, console game, video game, cartoon, childish, ugly, "
|
| 43 |
+
"artifacts, low resolution, blurry, jagged edges"
|
| 44 |
+
)
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
# ---------------------------------------------------------------------------
|
| 48 |
+
# Model download helpers
|
| 49 |
+
# ---------------------------------------------------------------------------
|
| 50 |
+
|
| 51 |
+
def _download(key: str, token: str | None = None) -> str:
|
| 52 |
+
repo, filename = MODELS[key]
|
| 53 |
+
return hf_hub_download(repo_id=repo, filename=filename, token=token)
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def _maybe_download_all(
|
| 57 |
+
token: str | None = None,
|
| 58 |
+
progress_cb: Callable[[str], None] | None = None,
|
| 59 |
+
) -> dict[str, str]:
|
| 60 |
+
paths = {}
|
| 61 |
+
for key in MODELS:
|
| 62 |
+
if progress_cb:
|
| 63 |
+
progress_cb(f"Downloading {key}…")
|
| 64 |
+
paths[key] = _download(key, token=token)
|
| 65 |
+
return paths
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
# ---------------------------------------------------------------------------
|
| 69 |
+
# Pipeline construction
|
| 70 |
+
# ---------------------------------------------------------------------------
|
| 71 |
+
|
| 72 |
+
def load_pipeline(
|
| 73 |
+
device: str = "cuda",
|
| 74 |
+
token: str | None = None,
|
| 75 |
+
progress_cb: Callable[[str], None] | None = None,
|
| 76 |
+
) -> dict:
|
| 77 |
+
"""
|
| 78 |
+
Download (if needed) and load all model components.
|
| 79 |
+
Returns a dict of loaded objects cached for reuse.
|
| 80 |
+
|
| 81 |
+
LoRAs are loaded but NOT fused so that lora_strength can be adjusted
|
| 82 |
+
per-request in run_inference() via set_adapters().
|
| 83 |
+
"""
|
| 84 |
+
from diffusers import (
|
| 85 |
+
AutoencoderKLLTXVideo,
|
| 86 |
+
LTXImageToVideoPipeline,
|
| 87 |
+
LTXVideoTransformer3DModel,
|
| 88 |
+
)
|
| 89 |
+
|
| 90 |
+
effective_token = token or os.environ.get("HF_TOKEN")
|
| 91 |
+
paths = _maybe_download_all(token=effective_token, progress_cb=progress_cb)
|
| 92 |
+
|
| 93 |
+
if progress_cb:
|
| 94 |
+
progress_cb("Loading transformer…")
|
| 95 |
+
# fp8_e4m3fn weights are loaded into bfloat16 compute precision to maximise
|
| 96 |
+
# diffusers compatibility; the weight file on disk is fp8-quantised.
|
| 97 |
+
transformer = LTXVideoTransformer3DModel.from_single_file(
|
| 98 |
+
paths["transformer"],
|
| 99 |
+
torch_dtype=torch.bfloat16,
|
| 100 |
+
).to(device)
|
| 101 |
+
|
| 102 |
+
if progress_cb:
|
| 103 |
+
progress_cb("Loading video VAE…")
|
| 104 |
+
video_vae = AutoencoderKLLTXVideo.from_single_file(
|
| 105 |
+
paths["video_vae"],
|
| 106 |
+
torch_dtype=torch.bfloat16,
|
| 107 |
+
).to(device)
|
| 108 |
+
|
| 109 |
+
if progress_cb:
|
| 110 |
+
progress_cb("Building pipeline…")
|
| 111 |
+
pipe = LTXImageToVideoPipeline.from_pretrained(
|
| 112 |
+
"Lightricks/LTX-Video",
|
| 113 |
+
transformer=transformer,
|
| 114 |
+
vae=video_vae,
|
| 115 |
+
torch_dtype=torch.bfloat16,
|
| 116 |
+
token=effective_token,
|
| 117 |
+
).to(device)
|
| 118 |
+
|
| 119 |
+
if progress_cb:
|
| 120 |
+
progress_cb("Loading LoRAs…")
|
| 121 |
+
pipe.load_lora_weights(paths["lora_motion"], adapter_name="motion")
|
| 122 |
+
pipe.load_lora_weights(paths["lora_bfs"], adapter_name="bfs")
|
| 123 |
+
# Default weights — overridden per-request in run_inference()
|
| 124 |
+
pipe.set_adapters(["motion", "bfs"], adapter_weights=[1.0, 1.0])
|
| 125 |
+
|
| 126 |
+
return {"pipe": pipe, "device": device}
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
# ---------------------------------------------------------------------------
|
| 130 |
+
# Inference
|
| 131 |
+
# ---------------------------------------------------------------------------
|
| 132 |
+
|
| 133 |
+
def run_inference(
|
| 134 |
+
state: dict,
|
| 135 |
+
composed_frames: np.ndarray,
|
| 136 |
+
prompt: str,
|
| 137 |
+
fps: float = 24.0,
|
| 138 |
+
lora_strength: float = 1.0,
|
| 139 |
+
seed: int = 42,
|
| 140 |
+
num_inference_steps: int = 8,
|
| 141 |
+
guidance_scale: float = 1.0,
|
| 142 |
+
region_size_px: int = 256,
|
| 143 |
+
progress_cb: Callable[[str], None] | None = None,
|
| 144 |
+
) -> np.ndarray:
|
| 145 |
+
"""
|
| 146 |
+
Run LTX-2.3 image-to-video inference on the composed frames.
|
| 147 |
+
|
| 148 |
+
Args:
|
| 149 |
+
state: dict returned by load_pipeline()
|
| 150 |
+
composed_frames: uint8 [N, H, W, 3] with chroma strip added
|
| 151 |
+
prompt: text prompt (head_swap: format)
|
| 152 |
+
fps: target frame rate
|
| 153 |
+
lora_strength: multiplier applied on top of default LoRA weights
|
| 154 |
+
seed: RNG seed
|
| 155 |
+
region_size_px: strip width to set guide frame crop
|
| 156 |
+
|
| 157 |
+
Returns:
|
| 158 |
+
uint8 [N, H, W, 3] — generated frames (strip still present, call
|
| 159 |
+
composer.crop_reserved_region() to remove it)
|
| 160 |
+
"""
|
| 161 |
+
pipe = state["pipe"]
|
| 162 |
+
device = state["device"]
|
| 163 |
+
|
| 164 |
+
N, H, W, _ = composed_frames.shape
|
| 165 |
+
first_frame = Image.fromarray(composed_frames[0])
|
| 166 |
+
|
| 167 |
+
# Always set adapter weights — LoRAs are not fused, so strength is dynamic.
|
| 168 |
+
# motion LoRA is kept at 1.0; only the BFS identity LoRA is user-adjustable.
|
| 169 |
+
pipe.set_adapters(["motion", "bfs"], adapter_weights=[1.0, lora_strength])
|
| 170 |
+
|
| 171 |
+
generator = torch.Generator(device=device).manual_seed(seed)
|
| 172 |
+
|
| 173 |
+
if progress_cb:
|
| 174 |
+
progress_cb("Running diffusion…")
|
| 175 |
+
|
| 176 |
+
with torch.inference_mode():
|
| 177 |
+
result = pipe(
|
| 178 |
+
image=first_frame,
|
| 179 |
+
prompt=prompt,
|
| 180 |
+
negative_prompt=NEGATIVE_PROMPT,
|
| 181 |
+
width=W,
|
| 182 |
+
height=H,
|
| 183 |
+
num_frames=N,
|
| 184 |
+
frame_rate=fps,
|
| 185 |
+
guidance_scale=guidance_scale,
|
| 186 |
+
num_inference_steps=num_inference_steps,
|
| 187 |
+
generator=generator,
|
| 188 |
+
decode_timestep=0.05,
|
| 189 |
+
decode_noise_scale=0.025,
|
| 190 |
+
output_type="pt",
|
| 191 |
+
)
|
| 192 |
+
|
| 193 |
+
# result.frames is [1, N, C, H, W] float in [0,1]
|
| 194 |
+
frames_pt = result.frames[0] # [N, C, H, W]
|
| 195 |
+
frames_np = (frames_pt.permute(0, 2, 3, 1).cpu().float().numpy() * 255).clip(0, 255).astype(np.uint8)
|
| 196 |
+
|
| 197 |
+
gc.collect()
|
| 198 |
+
if torch.cuda.is_available():
|
| 199 |
+
torch.cuda.empty_cache()
|
| 200 |
+
|
| 201 |
+
return frames_np
|
requirements.txt
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
torch>=2.4.0
|
| 2 |
+
torchvision
|
| 3 |
+
torchaudio
|
| 4 |
+
diffusers>=0.32.0
|
| 5 |
+
transformers>=4.50.0
|
| 6 |
+
accelerate>=1.0.0
|
| 7 |
+
huggingface_hub>=0.26.0
|
| 8 |
+
safetensors>=0.4.0
|
| 9 |
+
gradio>=5.0.0
|
| 10 |
+
spaces
|
| 11 |
+
opencv-python-headless
|
| 12 |
+
pillow>=10.0.0
|
| 13 |
+
numpy>=1.24.0
|
| 14 |
+
ffmpeg-python
|
| 15 |
+
imageio
|
| 16 |
+
imageio-ffmpeg
|
| 17 |
+
decord
|
| 18 |
+
einops
|
| 19 |
+
kornia
|
video_utils.py
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Video I/O utilities: load frames + audio from a video file, save frames back
|
| 3 |
+
to video with audio, and trim audio to match video duration.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import os
|
| 7 |
+
import subprocess
|
| 8 |
+
import tempfile
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
|
| 11 |
+
import numpy as np
|
| 12 |
+
from PIL import Image
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
# ---------------------------------------------------------------------------
|
| 16 |
+
# Loading
|
| 17 |
+
# ---------------------------------------------------------------------------
|
| 18 |
+
|
| 19 |
+
def load_video_frames(
|
| 20 |
+
path: str,
|
| 21 |
+
fps: float = 24.0,
|
| 22 |
+
max_frames: int | None = None,
|
| 23 |
+
) -> tuple[np.ndarray, float]:
|
| 24 |
+
"""
|
| 25 |
+
Decode video frames to a uint8 numpy array [N, H, W, 3].
|
| 26 |
+
|
| 27 |
+
Returns (frames, actual_fps).
|
| 28 |
+
Uses decord when available; falls back to opencv.
|
| 29 |
+
"""
|
| 30 |
+
try:
|
| 31 |
+
import decord
|
| 32 |
+
decord.bridge.set_bridge("native")
|
| 33 |
+
vr = decord.VideoReader(path, ctx=decord.cpu(0))
|
| 34 |
+
actual_fps = float(vr.get_avg_fps())
|
| 35 |
+
total = len(vr)
|
| 36 |
+
if max_frames is not None:
|
| 37 |
+
total = min(total, max_frames)
|
| 38 |
+
indices = list(range(total))
|
| 39 |
+
frames = vr.get_batch(indices).asnumpy() # [N, H, W, 3]
|
| 40 |
+
return frames, actual_fps
|
| 41 |
+
except ImportError:
|
| 42 |
+
pass
|
| 43 |
+
|
| 44 |
+
import cv2
|
| 45 |
+
cap = cv2.VideoCapture(path)
|
| 46 |
+
actual_fps = cap.get(cv2.CAP_PROP_FPS) or fps
|
| 47 |
+
frames = []
|
| 48 |
+
while True:
|
| 49 |
+
ret, frame = cap.read()
|
| 50 |
+
if not ret:
|
| 51 |
+
break
|
| 52 |
+
frames.append(cv2.cvtColor(frame, cv2.COLOR_BGR2RGB))
|
| 53 |
+
if max_frames is not None and len(frames) >= max_frames:
|
| 54 |
+
break
|
| 55 |
+
cap.release()
|
| 56 |
+
return np.stack(frames, axis=0), actual_fps
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def extract_audio(video_path: str, output_path: str) -> bool:
|
| 60 |
+
"""Extract audio track from video to a WAV file. Returns False if no audio."""
|
| 61 |
+
result = subprocess.run(
|
| 62 |
+
[
|
| 63 |
+
"ffprobe", "-v", "quiet", "-select_streams", "a",
|
| 64 |
+
"-show_entries", "stream=codec_type",
|
| 65 |
+
"-of", "csv=p=0", video_path,
|
| 66 |
+
],
|
| 67 |
+
capture_output=True, text=True,
|
| 68 |
+
)
|
| 69 |
+
if "audio" not in result.stdout:
|
| 70 |
+
return False
|
| 71 |
+
|
| 72 |
+
subprocess.run(
|
| 73 |
+
[
|
| 74 |
+
"ffmpeg", "-y", "-i", video_path,
|
| 75 |
+
"-vn", "-acodec", "pcm_s16le",
|
| 76 |
+
"-ar", "44100", "-ac", "2", output_path,
|
| 77 |
+
],
|
| 78 |
+
capture_output=True, check=True,
|
| 79 |
+
)
|
| 80 |
+
return True
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
# ---------------------------------------------------------------------------
|
| 84 |
+
# Saving
|
| 85 |
+
# ---------------------------------------------------------------------------
|
| 86 |
+
|
| 87 |
+
def save_video(
|
| 88 |
+
frames: np.ndarray,
|
| 89 |
+
fps: float,
|
| 90 |
+
output_path: str,
|
| 91 |
+
audio_path: str | None = None,
|
| 92 |
+
audio_duration: float | None = None,
|
| 93 |
+
crf: int = 19,
|
| 94 |
+
) -> str:
|
| 95 |
+
"""
|
| 96 |
+
Encode frames [N, H, W, 3] uint8 to an mp4 file.
|
| 97 |
+
Optionally mux audio_path (trimmed to audio_duration seconds if provided).
|
| 98 |
+
|
| 99 |
+
Returns the path to the written file.
|
| 100 |
+
"""
|
| 101 |
+
N, H, W, _ = frames.shape
|
| 102 |
+
tmp_video = output_path + ".noaudio.mp4"
|
| 103 |
+
|
| 104 |
+
# Write raw video with ffmpeg via stdin pipe
|
| 105 |
+
cmd = [
|
| 106 |
+
"ffmpeg", "-y",
|
| 107 |
+
"-f", "rawvideo",
|
| 108 |
+
"-vcodec", "rawvideo",
|
| 109 |
+
"-s", f"{W}x{H}",
|
| 110 |
+
"-pix_fmt", "rgb24",
|
| 111 |
+
"-r", str(fps),
|
| 112 |
+
"-i", "pipe:0",
|
| 113 |
+
"-vcodec", "libx264",
|
| 114 |
+
"-pix_fmt", "yuv420p",
|
| 115 |
+
"-crf", str(crf),
|
| 116 |
+
"-preset", "fast",
|
| 117 |
+
tmp_video,
|
| 118 |
+
]
|
| 119 |
+
proc = subprocess.Popen(cmd, stdin=subprocess.PIPE, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
| 120 |
+
for frame in frames:
|
| 121 |
+
proc.stdin.write(frame.tobytes())
|
| 122 |
+
proc.stdin.close()
|
| 123 |
+
proc.wait()
|
| 124 |
+
|
| 125 |
+
if audio_path and os.path.exists(audio_path):
|
| 126 |
+
duration_flag = ["-t", str(audio_duration)] if audio_duration else []
|
| 127 |
+
subprocess.run(
|
| 128 |
+
[
|
| 129 |
+
"ffmpeg", "-y",
|
| 130 |
+
"-i", tmp_video,
|
| 131 |
+
"-i", audio_path,
|
| 132 |
+
*duration_flag,
|
| 133 |
+
"-c:v", "copy",
|
| 134 |
+
"-c:a", "aac", "-b:a", "192k",
|
| 135 |
+
"-shortest",
|
| 136 |
+
output_path,
|
| 137 |
+
],
|
| 138 |
+
capture_output=True, check=True,
|
| 139 |
+
)
|
| 140 |
+
os.remove(tmp_video)
|
| 141 |
+
else:
|
| 142 |
+
os.rename(tmp_video, output_path)
|
| 143 |
+
|
| 144 |
+
return output_path
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
# ---------------------------------------------------------------------------
|
| 148 |
+
# Resolution helpers
|
| 149 |
+
# ---------------------------------------------------------------------------
|
| 150 |
+
|
| 151 |
+
def align_to(value: int, multiple: int = 32) -> int:
|
| 152 |
+
"""Round value up to the nearest multiple."""
|
| 153 |
+
return ((value + multiple - 1) // multiple) * multiple
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
def compute_target_size(
|
| 157 |
+
orig_w: int,
|
| 158 |
+
orig_h: int,
|
| 159 |
+
base_resolution: int = 768,
|
| 160 |
+
multiple: int = 32,
|
| 161 |
+
) -> tuple[int, int]:
|
| 162 |
+
"""
|
| 163 |
+
Scale the longer edge to base_resolution, preserving aspect ratio,
|
| 164 |
+
then align both dimensions to `multiple`.
|
| 165 |
+
"""
|
| 166 |
+
scale = base_resolution / max(orig_w, orig_h)
|
| 167 |
+
new_w = align_to(int(orig_w * scale), multiple)
|
| 168 |
+
new_h = align_to(int(orig_h * scale), multiple)
|
| 169 |
+
return new_w, new_h
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
def resize_frames(frames: np.ndarray, target_w: int, target_h: int) -> np.ndarray:
|
| 173 |
+
"""Resize [N, H, W, 3] frames to target_w x target_h."""
|
| 174 |
+
if frames.shape[2] == target_w and frames.shape[1] == target_h:
|
| 175 |
+
return frames
|
| 176 |
+
out = np.empty((len(frames), target_h, target_w, 3), dtype=np.uint8)
|
| 177 |
+
for i, f in enumerate(frames):
|
| 178 |
+
out[i] = np.array(Image.fromarray(f).resize((target_w, target_h), Image.LANCZOS))
|
| 179 |
+
return out
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
def frames_for_duration(fps: float, duration: float) -> int:
|
| 183 |
+
"""Return frame count aligned to LTX-2.3 requirements: ((n * fps) // 8) * 8 + 1."""
|
| 184 |
+
raw = int(duration * fps)
|
| 185 |
+
return ((raw // 8) * 8) + 1
|