ismailkattakath commited on
Commit
1405c30
·
verified ·
1 Parent(s): ff3148c

Upload folder using huggingface_hub

Browse files
Files changed (6) hide show
  1. README.md +53 -6
  2. app.py +227 -0
  3. composer.py +212 -0
  4. pipeline.py +201 -0
  5. requirements.txt +19 -0
  6. video_utils.py +185 -0
README.md CHANGED
@@ -1,13 +1,60 @@
1
  ---
2
  title: BFS Best Face Swap Video
3
- emoji: 🐨
4
- colorFrom: green
5
  colorTo: blue
6
  sdk: gradio
7
- sdk_version: 6.14.0
8
- python_version: '3.13'
9
  app_file: app.py
10
- pinned: false
 
 
 
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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