Replace with lightweight SAM3 mask-only service (no checkpoint)

#2
by pormungtai - opened
Files changed (2) hide show
  1. app.py +76 -1586
  2. requirements.txt +3 -14
app.py CHANGED
@@ -1,1265 +1,22 @@
1
- import gc
2
- import json
3
- import logging
 
 
 
 
4
  import os
5
- import shutil
6
- import subprocess
7
  import sys
 
8
  import tempfile
 
9
  import traceback
10
- import uuid
11
- import zipfile
12
- from dataclasses import dataclass
13
- from pathlib import Path
14
- from types import SimpleNamespace
15
 
16
  import gradio as gr
17
  import spaces
18
- import torch
19
- from huggingface_hub import hf_hub_download, snapshot_download
20
-
21
-
22
- logging.basicConfig(level=logging.INFO, format="[%(asctime)s] %(levelname)s: %(message)s")
23
-
24
- ROOT = Path(__file__).resolve().parent
25
-
26
-
27
- def _default_storage_root() -> Path:
28
- if os.getenv("SCAIL_STORAGE_ROOT"):
29
- return Path(os.environ["SCAIL_STORAGE_ROOT"])
30
- data_mount = Path("/data")
31
- if data_mount.exists() and os.access(data_mount, os.W_OK):
32
- return data_mount
33
- return Path("/tmp")
34
-
35
-
36
- STORAGE_ROOT = _default_storage_root()
37
- STAGING_ROOT = Path(os.getenv("SCAIL_STAGING_ROOT", "/tmp"))
38
- OUTPUT_DIR = Path(os.getenv("SCAIL_OUTPUT_DIR", str(STORAGE_ROOT / "scail2_outputs")))
39
- OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
40
-
41
- MODEL_REPO_ID = os.getenv("SCAIL_MODEL_REPO_ID", "zai-org/SCAIL-2")
42
- SAFETENSORS_REPO_ID = os.getenv("SCAIL_SAFETENSORS_REPO_ID")
43
- SAFETENSORS_FILENAME = os.getenv("SCAIL_SAFETENSORS_FILENAME", "SCAIL-2.safetensors")
44
- MODEL_NAME = os.getenv("SCAIL_MODEL_NAME", "SCAIL-14B")
45
- GPU_SIZE = os.getenv("SCAIL_ZEROGPU_SIZE", "xlarge")
46
- GPU_DURATION_COLD = int(os.getenv("SCAIL_GPU_DURATION_COLD", "600"))
47
- GPU_DURATION_WARM = int(os.getenv("SCAIL_GPU_DURATION_WARM", "330"))
48
- DEFAULT_TARGET_H = int(os.getenv("SCAIL_TARGET_H", "512"))
49
- DEFAULT_TARGET_W = int(os.getenv("SCAIL_TARGET_W", "896"))
50
- DEFAULT_SEGMENT_LEN = int(os.getenv("SCAIL_SEGMENT_LEN", "81"))
51
- DEFAULT_SEGMENT_OVERLAP = int(os.getenv("SCAIL_SEGMENT_OVERLAP", "5"))
52
- DEFAULT_SHIFT = float(os.getenv("SCAIL_SAMPLE_SHIFT", "3.0"))
53
- DEFAULT_GUIDE_SCALE = float(os.getenv("SCAIL_GUIDE_SCALE", "5.0"))
54
- DEFAULT_SOLVER = os.getenv("SCAIL_SAMPLE_SOLVER", "unipc")
55
- AUTO_CONVERT = os.getenv("SCAIL_AUTO_CONVERT", "1") == "1"
56
- PRELOAD_PIPELINE = os.getenv("SCAIL_PRELOAD_PIPELINE", "1") == "1"
57
- STAGE_SAFETENSORS_FOR_LOAD = os.getenv("SCAIL_STAGE_SAFETENSORS_FOR_LOAD", "1") == "1"
58
- CONVERT_TO_STAGING_FIRST = os.getenv("SCAIL_CONVERT_TO_STAGING_FIRST", "1") == "1"
59
- MAX_ADDITIONAL_REFS = int(os.getenv("SCAIL_MAX_ADDITIONAL_REFS", "16"))
60
-
61
- CLIP_CKPT_NAME = "models_clip_open-clip-xlm-roberta-large-vit-huge-14-onlyvisual.pth"
62
- ORIGINAL_DIT_REL_PATH = "model/1/fsdp2_rank_0000_checkpoint.pt"
63
- BASE_ALLOW_PATTERNS = [
64
- "Wan2.1_VAE.pth",
65
- "umt5-xxl/**",
66
- CLIP_CKPT_NAME,
67
- ]
68
-
69
- _PIPELINE = None
70
- _PIPELINE_KEY = None
71
- _ASSET_STATUS = "Assets were not prepared yet."
72
- _ASSET_ERROR = None
73
- _RUNTIME_STATUS = "Runtime was not prepared yet."
74
- _RUNTIME_ERROR = None
75
- _PIPELINE_STATUS = "Pipeline was not preloaded."
76
- _PIPELINE_ERROR = None
77
- _LAST_CONVERTED_SAFETENSORS = None
78
- _WAN = None
79
- _GENERATE_VIDEO = None
80
- _SCAIL_CONFIGS = None
81
- _SCAIL_CONFIG_PATHS = None
82
-
83
-
84
- @dataclass(frozen=True)
85
- class ReferencePair:
86
- label: str
87
- image: str
88
- mask_image: str
89
-
90
-
91
- @dataclass(frozen=True)
92
- class PreparedExample:
93
- label: str
94
- image: str
95
- mask_image: str
96
- pose: str
97
- mask_video: str
98
- prompt: str
99
- replace_flag: bool = False
100
- additional_refs: tuple[ReferencePair, ...] = ()
101
-
102
-
103
- PREPARED_EXAMPLES = {
104
- "Animation 001 - end-to-end": PreparedExample(
105
- label="Animation 001 - end-to-end",
106
- image="examples/animation_001/ref.jpg",
107
- mask_image="examples/animation_001/ref_mask.jpg",
108
- pose="examples/animation_001/rendered_v2.mp4",
109
- mask_video="examples/animation_001/rendered_mask_v2.mp4",
110
- prompt="A young woman is dancing with energetic body movement.",
111
- ),
112
- "Animation 001 - pose-driven": PreparedExample(
113
- label="Animation 001 - pose-driven",
114
- image="examples/animation_001_posedriven/ref.jpg",
115
- mask_image="examples/animation_001_posedriven/ref_mask.jpg",
116
- pose="examples/animation_001_posedriven/rendered_v2.mp4",
117
- mask_video="examples/animation_001_posedriven/rendered_mask_v2.mp4",
118
- prompt="A young woman is dancing with energetic body movement.",
119
- ),
120
- "Animation 002 - end-to-end": PreparedExample(
121
- label="Animation 002 - end-to-end",
122
- image="examples/animation_002/ref.jpg",
123
- mask_image="examples/animation_002/ref_mask.jpg",
124
- pose="examples/animation_002/rendered_v2.mp4",
125
- mask_video="examples/animation_002/rendered_mask_v2.mp4",
126
- prompt="A character performs the motion from the driving video.",
127
- ),
128
- "Animation 003 - multi-reference": PreparedExample(
129
- label="Animation 003 - multi-reference",
130
- image="examples/animation_003_multi_ref/ref.png",
131
- mask_image="examples/animation_003_multi_ref/ref_mask.jpg",
132
- pose="examples/animation_003_multi_ref/rendered_v2.mp4",
133
- mask_video="examples/animation_003_multi_ref/rendered_mask_v2.mp4",
134
- prompt="A character performs the motion from the driving video.",
135
- additional_refs=(
136
- ReferencePair(
137
- label="Background",
138
- image="examples/animation_003_multi_ref/background.png",
139
- mask_image="examples/animation_003_multi_ref/background_mask.png",
140
- ),
141
- ReferencePair(
142
- label="Reference view 1",
143
- image="examples/animation_003_multi_ref/character_0.png",
144
- mask_image="examples/animation_003_multi_ref/character_0_mask.png",
145
- ),
146
- ReferencePair(
147
- label="Reference view 2",
148
- image="examples/animation_003_multi_ref/character_1.png",
149
- mask_image="examples/animation_003_multi_ref/character_1_mask.png",
150
- ),
151
- ),
152
- ),
153
- "Replacement 001": PreparedExample(
154
- label="Replacement 001",
155
- image="examples/replace_001/ref.png",
156
- mask_image="examples/replace_001/ref_mask.png",
157
- pose="examples/replace_001/rendered_v2.mp4",
158
- mask_video="examples/replace_001/replace_mask.mp4",
159
- prompt=(
160
- "A blond white male wearing a black suit, trousers, and leather shoes "
161
- "is playing the violin on the street while pedestrians walk past him."
162
- ),
163
- replace_flag=True,
164
- ),
165
- }
166
-
167
-
168
- def _abs(path: str | Path) -> str:
169
- path = Path(path)
170
- if not path.is_absolute():
171
- path = ROOT / path
172
- return str(path)
173
-
174
-
175
- def _example_paths(example: PreparedExample) -> list[str]:
176
- paths = [example.image, example.mask_image, example.pose, example.mask_video]
177
- for ref in example.additional_refs:
178
- paths.extend([ref.image, ref.mask_image])
179
- return paths
180
-
181
-
182
- def _existing_examples() -> dict[str, PreparedExample]:
183
- available = {}
184
- for name, example in PREPARED_EXAMPLES.items():
185
- if all(Path(_abs(path)).exists() for path in _example_paths(example)):
186
- available[name] = example
187
- return available
188
-
189
-
190
- IMAGE_EXTS = (".png", ".jpg", ".jpeg", ".webp")
191
- VIDEO_EXTS = (".mp4", ".mov", ".webm", ".mkv")
192
- PRIMARY_STEMS = ("front", "main", "ref", "reference")
193
-
194
-
195
- def _safe_extract_zip(zip_path: str | Path) -> Path:
196
- zip_path = Path(zip_path)
197
- if not zip_path.exists():
198
- raise RuntimeError(f"Pack does not exist: {zip_path}")
199
- if zip_path.suffix.lower() != ".zip":
200
- raise RuntimeError("Advanced Pack expects a .zip file.")
201
-
202
- extract_root = Path(tempfile.gettempdir()) / "scail2_input_packs" / uuid.uuid4().hex
203
- extract_root.mkdir(parents=True, exist_ok=True)
204
- with zipfile.ZipFile(zip_path) as zf:
205
- for item in zf.infolist():
206
- item_path = Path(item.filename)
207
- if item_path.is_absolute() or ".." in item_path.parts:
208
- raise RuntimeError(f"Unsafe path in zip: {item.filename}")
209
- zf.extractall(extract_root)
210
-
211
- visible = [p for p in extract_root.iterdir() if p.name not in {".DS_Store", "__MACOSX"}]
212
- if len(visible) == 1 and visible[0].is_dir():
213
- return visible[0]
214
- return extract_root
215
-
216
-
217
- def _read_metadata(pack_root: Path) -> dict:
218
- metadata_path = pack_root / "metadata.json"
219
- if not metadata_path.exists():
220
- return {}
221
- try:
222
- return json.loads(metadata_path.read_text(encoding="utf-8"))
223
- except Exception as exc:
224
- raise RuntimeError(f"Invalid metadata.json: {exc}") from exc
225
-
226
-
227
- def _read_pack_prompt(pack_root: Path, metadata: dict) -> str:
228
- if isinstance(metadata.get("prompt"), str):
229
- return metadata["prompt"]
230
- prompt_path = pack_root / "prompt.txt"
231
- if prompt_path.exists():
232
- return prompt_path.read_text(encoding="utf-8").strip()
233
- return ""
234
-
235
-
236
- def _find_stem_file(directory: Path, stems: tuple[str, ...], exts: tuple[str, ...]) -> Path | None:
237
- for stem in stems:
238
- stem_path = Path(stem)
239
- if stem_path.suffix:
240
- candidate = directory / stem
241
- if candidate.exists() and candidate.is_file():
242
- return candidate
243
- continue
244
- for ext in exts:
245
- candidate = directory / f"{stem}{ext}"
246
- if candidate.exists() and candidate.is_file():
247
- return candidate
248
- return None
249
-
250
-
251
- def _mask_for_image(image_path: Path) -> Path | None:
252
- for ext in IMAGE_EXTS:
253
- candidate = image_path.with_name(f"{image_path.stem}_mask{ext}")
254
- if candidate.exists() and candidate.is_file():
255
- return candidate
256
- return None
257
-
258
-
259
- def _rel(path: Path, root: Path) -> str:
260
- try:
261
- return path.relative_to(root).as_posix()
262
- except ValueError:
263
- return path.name
264
-
265
-
266
- def _collect_pairs_from_dir(directory: Path, root: Path, identity: str) -> list[ReferencePair]:
267
- if not directory.exists() or not directory.is_dir():
268
- return []
269
-
270
- pairs = []
271
- for image_path in sorted(directory.iterdir(), key=lambda p: p.name.lower()):
272
- if not image_path.is_file() or image_path.suffix.lower() not in IMAGE_EXTS:
273
- continue
274
- if image_path.stem.endswith("_mask"):
275
- continue
276
- mask_path = _mask_for_image(image_path)
277
- if mask_path is None:
278
- raise RuntimeError(f"Missing mask for `{_rel(image_path, root)}`.")
279
- label = f"{identity}: {image_path.stem}"
280
- pairs.append(ReferencePair(label=label, image=str(image_path), mask_image=str(mask_path)))
281
- return pairs
282
-
283
-
284
- def _collect_character_pairs(pack_root: Path) -> list[ReferencePair]:
285
- characters_dir = pack_root / "characters"
286
- if not characters_dir.exists():
287
- return []
288
-
289
- pairs = []
290
- for identity_dir in sorted(characters_dir.iterdir(), key=lambda p: p.name.lower()):
291
- if identity_dir.is_dir():
292
- pairs.extend(_collect_pairs_from_dir(identity_dir, pack_root, identity_dir.name))
293
- return pairs
294
-
295
-
296
- def _collect_environment_pairs(pack_root: Path) -> list[ReferencePair]:
297
- pairs = _collect_pairs_from_dir(pack_root / "environment", pack_root, "environment")
298
- for stem in ("background", "environment"):
299
- image_path = _find_stem_file(pack_root, (stem,), IMAGE_EXTS)
300
- if image_path is None:
301
- continue
302
- mask_path = _mask_for_image(image_path)
303
- if mask_path is None:
304
- raise RuntimeError(f"Missing mask for `{_rel(image_path, pack_root)}`.")
305
- pairs.append(ReferencePair(label=f"environment: {image_path.stem}", image=str(image_path), mask_image=str(mask_path)))
306
- return pairs
307
-
308
-
309
- def _collect_legacy_flat_pairs(pack_root: Path) -> list[ReferencePair]:
310
- pairs = []
311
- for image_path in sorted(pack_root.iterdir(), key=lambda p: p.name.lower()):
312
- if not image_path.is_file() or image_path.suffix.lower() not in IMAGE_EXTS:
313
- continue
314
- if image_path.stem.endswith("_mask"):
315
- continue
316
- if not image_path.stem.startswith("character_"):
317
- continue
318
- mask_path = _mask_for_image(image_path)
319
- if mask_path is None:
320
- raise RuntimeError(f"Missing mask for `{_rel(image_path, pack_root)}`.")
321
- pairs.append(ReferencePair(label=f"reference: {image_path.stem}", image=str(image_path), mask_image=str(mask_path)))
322
- return pairs
323
-
324
-
325
- def _primary_sort_key(ref: ReferencePair):
326
- stem = Path(ref.image).stem.lower()
327
- if stem in PRIMARY_STEMS:
328
- return (0, PRIMARY_STEMS.index(stem), ref.label.lower())
329
- return (1, ref.label.lower())
330
-
331
-
332
- def _resolve_metadata_file(pack_root: Path, rel_path: str | None, label: str) -> Path | None:
333
- if not rel_path:
334
- return None
335
- path = pack_root / rel_path
336
- if not path.exists() or not path.is_file():
337
- raise RuntimeError(f"metadata.json references missing {label}: `{rel_path}`.")
338
- return path
339
-
340
-
341
- def _select_pack_primary(pack_root: Path, metadata: dict, character_pairs: list[ReferencePair]) -> ReferencePair:
342
- primary = metadata.get("primary") if isinstance(metadata.get("primary"), dict) else {}
343
- primary_image = _resolve_metadata_file(pack_root, primary.get("image"), "primary image")
344
- primary_mask = _resolve_metadata_file(pack_root, primary.get("mask"), "primary mask")
345
- if primary_image is not None or primary_mask is not None:
346
- if primary_image is None or primary_mask is None:
347
- raise RuntimeError("metadata.json primary must include both `image` and `mask`.")
348
- return ReferencePair(label="metadata primary", image=str(primary_image), mask_image=str(primary_mask))
349
-
350
- ref_image = _find_stem_file(pack_root, ("ref", "main", "reference"), IMAGE_EXTS)
351
- if ref_image is not None:
352
- ref_mask = _mask_for_image(ref_image)
353
- if ref_mask is None:
354
- raise RuntimeError(f"Missing mask for primary reference `{_rel(ref_image, pack_root)}`.")
355
- return ReferencePair(label="primary reference", image=str(ref_image), mask_image=str(ref_mask))
356
-
357
- if not character_pairs:
358
- raise RuntimeError(
359
- "No primary reference found. Provide `ref.png` + `ref_mask.png`, "
360
- "or at least one pair under `characters/character_0/`."
361
- )
362
- character_0 = [ref for ref in character_pairs if "/character_0/" in Path(ref.image).as_posix()]
363
- candidates = character_0 or character_pairs
364
- selected = sorted(candidates, key=_primary_sort_key)[0]
365
- return ReferencePair(label=f"{selected.label} (auto primary)", image=selected.image, mask_image=selected.mask_image)
366
-
367
-
368
- def _find_pack_video(pack_root: Path, metadata: dict) -> Path:
369
- driving = metadata.get("driving") if isinstance(metadata.get("driving"), dict) else {}
370
- metadata_video = _resolve_metadata_file(pack_root, driving.get("video"), "driving video")
371
- if metadata_video is not None:
372
- return metadata_video
373
- video = _find_stem_file(pack_root, ("rendered_v2", "driving", "pose"), VIDEO_EXTS)
374
- if video is None:
375
- raise RuntimeError("Missing driving video. Expected `rendered_v2.mp4`.")
376
- return video
377
-
378
-
379
- def _find_pack_mask_video(pack_root: Path, metadata: dict) -> tuple[Path, bool]:
380
- driving = metadata.get("driving") if isinstance(metadata.get("driving"), dict) else {}
381
- metadata_mask = _resolve_metadata_file(pack_root, driving.get("mask_video"), "driving mask video")
382
- if metadata_mask is not None:
383
- return metadata_mask, metadata_mask.name == "replace_mask.mp4" or metadata.get("mode") == "replacement"
384
-
385
- rendered_mask = pack_root / "rendered_mask_v2.mp4"
386
- replace_mask = pack_root / "replace_mask.mp4"
387
- if rendered_mask.exists() and replace_mask.exists():
388
- raise RuntimeError("Found both `rendered_mask_v2.mp4` and `replace_mask.mp4`; keep only one or set metadata.json.")
389
- if replace_mask.exists():
390
- return replace_mask, True
391
- if rendered_mask.exists():
392
- return rendered_mask, False
393
- raise RuntimeError("Missing mask video. Expected `rendered_mask_v2.mp4` or `replace_mask.mp4`.")
394
-
395
-
396
- def _same_pair(a: ReferencePair, b: ReferencePair) -> bool:
397
- return Path(a.image).resolve() == Path(b.image).resolve() and Path(a.mask_image).resolve() == Path(b.mask_image).resolve()
398
-
399
-
400
- def parse_input_pack(pack_zip: str | Path) -> dict:
401
- pack_root = _safe_extract_zip(pack_zip)
402
- metadata = _read_metadata(pack_root)
403
- character_pairs = _collect_character_pairs(pack_root)
404
- environment_pairs = _collect_environment_pairs(pack_root)
405
- legacy_pairs = _collect_legacy_flat_pairs(pack_root)
406
- primary = _select_pack_primary(pack_root, metadata, character_pairs + legacy_pairs)
407
- driving_video = _find_pack_video(pack_root, metadata)
408
- mask_video, replace_flag = _find_pack_mask_video(pack_root, metadata)
409
-
410
- refs = sorted(character_pairs + legacy_pairs, key=lambda ref: ref.label.lower()) + environment_pairs
411
- additional_refs = [ref for ref in refs if not _same_pair(ref, primary)]
412
- if len(additional_refs) > MAX_ADDITIONAL_REFS:
413
- raise RuntimeError(f"Too many additional references: {len(additional_refs)}. Limit is {MAX_ADDITIONAL_REFS}.")
414
-
415
- return {
416
- "root": str(pack_root),
417
- "prompt": _read_pack_prompt(pack_root, metadata),
418
- "mode": "replacement" if replace_flag else "animation",
419
- "replace_flag": replace_flag,
420
- "image": primary.image,
421
- "mask_image": primary.mask_image,
422
- "pose": str(driving_video),
423
- "mask_video": str(mask_video),
424
- "primary_label": primary.label,
425
- "additional_refs": [
426
- {"label": ref.label, "image": ref.image, "mask_image": ref.mask_image}
427
- for ref in additional_refs
428
- ],
429
- }
430
-
431
-
432
- def _pack_gallery(pack: dict):
433
- items = [
434
- (pack["image"], f"Primary: {pack['primary_label']}"),
435
- (pack["mask_image"], "Primary mask"),
436
- ]
437
- for ref in pack["additional_refs"]:
438
- items.append((ref["image"], ref["label"]))
439
- items.append((ref["mask_image"], f"{ref['label']} mask"))
440
- return items
441
-
442
-
443
- def _pack_summary(pack: dict) -> str:
444
- lines = [
445
- "### Pack validated",
446
- f"- Mode: `{pack['mode']}`",
447
- f"- Primary: `{pack['primary_label']}`",
448
- f"- Driving video: `{Path(pack['pose']).name}`",
449
- f"- Mask video: `{Path(pack['mask_video']).name}`",
450
- f"- Additional reference pairs: `{len(pack['additional_refs'])}`",
451
- ]
452
- for ref in pack["additional_refs"]:
453
- lines.append(f" - `{ref['label']}`")
454
- return "\n".join(lines)
455
-
456
-
457
- def validate_input_pack(pack_zip):
458
- if pack_zip is None:
459
- return None, "Upload a `.zip` pack first.", [], None, None, "", "animation"
460
- try:
461
- pack = parse_input_pack(pack_zip)
462
- return (
463
- pack,
464
- _pack_summary(pack),
465
- _pack_gallery(pack),
466
- pack["pose"],
467
- pack["mask_video"],
468
- pack["prompt"],
469
- pack["mode"],
470
- )
471
- except Exception:
472
- logging.exception("Advanced pack validation failed")
473
- return None, traceback.format_exc(), [], None, None, "", "animation"
474
-
475
-
476
- def _require_repo_layout():
477
- missing = []
478
- for rel in ("wan/scail.py", "wan/modules/model_scail2.py", "generate.py", "configs/config-14b.json"):
479
- if not (ROOT / rel).exists():
480
- missing.append(rel)
481
- if missing:
482
- raise RuntimeError(
483
- "This app.py is meant to live at the root of the SCAIL-2 repository. "
484
- f"Missing: {', '.join(missing)}"
485
- )
486
-
487
-
488
- def _download_safetensors_if_configured() -> Path | None:
489
- if not SAFETENSORS_REPO_ID:
490
- return None
491
-
492
- local_dir = Path(os.getenv("SCAIL_SAFETENSORS_CACHE", str(STORAGE_ROOT / "scail2_safetensors")))
493
- local_dir.mkdir(parents=True, exist_ok=True)
494
- local_path = local_dir / SAFETENSORS_FILENAME
495
- if local_path.exists():
496
- return local_path
497
-
498
- logging.info("Downloading converted SCAIL-2 safetensors from %s/%s", SAFETENSORS_REPO_ID, SAFETENSORS_FILENAME)
499
- downloaded = hf_hub_download(
500
- repo_id=SAFETENSORS_REPO_ID,
501
- filename=SAFETENSORS_FILENAME,
502
- local_dir=str(local_dir),
503
- local_dir_use_symlinks=False,
504
- resume_download=True,
505
- )
506
- return Path(downloaded)
507
-
508
-
509
- def _find_converted_safetensors(ckpt_dir: Path | None) -> Path | None:
510
- candidates = []
511
- env_path = os.getenv("SCAIL_SAFETENSORS_PATH")
512
- if env_path:
513
- candidates.append(Path(env_path))
514
- if _LAST_CONVERTED_SAFETENSORS is not None:
515
- candidates.append(Path(_LAST_CONVERTED_SAFETENSORS))
516
- candidates += [
517
- ROOT / "SCAIL-2.safetensors",
518
- ROOT / "models" / "SCAIL-2.safetensors",
519
- ROOT / "model.safetensors",
520
- Path(os.getenv("SCAIL_CONVERTED_DIR", str(STORAGE_ROOT / "scail2_converted"))) / "SCAIL-2.safetensors",
521
- ]
522
- if ckpt_dir is not None:
523
- candidates += [ckpt_dir / "SCAIL-2.safetensors", ckpt_dir / "model.safetensors"]
524
- for candidate in candidates:
525
- if candidate.exists():
526
- return candidate
527
- return _download_safetensors_if_configured()
528
-
529
-
530
- def _copy_file_with_progress(source: Path, dest: Path, description: str) -> Path:
531
- source_size = source.stat().st_size
532
- chunk_size = int(os.getenv("SCAIL_STAGE_COPY_CHUNK_MB", "64")) * 1024 * 1024
533
- log_every = int(os.getenv("SCAIL_STAGE_COPY_LOG_GB", "1")) * 1024 * 1024 * 1024
534
- dest.parent.mkdir(parents=True, exist_ok=True)
535
-
536
- if dest.exists() and dest.stat().st_size == source_size:
537
- return dest
538
-
539
- tmp_dest = dest.with_suffix(dest.suffix + ".tmp")
540
- copied = tmp_dest.stat().st_size if tmp_dest.exists() else 0
541
- if copied > source_size:
542
- tmp_dest.unlink()
543
- copied = 0
544
-
545
- logging.info("%s: %s -> %s", description, source, dest)
546
- next_log = ((copied // log_every) + 1) * log_every if log_every > 0 else source_size
547
- if copied:
548
- logging.info("Resuming copy at %.2f/%.2f GB", copied / 1024**3, source_size / 1024**3)
549
-
550
- with source.open("rb") as src, tmp_dest.open("ab") as dst:
551
- if copied:
552
- src.seek(copied)
553
- while copied < source_size:
554
- chunk = src.read(min(chunk_size, source_size - copied))
555
- if not chunk:
556
- raise RuntimeError(f"Unexpected EOF while copying {source}: {copied} of {source_size} bytes")
557
- dst.write(chunk)
558
- copied += len(chunk)
559
- if log_every > 0 and copied >= next_log:
560
- logging.info("%s: %.2f/%.2f GB", description, copied / 1024**3, source_size / 1024**3)
561
- next_log += log_every
562
-
563
- if tmp_dest.stat().st_size != source_size:
564
- raise RuntimeError(f"Copied file size mismatch: {tmp_dest.stat().st_size} != {source_size}")
565
- tmp_dest.replace(dest)
566
- logging.info("Finished %s: %s", description, dest)
567
- return dest
568
-
569
-
570
- def _is_relative_to(path: Path, parent: Path) -> bool:
571
- try:
572
- path.resolve().relative_to(parent.resolve())
573
- return True
574
- except ValueError:
575
- return False
576
-
577
-
578
- def _stage_safetensors_for_load(scail_path: Path) -> Path:
579
- if not STAGE_SAFETENSORS_FOR_LOAD:
580
- return scail_path
581
-
582
- source = Path(scail_path)
583
- if _is_relative_to(source, STAGING_ROOT):
584
- return source
585
-
586
- stage_dir = Path(os.getenv("SCAIL_MODEL_LOAD_CACHE", str(STAGING_ROOT / "scail2_model_load")))
587
- staged = stage_dir / source.name
588
- if staged.exists() and staged.stat().st_size == source.stat().st_size:
589
- return staged
590
- return _copy_file_with_progress(source, staged, "Staging SCAIL-2 safetensors for local load")
591
-
592
-
593
- def _download_checkpoint_if_needed(include_original_dit: bool = False) -> Path:
594
- env_dir = os.getenv("SCAIL_CKPT_DIR")
595
- if env_dir:
596
- ckpt_dir = Path(env_dir)
597
- if not ckpt_dir.exists():
598
- raise RuntimeError(f"SCAIL_CKPT_DIR does not exist: {ckpt_dir}")
599
- return ckpt_dir
600
-
601
- local_dir = Path(os.getenv("SCAIL_CKPT_CACHE", str(STORAGE_ROOT / "scail2_ckpt")))
602
- has_base_assets = (
603
- (local_dir / "Wan2.1_VAE.pth").exists()
604
- and (local_dir / "umt5-xxl").exists()
605
- and (local_dir / CLIP_CKPT_NAME).exists()
606
- )
607
- if has_base_assets:
608
- return local_dir
609
-
610
- if include_original_dit:
611
- logging.warning("Original DiT staging uses SCAIL_ORIGINAL_DIT_CACHE; base download will stay narrow.")
612
- logging.info("Downloading SCAIL-2 base checkpoint assets from %s", MODEL_REPO_ID)
613
- snapshot_download(
614
- repo_id=MODEL_REPO_ID,
615
- local_dir=str(local_dir),
616
- local_dir_use_symlinks=False,
617
- resume_download=True,
618
- allow_patterns=BASE_ALLOW_PATTERNS,
619
- )
620
- return local_dir
621
-
622
-
623
- def _download_original_dit_for_conversion() -> Path:
624
- env_dir = os.getenv("SCAIL_ORIGINAL_DIT_DIR")
625
- if env_dir:
626
- original_dir = Path(env_dir)
627
- original_path = original_dir / ORIGINAL_DIT_REL_PATH
628
- if not original_path.exists():
629
- raise RuntimeError(f"SCAIL_ORIGINAL_DIT_DIR is missing {ORIGINAL_DIT_REL_PATH}: {original_dir}")
630
- return original_dir
631
-
632
- local_dir = Path(os.getenv("SCAIL_ORIGINAL_DIT_CACHE", str(STAGING_ROOT / "scail2_original_dit")))
633
- original_path = local_dir / ORIGINAL_DIT_REL_PATH
634
- if original_path.exists():
635
- return local_dir
636
-
637
- logging.info("Downloading original SCAIL-2 DiT checkpoint for one-time conversion into %s", local_dir)
638
- snapshot_download(
639
- repo_id=MODEL_REPO_ID,
640
- local_dir=str(local_dir),
641
- local_dir_use_symlinks=False,
642
- resume_download=True,
643
- allow_patterns=[ORIGINAL_DIT_REL_PATH],
644
- )
645
- return local_dir
646
-
647
-
648
- def _prepare_assets_for_runtime() -> str:
649
- global _ASSET_STATUS, _ASSET_ERROR
650
- try:
651
- ckpt_dir = _download_checkpoint_if_needed(include_original_dit=False)
652
- scail_path = _find_converted_safetensors(ckpt_dir)
653
- if scail_path is None and AUTO_CONVERT:
654
- original_dir = _download_original_dit_for_conversion()
655
- scail_path = _maybe_convert_checkpoint(original_dir, None)
656
- if scail_path is None:
657
- _ASSET_STATUS = (
658
- "Base checkpoint assets are present, but no converted safetensors file was found. "
659
- "Set SCAIL_SAFETENSORS_PATH or SCAIL_SAFETENSORS_REPO_ID."
660
- )
661
- else:
662
- _ASSET_STATUS = f"Assets ready. Base checkpoint: {ckpt_dir}. Converted DiT safetensors: {scail_path}."
663
- _ASSET_ERROR = None
664
- except Exception:
665
- _ASSET_ERROR = traceback.format_exc()
666
- _ASSET_STATUS = "Asset preparation failed. See the traceback below."
667
- logging.exception("Asset preparation failed")
668
- return _ASSET_STATUS if _ASSET_ERROR is None else _ASSET_STATUS + "\n\n" + _ASSET_ERROR
669
-
670
-
671
- def _maybe_convert_checkpoint(ckpt_dir: Path, scail_path: Path | None) -> Path:
672
- global _LAST_CONVERTED_SAFETENSORS
673
- if scail_path is not None:
674
- return scail_path
675
-
676
- if not AUTO_CONVERT:
677
- raise RuntimeError(
678
- "Converted SCAIL-2 safetensors file was not found. Set SCAIL_SAFETENSORS_PATH, "
679
- "or enable SCAIL_AUTO_CONVERT=1 for one-time conversion."
680
- )
681
-
682
- persistent_dir = Path(os.getenv("SCAIL_CONVERTED_DIR", str(STORAGE_ROOT / "scail2_converted")))
683
- persistent_path = persistent_dir / "SCAIL-2.safetensors"
684
- if persistent_path.exists():
685
- return persistent_path
686
 
687
- save_dir = Path(os.getenv("SCAIL_CONVERSION_WORK_DIR", str(STAGING_ROOT / "scail2_converted_work")))
688
- if not CONVERT_TO_STAGING_FIRST:
689
- save_dir = persistent_dir
690
- save_dir.mkdir(parents=True, exist_ok=True)
691
- save_path = save_dir / "SCAIL-2.safetensors"
692
- if save_path.exists():
693
- _LAST_CONVERTED_SAFETENSORS = save_path
694
- if save_path != persistent_path:
695
- _copy_file_with_progress(save_path, persistent_path, "Persisting converted SCAIL-2 safetensors to storage")
696
- return save_path
697
-
698
- logging.info("Converting checkpoint to safetensors: %s", save_path)
699
- convert_env = os.environ.copy()
700
- convert_env["TORCH_FORCE_NO_WEIGHTS_ONLY_LOAD"] = "1"
701
- subprocess.run(
702
- [
703
- sys.executable,
704
- str(ROOT / "convert.py"),
705
- "--scail-dir",
706
- str(ckpt_dir),
707
- "--save-path",
708
- str(save_path),
709
- ],
710
- check=True,
711
- cwd=str(ROOT),
712
- env=convert_env,
713
- )
714
- _LAST_CONVERTED_SAFETENSORS = save_path
715
- if save_path != persistent_path:
716
- _copy_file_with_progress(save_path, persistent_path, "Persisting converted SCAIL-2 safetensors to storage")
717
- return save_path
718
-
719
-
720
- def _install_attention_patch():
721
- import wan.modules.attention as attention_mod
722
-
723
- hf_flash_attn2 = None
724
- try:
725
- from kernels import get_kernel
726
-
727
- hf_flash_attn2 = get_kernel("kernels-community/flash-attn2", version=2)
728
- logging.info("Using kernels-community/flash-attn2 through HF Kernels.")
729
- except Exception as exc:
730
- if torch.cuda.is_available():
731
- device_name = torch.cuda.get_device_name(0)
732
- capability = torch.cuda.get_device_capability(0)
733
- else:
734
- device_name = "no cuda"
735
- capability = None
736
- logging.warning("Could not initialize HF Kernels flash-attn2: %r", exc)
737
- logging.warning(
738
- "Attention fallback environment: torch=%s cuda=%s device=%s capability=%s",
739
- torch.__version__,
740
- torch.version.cuda,
741
- device_name,
742
- capability,
743
- )
744
-
745
- def patched_flash_attention(
746
- q,
747
- k,
748
- v,
749
- q_lens=None,
750
- k_lens=None,
751
- dropout_p=0.0,
752
- softmax_scale=None,
753
- q_scale=None,
754
- causal=False,
755
- window_size=(-1, -1),
756
- deterministic=False,
757
- dtype=torch.bfloat16,
758
- version=None,
759
- ):
760
- half_dtypes = (torch.float16, torch.bfloat16)
761
- b, lq, lk, out_dtype = q.size(0), q.size(1), k.size(1), q.dtype
762
-
763
- def half(x):
764
- return x if x.dtype in half_dtypes else x.to(dtype)
765
-
766
- if hf_flash_attn2 is not None and q.device.type == "cuda":
767
- if q_lens is None:
768
- q_var = half(q.flatten(0, 1))
769
- q_lens_t = torch.full((b,), lq, dtype=torch.int32, device=q.device)
770
- else:
771
- q_lens_t = q_lens.to(device=q.device, dtype=torch.int32)
772
- q_var = half(torch.cat([u[: int(n)] for u, n in zip(q, q_lens_t)]))
773
-
774
- if k_lens is None:
775
- k_var = half(k.flatten(0, 1))
776
- v_var = half(v.flatten(0, 1))
777
- k_lens_t = torch.full((b,), lk, dtype=torch.int32, device=k.device)
778
- else:
779
- k_lens_t = k_lens.to(device=k.device, dtype=torch.int32)
780
- k_var = half(torch.cat([u[: int(n)] for u, n in zip(k, k_lens_t)]))
781
- v_var = half(torch.cat([u[: int(n)] for u, n in zip(v, k_lens_t)]))
782
-
783
- q_var = q_var.to(v_var.dtype)
784
- k_var = k_var.to(v_var.dtype)
785
- if q_scale is not None:
786
- q_var = q_var * q_scale
787
-
788
- cu_q = torch.cat([q_lens_t.new_zeros([1]), q_lens_t]).cumsum(0, dtype=torch.int32)
789
- cu_k = torch.cat([k_lens_t.new_zeros([1]), k_lens_t]).cumsum(0, dtype=torch.int32)
790
- try:
791
- out = hf_flash_attn2.flash_attn_varlen_func(
792
- q=q_var,
793
- k=k_var,
794
- v=v_var,
795
- cu_seqlens_q=cu_q,
796
- cu_seqlens_k=cu_k,
797
- max_seqlen_q=lq,
798
- max_seqlen_k=lk,
799
- dropout_p=dropout_p,
800
- softmax_scale=softmax_scale,
801
- causal=causal,
802
- window_size=window_size,
803
- deterministic=deterministic,
804
- )
805
- if isinstance(out, tuple):
806
- out = out[0]
807
- return out.unflatten(0, (b, lq)).type(out_dtype)
808
- except Exception as exc:
809
- logging.warning("HF Kernels flash-attn2 failed, falling back to SDPA: %s", exc)
810
-
811
- if q_lens is not None and not torch.all(q_lens == lq):
812
- logging.warning("SDPA fallback ignores variable q_lens; demo batch size should stay at 1.")
813
- if k_lens is not None and not torch.all(k_lens == lk):
814
- logging.warning("SDPA fallback ignores variable k_lens; demo batch size should stay at 1.")
815
-
816
- q_sdpa = q.transpose(1, 2).to(dtype)
817
- k_sdpa = k.transpose(1, 2).to(dtype)
818
- v_sdpa = v.transpose(1, 2).to(dtype)
819
- out = torch.nn.functional.scaled_dot_product_attention(
820
- q_sdpa,
821
- k_sdpa,
822
- v_sdpa,
823
- attn_mask=None,
824
- dropout_p=dropout_p,
825
- is_causal=causal,
826
- scale=softmax_scale,
827
- )
828
- return out.transpose(1, 2).contiguous().type(out_dtype)
829
-
830
- attention_mod.flash_attention = patched_flash_attention
831
- for module_name in (
832
- "wan.modules.clip",
833
- "wan.modules.model",
834
- "wan.modules.model_scail",
835
- "wan.modules.model_scail2",
836
- ):
837
- try:
838
- module = __import__(module_name, fromlist=["flash_attention"])
839
- if hasattr(module, "flash_attention"):
840
- module.flash_attention = patched_flash_attention
841
- logging.info("Patched %s.flash_attention", module_name)
842
- except Exception as exc:
843
- logging.warning("Could not patch %s.flash_attention: %s", module_name, exc)
844
-
845
-
846
- def _import_runtime():
847
- global _WAN, _GENERATE_VIDEO, _SCAIL_CONFIGS, _SCAIL_CONFIG_PATHS
848
- if _WAN is not None:
849
- return
850
-
851
- _require_repo_layout()
852
- if str(ROOT) not in sys.path:
853
- sys.path.insert(0, str(ROOT))
854
-
855
- import wan
856
- from generate import generate_video
857
- from wan.configs import SCAIL_CONFIGS, SCAIL_CONFIG_PATHS
858
-
859
- _install_attention_patch()
860
- _WAN = wan
861
- _GENERATE_VIDEO = generate_video
862
- _SCAIL_CONFIGS = SCAIL_CONFIGS
863
- _SCAIL_CONFIG_PATHS = SCAIL_CONFIG_PATHS
864
-
865
-
866
- def _prepare_runtime_for_startup() -> str:
867
- global _RUNTIME_STATUS, _RUNTIME_ERROR
868
- try:
869
- _import_runtime()
870
- _RUNTIME_STATUS = "Runtime ready. Attention backend has been initialized at startup."
871
- _RUNTIME_ERROR = None
872
- except Exception:
873
- _RUNTIME_ERROR = traceback.format_exc()
874
- _RUNTIME_STATUS = "Runtime preparation failed. See the traceback below."
875
- logging.exception("Runtime preparation failed")
876
- return _RUNTIME_STATUS if _RUNTIME_ERROR is None else _RUNTIME_STATUS + "\n\n" + _RUNTIME_ERROR
877
-
878
-
879
- def _get_pipeline():
880
- global _PIPELINE, _PIPELINE_KEY
881
- _import_runtime()
882
-
883
- ckpt_dir = _download_checkpoint_if_needed(include_original_dit=False)
884
- scail_path = _find_converted_safetensors(ckpt_dir)
885
- if scail_path is None and AUTO_CONVERT:
886
- original_dir = _download_original_dit_for_conversion()
887
- scail_path = _maybe_convert_checkpoint(original_dir, None)
888
- else:
889
- scail_path = _maybe_convert_checkpoint(ckpt_dir, scail_path)
890
-
891
- scail_load_path = _stage_safetensors_for_load(scail_path)
892
- config_path = Path(os.getenv("SCAIL_CONFIG_PATH", _SCAIL_CONFIG_PATHS[MODEL_NAME]))
893
- if not config_path.is_absolute():
894
- config_path = ROOT / config_path
895
-
896
- lora_path = os.getenv("SCAIL_LORA_PATH") or None
897
- lora_alpha = float(os.getenv("SCAIL_LORA_ALPHA", "1.0"))
898
- key = (str(ckpt_dir), str(scail_load_path), str(config_path), lora_path, lora_alpha)
899
- if _PIPELINE is not None and _PIPELINE_KEY == key:
900
- return _PIPELINE
901
-
902
- logging.info("Loading SCAIL-2 pipeline.")
903
- cfg = _SCAIL_CONFIGS[MODEL_NAME]
904
- _PIPELINE = _WAN.SCAIL2Pipeline(
905
- config=cfg,
906
- checkpoint_dir=str(ckpt_dir),
907
- scail_safetensors_path=str(scail_load_path),
908
- scail_config_path=str(config_path),
909
- device_id=0,
910
- rank=0,
911
- t5_fsdp=False,
912
- dit_fsdp=False,
913
- use_usp=False,
914
- t5_cpu=False,
915
- lora_path=lora_path,
916
- lora_alpha=lora_alpha,
917
- )
918
- _PIPELINE_KEY = key
919
- return _PIPELINE
920
-
921
-
922
- def _prepare_pipeline_for_startup() -> str:
923
- global _PIPELINE_STATUS, _PIPELINE_ERROR
924
- try:
925
- _get_pipeline()
926
- _PIPELINE_STATUS = "Pipeline preloaded at startup."
927
- _PIPELINE_ERROR = None
928
- except Exception:
929
- _PIPELINE_ERROR = traceback.format_exc()
930
- _PIPELINE_STATUS = "Pipeline preload failed. See the traceback below."
931
- logging.exception("Pipeline preload failed")
932
- return _PIPELINE_STATUS if _PIPELINE_ERROR is None else _PIPELINE_STATUS + "\n\n" + _PIPELINE_ERROR
933
-
934
-
935
- def _is_gradio_native_file_path(path: Path) -> bool:
936
- path = path.resolve()
937
- native_roots = [ROOT.resolve(), Path(tempfile.gettempdir()).resolve()]
938
- return any(_is_relative_to(path, root) for root in native_roots)
939
-
940
-
941
- def _prepare_output_for_gradio(path: str | Path) -> str:
942
- source = Path(path)
943
- if not source.exists():
944
- raise RuntimeError(f"Generated video was not found: {source}")
945
- if _is_gradio_native_file_path(source):
946
- return str(source)
947
-
948
- gradio_dir = Path(os.getenv("SCAIL_GRADIO_OUTPUT_CACHE", str(Path(tempfile.gettempdir()) / "scail2_gradio_outputs")))
949
- gradio_dir.mkdir(parents=True, exist_ok=True)
950
- dest = gradio_dir / source.name
951
- shutil.copy2(source, dest)
952
- logging.info("Copied generated video for Gradio display: %s -> %s", source, dest)
953
- return str(dest)
954
-
955
-
956
- def _duration_for_job(*args, **kwargs):
957
- if _PIPELINE is None:
958
- return int(os.getenv("SCAIL_GPU_DURATION", str(GPU_DURATION_COLD)))
959
- return int(os.getenv("SCAIL_GPU_DURATION", str(GPU_DURATION_WARM)))
960
-
961
-
962
- def _run_scail_job(
963
- image_path,
964
- mask_image_path,
965
- pose_path,
966
- mask_video_path,
967
- prompt,
968
- replace_flag,
969
- target_h,
970
- target_w,
971
- sample_steps,
972
- guide_scale,
973
- sample_shift,
974
- seed,
975
- segment_len,
976
- segment_overlap,
977
- additional_refs: tuple[ReferencePair, ...] = (),
978
- progress=None,
979
- ):
980
- if progress is not None:
981
- progress(0.02, desc="Loading SCAIL-2 pipeline")
982
- pipeline = _get_pipeline()
983
- cfg = _SCAIL_CONFIGS[MODEL_NAME]
984
- save_file = OUTPUT_DIR / f"scail2_{uuid.uuid4().hex}.mp4"
985
-
986
- if progress is not None:
987
- progress(0.12, desc="Preparing inputs")
988
- args = SimpleNamespace(
989
- target_h=int(target_h),
990
- target_w=int(target_w),
991
- sample_shift=float(sample_shift),
992
- sample_solver=DEFAULT_SOLVER,
993
- segment_len=int(segment_len),
994
- segment_overlap=int(segment_overlap),
995
- sample_steps=int(sample_steps),
996
- sample_guide_scale=float(guide_scale),
997
- base_seed=int(seed),
998
- offload_model=True,
999
- save_file=str(save_file),
1000
- save_dir=str(OUTPUT_DIR),
1001
- prompt=prompt or "",
1002
- )
1003
-
1004
- additional_task_input = None
1005
- if additional_refs:
1006
- additional_task_input = {
1007
- "additional_ref_image_paths": [str(ref.image) for ref in additional_refs],
1008
- "additional_ref_mask_image_paths": [str(ref.mask_image) for ref in additional_refs],
1009
- }
1010
-
1011
- if progress is not None:
1012
- progress(0.15, desc="Generating video")
1013
- _GENERATE_VIDEO(
1014
- pipeline,
1015
- prompt or "",
1016
- str(image_path),
1017
- str(mask_image_path),
1018
- str(pose_path),
1019
- str(mask_video_path),
1020
- args,
1021
- device=0,
1022
- rank=0,
1023
- cfg=cfg,
1024
- input_idx=None,
1025
- replace_flag=bool(replace_flag),
1026
- additional_task_input=additional_task_input,
1027
- )
1028
-
1029
- if progress is not None:
1030
- progress(0.95, desc="Finalizing output")
1031
- gc.collect()
1032
- if torch.cuda.is_available():
1033
- torch.cuda.empty_cache()
1034
- if progress is not None:
1035
- progress(0.98, desc="Preparing video for display")
1036
- display_file = _prepare_output_for_gradio(save_file)
1037
- if progress is not None:
1038
- progress(1.0, desc="Done")
1039
- return display_file
1040
-
1041
-
1042
- @spaces.GPU(duration=_duration_for_job, size=GPU_SIZE)
1043
- def generate_from_example(
1044
- example_name,
1045
- prompt,
1046
- sample_steps,
1047
- guide_scale,
1048
- sample_shift,
1049
- seed,
1050
- target_size,
1051
- segment_len,
1052
- segment_overlap,
1053
- progress=gr.Progress(track_tqdm=True),
1054
- ):
1055
- try:
1056
- progress(0.0, desc="Checking example inputs")
1057
- examples = _existing_examples()
1058
- if example_name not in examples:
1059
- raise RuntimeError(f"Example is missing from this checkout: {example_name}")
1060
- example = examples[example_name]
1061
- target_w, target_h = [int(v) for v in str(target_size).split("x")]
1062
- refs = tuple(
1063
- ReferencePair(ref.label, _abs(ref.image), _abs(ref.mask_image))
1064
- for ref in example.additional_refs
1065
- )
1066
- output = _run_scail_job(
1067
- _abs(example.image),
1068
- _abs(example.mask_image),
1069
- _abs(example.pose),
1070
- _abs(example.mask_video),
1071
- prompt,
1072
- example.replace_flag,
1073
- target_h,
1074
- target_w,
1075
- sample_steps,
1076
- guide_scale,
1077
- sample_shift,
1078
- seed,
1079
- segment_len,
1080
- segment_overlap,
1081
- additional_refs=refs,
1082
- progress=progress,
1083
- )
1084
- return output, "Done."
1085
- except Exception:
1086
- logging.exception("Generation failed")
1087
- return None, traceback.format_exc()
1088
-
1089
-
1090
- @spaces.GPU(duration=_duration_for_job, size=GPU_SIZE)
1091
- def generate_from_pack(
1092
- pack,
1093
- prompt,
1094
- sample_steps,
1095
- guide_scale,
1096
- sample_shift,
1097
- seed,
1098
- target_size,
1099
- segment_len,
1100
- segment_overlap,
1101
- progress=gr.Progress(track_tqdm=True),
1102
- ):
1103
- try:
1104
- progress(0.0, desc="Checking validated pack")
1105
- if not pack:
1106
- raise RuntimeError("Validate an Advanced Pack before generating.")
1107
- target_w, target_h = [int(v) for v in str(target_size).split("x")]
1108
- refs = tuple(
1109
- ReferencePair(ref["label"], ref["image"], ref["mask_image"])
1110
- for ref in pack.get("additional_refs", [])
1111
- )
1112
- output = _run_scail_job(
1113
- pack["image"],
1114
- pack["mask_image"],
1115
- pack["pose"],
1116
- pack["mask_video"],
1117
- prompt,
1118
- pack.get("replace_flag", False),
1119
- target_h,
1120
- target_w,
1121
- sample_steps,
1122
- guide_scale,
1123
- sample_shift,
1124
- seed,
1125
- segment_len,
1126
- segment_overlap,
1127
- additional_refs=refs,
1128
- progress=progress,
1129
- )
1130
- return output, "Done."
1131
- except Exception:
1132
- logging.exception("Generation failed")
1133
- return None, traceback.format_exc()
1134
-
1135
-
1136
- @spaces.GPU(duration=_duration_for_job, size=GPU_SIZE)
1137
- def generate_from_uploads(
1138
- image,
1139
- mask_image,
1140
- pose_video,
1141
- mask_video,
1142
- prompt,
1143
- mode,
1144
- sample_steps,
1145
- guide_scale,
1146
- sample_shift,
1147
- seed,
1148
- target_size,
1149
- segment_len,
1150
- segment_overlap,
1151
- progress=gr.Progress(track_tqdm=True),
1152
- ):
1153
- try:
1154
- progress(0.0, desc="Checking uploaded inputs")
1155
- required = {
1156
- "reference image": image,
1157
- "reference mask": mask_image,
1158
- "driving/rendered video": pose_video,
1159
- "driving mask video": mask_video,
1160
- }
1161
- missing = [name for name, value in required.items() if value is None]
1162
- if missing:
1163
- raise RuntimeError("Missing required input(s): " + ", ".join(missing))
1164
-
1165
- target_w, target_h = [int(v) for v in str(target_size).split("x")]
1166
- output = _run_scail_job(
1167
- image,
1168
- mask_image,
1169
- pose_video,
1170
- mask_video,
1171
- prompt,
1172
- mode == "replacement",
1173
- target_h,
1174
- target_w,
1175
- sample_steps,
1176
- guide_scale,
1177
- sample_shift,
1178
- seed,
1179
- segment_len,
1180
- segment_overlap,
1181
- progress=progress,
1182
- )
1183
- return output, "Done."
1184
- except Exception:
1185
- logging.exception("Generation failed")
1186
- return None, traceback.format_exc()
1187
-
1188
-
1189
- def _reference_gallery(example: PreparedExample):
1190
- items = [
1191
- (_abs(example.image), "Primary reference"),
1192
- (_abs(example.mask_image), "Primary mask"),
1193
- ]
1194
- for ref in example.additional_refs:
1195
- items.append((_abs(ref.image), ref.label))
1196
- items.append((_abs(ref.mask_image), f"{ref.label} mask"))
1197
- return items
1198
-
1199
-
1200
- def _reference_note(example: PreparedExample) -> str:
1201
- if not example.additional_refs:
1202
- return "Single-reference example."
1203
- return f"Multi-reference example: {len(example.additional_refs)} additional reference pair(s) are passed to SCAIL-2."
1204
-
1205
-
1206
- def load_example_preview(example_name):
1207
- examples = _existing_examples()
1208
- if example_name not in examples:
1209
- return None, None, None, None, "", "animation", [], "Example not available."
1210
- example = examples[example_name]
1211
- mode = "replacement" if example.replace_flag else "animation"
1212
- return (
1213
- _abs(example.image),
1214
- _abs(example.pose),
1215
- _abs(example.mask_image),
1216
- _abs(example.mask_video),
1217
- example.prompt,
1218
- mode,
1219
- _reference_gallery(example),
1220
- _reference_note(example),
1221
- )
1222
-
1223
-
1224
- def _startup_message():
1225
- try:
1226
- _require_repo_layout()
1227
- examples = _existing_examples()
1228
- if not examples:
1229
- return "Repo layout detected, but no prepared examples were found."
1230
- return (
1231
- f"Ready. Found {len(examples)} prepared example(s). "
1232
- f"Storage root: {STORAGE_ROOT}. Staging root: {STAGING_ROOT}. "
1233
- f"Output dir: {OUTPUT_DIR}.\n\n"
1234
- f"{_ASSET_STATUS}\n\n{_RUNTIME_STATUS}\n\n{_PIPELINE_STATUS}\n\n"
1235
- "Attention backend: HF Kernels flash-attn2 when available, otherwise SDPA."
1236
- )
1237
- except Exception as exc:
1238
- return str(exc)
1239
-
1240
-
1241
- def _sampling_controls(prefix: str = ""):
1242
- with gr.Row():
1243
- steps = gr.Slider(4, 40, value=8, step=1, label=f"{prefix}Steps".strip())
1244
- cfg = gr.Slider(1.0, 8.0, value=DEFAULT_GUIDE_SCALE, step=0.1, label=f"{prefix}CFG".strip())
1245
- shift = gr.Slider(1.0, 6.0, value=DEFAULT_SHIFT, step=0.1, label=f"{prefix}Shift".strip())
1246
- with gr.Row():
1247
- seed = gr.Number(value=42, precision=0, label=f"{prefix}Seed".strip())
1248
- target_size = gr.Dropdown(
1249
- ["896x512", "512x896", "1280x704", "704x1280"],
1250
- value=f"{DEFAULT_TARGET_W}x{DEFAULT_TARGET_H}",
1251
- label=f"{prefix}Target size".strip(),
1252
- )
1253
- segment_len = gr.Number(value=DEFAULT_SEGMENT_LEN, precision=0, label=f"{prefix}Segment length".strip())
1254
- segment_overlap = gr.Number(value=DEFAULT_SEGMENT_OVERLAP, precision=0, label=f"{prefix}Segment overlap".strip())
1255
- return steps, cfg, shift, seed, target_size, segment_len, segment_overlap
1256
-
1257
-
1258
- # ============================================================================
1259
- # NEW: generate_from_image_video — ทำ SAM3 mask บน GPU เอง (ส่งแค่รูป+วิดีโอ)
1260
- # ใช้ SCAIL-Pose e2e (process_one e2e_mode → SAM3 ผ่าน ultralytics) → _run_scail_job
1261
- # ============================================================================
1262
- import tempfile as _tempfile
1263
 
1264
  _SCAIL_POSE_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "SCAIL-Pose")
1265
  if _SCAIL_POSE_DIR not in sys.path:
@@ -1269,38 +26,11 @@ SAM3_MODEL_PATH = os.getenv(
1269
  "SAM3_MODEL", os.path.join(_SCAIL_POSE_DIR, "pretrained_weights", "sam3.pt")
1270
  )
1271
 
1272
- _sam3_predictor = None
1273
-
1274
-
1275
- def _get_sam3_predictor():
1276
- global _sam3_predictor
1277
- if _sam3_predictor is None:
1278
- from ultralytics.models.sam import SAM3VideoSemanticPredictor
1279
- overrides = dict(
1280
- conf=0.25, task="segment", mode="predict", imgsz=640,
1281
- model=SAM3_MODEL_PATH, half=True, save=False, verbose=False,
1282
- )
1283
- _sam3_predictor = SAM3VideoSemanticPredictor(overrides=overrides, new_det_thresh=1.0)
1284
- return _sam3_predictor
1285
-
1286
-
1287
- def _make_masks_e2e(subdir, video_name="driving.mp4",
1288
- text=("human", "character"), max_persons=1):
1289
- from NLFPoseExtract.process_animation_aio import process_one
1290
- predictor = _get_sam3_predictor()
1291
- process_one(
1292
- subdir, video_name, e2e_mode=True, crop_kind=None,
1293
- max_persons=int(max_persons), text=list(text),
1294
- model_nlf=None, detector=None, predictor=predictor, image_predictor=None,
1295
- )
1296
- return (
1297
- os.path.join(subdir, "ref_mask.jpg"),
1298
- os.path.join(subdir, "rendered_mask_v2.mp4"),
1299
- os.path.join(subdir, "rendered_v2.mp4"),
1300
- )
1301
 
1302
 
1303
  def _ensure_sam3():
 
1304
  if os.path.exists(SAM3_MODEL_PATH):
1305
  return
1306
  os.makedirs(os.path.dirname(SAM3_MODEL_PATH), exist_ok=True)
@@ -1312,315 +42,75 @@ def _ensure_sam3():
1312
  shutil.copyfile(f, SAM3_MODEL_PATH)
1313
 
1314
 
1315
- @spaces.GPU(duration=_duration_for_job, size=GPU_SIZE)
1316
- def generate_from_image_video(
1317
- image, pose_video, prompt, mode,
1318
- sample_steps, guide_scale, sample_shift, seed,
1319
- target_size, segment_len, segment_overlap,
1320
- progress=gr.Progress(track_tqdm=True),
1321
- ):
1322
- try:
1323
- if image is None or pose_video is None:
1324
- raise RuntimeError("Missing required input(s): reference image / pose video")
1325
- target_w, target_h = [int(v) for v in str(target_size).split("x")]
1326
- subdir = _tempfile.mkdtemp(prefix="scail_iv_")
1327
- ref_path = os.path.join(subdir, "ref.png")
1328
- drv_path = os.path.join(subdir, "driving.mp4")
1329
- shutil.copyfile(image, ref_path)
1330
- shutil.copyfile(pose_video, drv_path)
1331
- progress(0.05, desc="Masking with SAM3 on GPU ...")
1332
- ref_mask, drv_mask, rendered = _make_masks_e2e(
1333
- subdir, "driving.mp4", text=("human", "character"), max_persons=1,
1334
- )
1335
- progress(0.35, desc="Running SCAIL-2 generation ...")
1336
- output = _run_scail_job(
1337
- ref_path, ref_mask, rendered, drv_mask,
1338
- prompt, (mode == "replacement"), target_h, target_w,
1339
- sample_steps, guide_scale, sample_shift, seed,
1340
- segment_len, segment_overlap, progress=progress,
1341
- )
1342
- return output, "Done."
1343
- except Exception:
1344
- logging.exception("generate_from_image_video failed")
1345
- return None, traceback.format_exc()
1346
-
1347
-
1348
- def build_ui():
1349
- examples = _existing_examples()
1350
- example_names = list(examples.keys())
1351
- default_example = example_names[0] if example_names else None
1352
- default_preview = (
1353
- load_example_preview(default_example)
1354
- if default_example
1355
- else (None, None, None, None, "", "animation", [], "No prepared examples found.")
1356
- )
1357
-
1358
- with gr.Blocks(title="SCAIL-2 Character Animation Demo") as demo:
1359
- gr.Markdown(
1360
- "# SCAIL-2 Character Animation Demo\n"
1361
- "Try SCAIL-2 from curated examples or from already-prepared custom inputs. "
1362
- "The multi-reference example from the repo is handled as a prepared example: "
1363
- "its additional references are passed to the model automatically."
1364
  )
1365
- startup = gr.Textbox(value=_startup_message(), label="Startup status", interactive=False)
1366
-
1367
- with gr.Tab("Prepared Examples"):
1368
- with gr.Row():
1369
- example_dropdown = gr.Dropdown(choices=example_names, value=default_example, label="Example")
1370
- mode_view = gr.Textbox(value=default_preview[5], label="Mode", interactive=False)
1371
- reference_note = gr.Markdown(default_preview[7])
1372
- with gr.Accordion("Input preview", open=False):
1373
- with gr.Row():
1374
- ref_preview = gr.Image(value=default_preview[0], label="Primary reference", interactive=False)
1375
- driving_preview = gr.Video(value=default_preview[1], label="Driving / rendered video")
1376
- with gr.Row():
1377
- ref_mask_preview = gr.Image(value=default_preview[2], label="Primary mask", interactive=False)
1378
- driving_mask_preview = gr.Video(value=default_preview[3], label="Driving mask")
1379
- reference_gallery = gr.Gallery(
1380
- value=default_preview[6],
1381
- label="Reference set",
1382
- columns=4,
1383
- height=260,
1384
- selected_index=0,
1385
- preview=True,
1386
- )
1387
-
1388
- prompt = gr.Textbox(value=default_preview[4], label="Prompt", lines=3)
1389
- sample_steps, guide_scale, sample_shift, seed, target_size, segment_len, segment_overlap = _sampling_controls()
1390
-
1391
- run_example = gr.Button("Generate", variant="primary")
1392
- output_video = gr.Video(label="Output")
1393
- status = gr.Textbox(label="Run status", lines=8)
1394
-
1395
- example_dropdown.change(
1396
- load_example_preview,
1397
- inputs=[example_dropdown],
1398
- outputs=[
1399
- ref_preview,
1400
- driving_preview,
1401
- ref_mask_preview,
1402
- driving_mask_preview,
1403
- prompt,
1404
- mode_view,
1405
- reference_gallery,
1406
- reference_note,
1407
- ],
1408
- )
1409
- run_example.click(
1410
- generate_from_example,
1411
- inputs=[
1412
- example_dropdown,
1413
- prompt,
1414
- sample_steps,
1415
- guide_scale,
1416
- sample_shift,
1417
- seed,
1418
- target_size,
1419
- segment_len,
1420
- segment_overlap,
1421
- ],
1422
- outputs=[output_video, status],
1423
- )
1424
-
1425
- with gr.Tab("Custom Uploads"):
1426
- gr.Markdown(
1427
- "Upload a prepared SCAIL-2 input set: reference image, reference mask, "
1428
- "driving/rendered video, and driving mask video. This tab is intentionally "
1429
- "single-reference; use prepared examples for the official multi-reference case."
1430
- )
1431
- with gr.Row():
1432
- up_image = gr.Image(type="filepath", label="Reference image")
1433
- up_mask_image = gr.Image(type="filepath", label="Reference mask")
1434
- with gr.Row():
1435
- up_pose_video = gr.Video(label="Driving / rendered video")
1436
- up_mask_video = gr.Video(label="Driving mask / replace mask")
1437
- up_mode = gr.Radio(["animation", "replacement"], value="animation", label="Mode")
1438
- up_prompt = gr.Textbox(label="Prompt", lines=3)
1439
- up_steps, up_cfg, up_shift, up_seed, up_target_size, up_segment_len, up_segment_overlap = _sampling_controls()
1440
 
1441
- run_upload = gr.Button("Generate from uploads", variant="primary")
1442
- upload_output = gr.Video(label="Output")
1443
- upload_status = gr.Textbox(label="Run status", lines=8)
1444
- run_upload.click(
1445
- generate_from_uploads,
1446
- inputs=[
1447
- up_image,
1448
- up_mask_image,
1449
- up_pose_video,
1450
- up_mask_video,
1451
- up_prompt,
1452
- up_mode,
1453
- up_steps,
1454
- up_cfg,
1455
- up_shift,
1456
- up_seed,
1457
- up_target_size,
1458
- up_segment_len,
1459
- up_segment_overlap,
1460
- ],
1461
- outputs=[upload_output, upload_status],
1462
- )
1463
 
1464
- with gr.Tab("Image + Video (auto-mask)"):
1465
- gr.Markdown(
1466
- "Send only a **reference image** + **driving video**. "
1467
- "The Space runs SAM3 masking on the GPU (SCAIL-Pose e2e) and generates."
1468
- )
1469
- iv_image = gr.Image(type="filepath", label="Reference image")
1470
- iv_video = gr.Video(label="Driving / pose video")
1471
- iv_prompt = gr.Textbox(label="Prompt", value="A young woman with natural body movement.")
1472
- iv_mode = gr.Radio(["animation", "replacement"], value="animation", label="Mode")
1473
- with gr.Row():
1474
- iv_steps = gr.Slider(1, 50, value=8, step=1, label="Steps")
1475
- iv_cfg = gr.Slider(1, 10, value=1.0, step=0.5, label="CFG (guide_scale)")
1476
- iv_shift = gr.Slider(1, 10, value=3.0, step=0.5, label="Shift")
1477
- with gr.Row():
1478
- iv_seed = gr.Number(value=42, precision=0, label="Seed")
1479
- iv_target = gr.Dropdown(
1480
- ["512x896", "896x512", "704x1280", "1280x704"],
1481
- value="512x896", label="Target size",
1482
- )
1483
- iv_segment_len = gr.Number(value=81, precision=0, label="Segment length")
1484
- iv_segment_overlap = gr.Number(value=5, precision=0, label="Segment overlap")
1485
- run_iv = gr.Button("Generate (auto-mask)", variant="primary")
1486
- iv_output = gr.Video(label="Output")
1487
- iv_status = gr.Textbox(label="Run status")
1488
- run_iv.click(
1489
- generate_from_image_video,
1490
- inputs=[
1491
- iv_image, iv_video, iv_prompt, iv_mode,
1492
- iv_steps, iv_cfg, iv_shift, iv_seed,
1493
- iv_target, iv_segment_len, iv_segment_overlap,
1494
- ],
1495
- outputs=[iv_output, iv_status],
1496
- api_name="generate_from_image_video",
1497
- )
1498
-
1499
- with gr.Tab("Advanced Pack"):
1500
- gr.Markdown(
1501
- "Upload a `.zip` pack for multi-reference or multi-character inputs. "
1502
- "The app validates the file structure, selects one primary reference, and "
1503
- "passes every other image/mask pair as additional references to SCAIL-2."
1504
- )
1505
- with gr.Accordion("Pack format", open=False):
1506
- gr.Markdown(
1507
- "### Canonical zip structure\n"
1508
- "Use this layout when one or more characters have several reference views. "
1509
- "Each image must have a matching mask with the same stem plus `_mask`.\n\n"
1510
- "```text\n"
1511
- "scail2_input_pack/\n"
1512
- "|-- rendered_v2.mp4\n"
1513
- "|-- rendered_mask_v2.mp4\n"
1514
- "|-- prompt.txt # optional\n"
1515
- "|-- metadata.json # optional\n"
1516
- "|-- characters/\n"
1517
- "| |-- character_0/\n"
1518
- "| | |-- front.png\n"
1519
- "| | |-- front_mask.png\n"
1520
- "| | |-- back.png\n"
1521
- "| | `-- back_mask.png\n"
1522
- "| `-- character_1/\n"
1523
- "| |-- front.png\n"
1524
- "| `-- front_mask.png\n"
1525
- "`-- environment/\n"
1526
- " |-- background.png\n"
1527
- " `-- background_mask.png\n"
1528
- "```\n\n"
1529
- "### Mask convention\n"
1530
- "Colors represent identity slots, not individual views. If `character_0` has "
1531
- "front, back, and close-up references, all masks for those views should use the "
1532
- "same identity color. A different character gets a different color. The driving "
1533
- "mask video should use the same color assignments.\n\n"
1534
- "### Mapping to SCAIL-2\n"
1535
- "SCAIL-2 receives one primary reference plus a list of additional refs. The parser "
1536
- "uses `metadata.json` when a primary is declared. Otherwise it uses "
1537
- "`characters/character_0/front.*` or the first available view from `character_0`. "
1538
- "All remaining image/mask pairs become additional references.\n\n"
1539
- "### Legacy repo-style pack\n"
1540
- "The official multi-reference example also uses this flat layout, which is supported:\n\n"
1541
- "```text\n"
1542
- "ref.png\n"
1543
- "ref_mask.jpg\n"
1544
- "rendered_v2.mp4\n"
1545
- "rendered_mask_v2.mp4\n"
1546
- "background.png\n"
1547
- "background_mask.png\n"
1548
- "character_0.png\n"
1549
- "character_0_mask.png\n"
1550
- "character_1.png\n"
1551
- "character_1_mask.png\n"
1552
- "```\n"
1553
- )
1554
-
1555
- pack_state = gr.State(None)
1556
- pack_file = gr.File(
1557
- label="SCAIL-2 input pack (.zip)",
1558
- file_types=[".zip"],
1559
- type="filepath",
1560
- )
1561
- validate_pack = gr.Button("Validate pack")
1562
- pack_summary = gr.Markdown("Upload a pack and validate it before generating.")
1563
- pack_gallery = gr.Gallery(
1564
- label="Parsed reference set",
1565
- columns=4,
1566
- height=260,
1567
- selected_index=0,
1568
- preview=True,
1569
- )
1570
- with gr.Row():
1571
- pack_driving_preview = gr.Video(label="Driving / rendered video")
1572
- pack_mask_preview = gr.Video(label="Driving mask / replace mask")
1573
- pack_mode = gr.Textbox(value="animation", label="Mode", interactive=False)
1574
- pack_prompt = gr.Textbox(label="Prompt", lines=3)
1575
- pack_steps, pack_cfg, pack_shift, pack_seed, pack_target_size, pack_segment_len, pack_segment_overlap = _sampling_controls()
1576
- run_pack = gr.Button("Generate from pack", variant="primary")
1577
- pack_output = gr.Video(label="Output")
1578
- pack_status = gr.Textbox(label="Run status", lines=8)
1579
-
1580
- validate_pack.click(
1581
- validate_input_pack,
1582
- inputs=[pack_file],
1583
- outputs=[
1584
- pack_state,
1585
- pack_summary,
1586
- pack_gallery,
1587
- pack_driving_preview,
1588
- pack_mask_preview,
1589
- pack_prompt,
1590
- pack_mode,
1591
- ],
1592
- )
1593
- run_pack.click(
1594
- generate_from_pack,
1595
- inputs=[
1596
- pack_state,
1597
- pack_prompt,
1598
- pack_steps,
1599
- pack_cfg,
1600
- pack_shift,
1601
- pack_seed,
1602
- pack_target_size,
1603
- pack_segment_len,
1604
- pack_segment_overlap,
1605
- ],
1606
- outputs=[pack_output, pack_status],
1607
- )
1608
 
1609
- return demo
1610
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1611
 
1612
  if __name__ == "__main__":
1613
- if os.getenv("SCAIL_PRELOAD_ASSETS", "1") == "1":
1614
- _prepare_assets_for_runtime()
1615
- if os.getenv("SCAIL_PRELOAD_RUNTIME", "1") == "1":
1616
- _prepare_runtime_for_startup()
1617
- if PRELOAD_PIPELINE:
1618
- _prepare_pipeline_for_startup()
1619
  try:
1620
- _ensure_sam3() # โหลด sam3.pt (ครั้งแรก) — ต้องมี secret HF_TOKEN ที่ได้สิทธิ์ facebook/sam3
1621
  except Exception as _e:
1622
- logging.warning("SAM3 weights not ready: %s", _e)
1623
- build_ui().queue(max_size=8).launch(
1624
- allowed_paths=[str(OUTPUT_DIR.resolve())],
1625
- show_error=True,
1626
- )
 
1
+ # ============================================================================
2
+ # SCAIL SAM3 Mask Service (lightweight)
3
+ # รับ รูป + วิดีโอ → ทำ SAM3 mask บน GPU (SCAIL-Pose e2e) → คืน
4
+ # ref_mask.jpg + rendered_mask_v2.mp4 + rendered_v2.mp4 (driving copy, res ตรงกับ mask)
5
+ # ไม่มี checkpoint/generation → Space เบา start ได้ในฟรี ephemeral
6
+ # endpoint: /make_masks(image, video, max_persons) -> (ref_mask, driving_mask, rendered, status)
7
+ # ============================================================================
8
  import os
 
 
9
  import sys
10
+ import shutil
11
  import tempfile
12
+ import logging
13
  import traceback
 
 
 
 
 
14
 
15
  import gradio as gr
16
  import spaces
17
+ from huggingface_hub import hf_hub_download
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18
 
19
+ logging.basicConfig(level=logging.INFO)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
 
21
  _SCAIL_POSE_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "SCAIL-Pose")
22
  if _SCAIL_POSE_DIR not in sys.path:
 
26
  "SAM3_MODEL", os.path.join(_SCAIL_POSE_DIR, "pretrained_weights", "sam3.pt")
27
  )
28
 
29
+ _predictor = None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
30
 
31
 
32
  def _ensure_sam3():
33
+ """โหลด sam3.pt จาก facebook/sam3 (gated) — ต้องมี HF_TOKEN ที่ได้สิทธิ์."""
34
  if os.path.exists(SAM3_MODEL_PATH):
35
  return
36
  os.makedirs(os.path.dirname(SAM3_MODEL_PATH), exist_ok=True)
 
42
  shutil.copyfile(f, SAM3_MODEL_PATH)
43
 
44
 
45
+ def _get_predictor():
46
+ global _predictor
47
+ if _predictor is None:
48
+ from ultralytics.models.sam import SAM3VideoSemanticPredictor
49
+ overrides = dict(
50
+ conf=0.25, task="segment", mode="predict", imgsz=640,
51
+ model=SAM3_MODEL_PATH, half=True, save=False, verbose=False,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
52
  )
53
+ _predictor = SAM3VideoSemanticPredictor(overrides=overrides, new_det_thresh=1.0)
54
+ return _predictor
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
55
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
56
 
57
+ @spaces.GPU(duration=120)
58
+ def make_masks(image, video, max_persons=1):
59
+ """รูป + วิดีโอ SAM3 mask (e2e). คืน (ref_mask.jpg, rendered_mask_v2.mp4, rendered_v2.mp4, status)."""
60
+ try:
61
+ if image is None or video is None:
62
+ return None, None, None, "Missing image or video"
63
+ _ensure_sam3()
64
+ from NLFPoseExtract.process_animation_aio import process_one
65
+
66
+ subdir = tempfile.mkdtemp(prefix="mask_")
67
+ shutil.copyfile(image, os.path.join(subdir, "ref.png"))
68
+ shutil.copyfile(video, os.path.join(subdir, "driving.mp4"))
69
+
70
+ process_one(
71
+ subdir, "driving.mp4", e2e_mode=True, crop_kind=None,
72
+ max_persons=int(max_persons), text=["human", "character"],
73
+ model_nlf=None, detector=None, predictor=_get_predictor(), image_predictor=None,
74
+ )
75
+ ref_mask = os.path.join(subdir, "ref_mask.jpg")
76
+ drv_mask = os.path.join(subdir, "rendered_mask_v2.mp4")
77
+ rendered = os.path.join(subdir, "rendered_v2.mp4")
78
+ if not (os.path.exists(ref_mask) and os.path.exists(drv_mask)):
79
+ return None, None, None, "Mask generation produced no output"
80
+ return ref_mask, drv_mask, rendered, "OK"
81
+ except Exception:
82
+ logging.exception("make_masks failed")
83
+ return None, None, None, traceback.format_exc()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
84
 
 
85
 
86
+ with gr.Blocks(title="SCAIL SAM3 Mask Service") as demo:
87
+ gr.Markdown(
88
+ "## SCAIL-2 SAM3 Masking Service\n"
89
+ "Send a **reference image** + **driving video** → returns colored SAM3 masks "
90
+ "(`ref_mask.jpg`, `rendered_mask_v2.mp4`) + the driving copy (`rendered_v2.mp4`, same res as mask). "
91
+ "Feed these to `fffiloni/SCAIL-2` `/generate_from_uploads`."
92
+ )
93
+ with gr.Row():
94
+ with gr.Column():
95
+ m_image = gr.Image(type="filepath", label="Reference image")
96
+ m_video = gr.Video(label="Driving video")
97
+ m_persons = gr.Number(value=1, precision=0, label="Max persons")
98
+ m_run = gr.Button("Make masks", variant="primary")
99
+ m_status = gr.Textbox(label="Status")
100
+ with gr.Column():
101
+ m_refmask = gr.Image(type="filepath", label="ref_mask")
102
+ m_drvmask = gr.Video(label="driving mask (rendered_mask_v2)")
103
+ m_rendered = gr.Video(label="rendered (driving copy)")
104
+ m_run.click(
105
+ make_masks,
106
+ inputs=[m_image, m_video, m_persons],
107
+ outputs=[m_refmask, m_drvmask, m_rendered, m_status],
108
+ api_name="make_masks",
109
+ )
110
 
111
  if __name__ == "__main__":
 
 
 
 
 
 
112
  try:
113
+ _ensure_sam3()
114
  except Exception as _e:
115
+ logging.warning("SAM3 weights not ready at startup: %s", _e)
116
+ demo.queue(max_size=8).launch()
 
 
 
requirements.txt CHANGED
@@ -2,22 +2,11 @@ gradio==6.19.0
2
  spaces
3
  torch==2.11.0
4
  torchvision==0.26.0
5
- kernels>=0.16,<0.17
6
  huggingface_hub[hf_xet]
7
- safetensors
8
- diffusers>=0.31.0
9
- transformers>=4.49.0
10
- tokenizers>=0.20.3
11
- accelerate>=1.1.1
12
- opencv-python-headless>=4.9.0.80
13
  decord
14
  imageio
15
  imageio-ffmpeg
16
- easydict
17
- ftfy
18
- tqdm
19
- einops
20
  numpy>=1.23.5,<2
21
-
22
- ultralytics
23
- moviepy>=1.0.3
 
2
  spaces
3
  torch==2.11.0
4
  torchvision==0.26.0
 
5
  huggingface_hub[hf_xet]
6
+ ultralytics
7
+ moviepy>=1.0.3
 
 
 
 
8
  decord
9
  imageio
10
  imageio-ffmpeg
11
+ opencv-python-headless>=4.9.0.80
 
 
 
12
  numpy>=1.23.5,<2