fffiloni commited on
Commit
7b2c15e
·
verified ·
1 Parent(s): 2eefe64

clean UI and Advanced Pack for multi refs & multi characters

Browse files
Files changed (1) hide show
  1. app.py +623 -112
app.py CHANGED
@@ -1,4 +1,5 @@
1
  import gc
 
2
  import logging
3
  import os
4
  import shutil
@@ -7,6 +8,7 @@ import sys
7
  import tempfile
8
  import traceback
9
  import uuid
 
10
  from dataclasses import dataclass
11
  from pathlib import Path
12
  from types import SimpleNamespace
@@ -41,8 +43,8 @@ SAFETENSORS_REPO_ID = os.getenv("SCAIL_SAFETENSORS_REPO_ID")
41
  SAFETENSORS_FILENAME = os.getenv("SCAIL_SAFETENSORS_FILENAME", "SCAIL-2.safetensors")
42
  MODEL_NAME = os.getenv("SCAIL_MODEL_NAME", "SCAIL-14B")
43
  GPU_SIZE = os.getenv("SCAIL_ZEROGPU_SIZE", "xlarge")
44
- GPU_DURATION_COLD = int(os.getenv("SCAIL_GPU_DURATION_COLD", "900"))
45
- GPU_DURATION_WARM = int(os.getenv("SCAIL_GPU_DURATION_WARM", "360"))
46
  DEFAULT_TARGET_H = int(os.getenv("SCAIL_TARGET_H", "512"))
47
  DEFAULT_TARGET_W = int(os.getenv("SCAIL_TARGET_W", "896"))
48
  DEFAULT_SEGMENT_LEN = int(os.getenv("SCAIL_SEGMENT_LEN", "81"))
@@ -54,6 +56,8 @@ AUTO_CONVERT = os.getenv("SCAIL_AUTO_CONVERT", "1") == "1"
54
  PRELOAD_PIPELINE = os.getenv("SCAIL_PRELOAD_PIPELINE", "1") == "1"
55
  STAGE_SAFETENSORS_FOR_LOAD = os.getenv("SCAIL_STAGE_SAFETENSORS_FOR_LOAD", "1") == "1"
56
  CONVERT_TO_STAGING_FIRST = os.getenv("SCAIL_CONVERT_TO_STAGING_FIRST", "1") == "1"
 
 
57
  CLIP_CKPT_NAME = "models_clip_open-clip-xlm-roberta-large-vit-huge-14-onlyvisual.pth"
58
  ORIGINAL_DIT_REL_PATH = "model/1/fsdp2_rank_0000_checkpoint.pt"
59
  BASE_ALLOW_PATTERNS = [
@@ -77,6 +81,13 @@ _SCAIL_CONFIGS = None
77
  _SCAIL_CONFIG_PATHS = None
78
 
79
 
 
 
 
 
 
 
 
80
  @dataclass(frozen=True)
81
  class PreparedExample:
82
  label: str
@@ -86,6 +97,7 @@ class PreparedExample:
86
  mask_video: str
87
  prompt: str
88
  replace_flag: bool = False
 
89
 
90
 
91
  PREPARED_EXAMPLES = {
@@ -120,6 +132,23 @@ PREPARED_EXAMPLES = {
120
  pose="examples/animation_003_multi_ref/rendered_v2.mp4",
121
  mask_video="examples/animation_003_multi_ref/rendered_mask_v2.mp4",
122
  prompt="A character performs the motion from the driving video.",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
123
  ),
124
  "Replacement 001": PreparedExample(
125
  label="Replacement 001",
@@ -143,15 +172,307 @@ def _abs(path: str | Path) -> str:
143
  return str(path)
144
 
145
 
 
 
 
 
 
 
 
146
  def _existing_examples() -> dict[str, PreparedExample]:
147
  available = {}
148
  for name, example in PREPARED_EXAMPLES.items():
149
- paths = [example.image, example.mask_image, example.pose, example.mask_video]
150
- if all(Path(_abs(p)).exists() for p in paths):
151
  available[name] = example
152
  return available
153
 
154
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
155
  def _require_repo_layout():
156
  missing = []
157
  for rel in ("wan/scail.py", "wan/modules/model_scail2.py", "generate.py", "configs/config-14b.json"):
@@ -174,11 +495,7 @@ def _download_safetensors_if_configured() -> Path | None:
174
  if local_path.exists():
175
  return local_path
176
 
177
- logging.info(
178
- "Downloading converted SCAIL-2 safetensors from %s/%s",
179
- SAFETENSORS_REPO_ID,
180
- SAFETENSORS_FILENAME,
181
- )
182
  downloaded = hf_hub_download(
183
  repo_id=SAFETENSORS_REPO_ID,
184
  filename=SAFETENSORS_FILENAME,
@@ -203,10 +520,7 @@ def _find_converted_safetensors(ckpt_dir: Path | None) -> Path | None:
203
  Path(os.getenv("SCAIL_CONVERTED_DIR", str(STORAGE_ROOT / "scail2_converted"))) / "SCAIL-2.safetensors",
204
  ]
205
  if ckpt_dir is not None:
206
- candidates += [
207
- ckpt_dir / "SCAIL-2.safetensors",
208
- ckpt_dir / "model.safetensors",
209
- ]
210
  for candidate in candidates:
211
  if candidate.exists():
212
  return candidate
@@ -231,11 +545,7 @@ def _copy_file_with_progress(source: Path, dest: Path, description: str) -> Path
231
  logging.info("%s: %s -> %s", description, source, dest)
232
  next_log = ((copied // log_every) + 1) * log_every if log_every > 0 else source_size
233
  if copied:
234
- logging.info(
235
- "Resuming copy at %.2f/%.2f GB",
236
- copied / 1024**3,
237
- source_size / 1024**3,
238
- )
239
 
240
  with source.open("rb") as src, tmp_dest.open("ab") as dst:
241
  if copied:
@@ -247,12 +557,7 @@ def _copy_file_with_progress(source: Path, dest: Path, description: str) -> Path
247
  dst.write(chunk)
248
  copied += len(chunk)
249
  if log_every > 0 and copied >= next_log:
250
- logging.info(
251
- "%s: %.2f/%.2f GB",
252
- description,
253
- copied / 1024**3,
254
- source_size / 1024**3,
255
- )
256
  next_log += log_every
257
 
258
  if tmp_dest.stat().st_size != source_size:
@@ -279,11 +584,9 @@ def _stage_safetensors_for_load(scail_path: Path) -> Path:
279
  return source
280
 
281
  stage_dir = Path(os.getenv("SCAIL_MODEL_LOAD_CACHE", str(STAGING_ROOT / "scail2_model_load")))
282
- stage_dir.mkdir(parents=True, exist_ok=True)
283
  staged = stage_dir / source.name
284
  if staged.exists() and staged.stat().st_size == source.stat().st_size:
285
  return staged
286
-
287
  return _copy_file_with_progress(source, staged, "Staging SCAIL-2 safetensors for local load")
288
 
289
 
@@ -304,13 +607,9 @@ def _download_checkpoint_if_needed(include_original_dit: bool = False) -> Path:
304
  if has_base_assets:
305
  return local_dir
306
 
307
- logging.info("Downloading SCAIL-2 base checkpoint assets from %s", MODEL_REPO_ID)
308
  if include_original_dit:
309
- logging.warning(
310
- "include_original_dit is deprecated here; original DiT conversion staging "
311
- "uses SCAIL_ORIGINAL_DIT_CACHE instead."
312
- )
313
-
314
  snapshot_download(
315
  repo_id=MODEL_REPO_ID,
316
  local_dir=str(local_dir),
@@ -335,10 +634,7 @@ def _download_original_dit_for_conversion() -> Path:
335
  if original_path.exists():
336
  return local_dir
337
 
338
- logging.info(
339
- "Downloading original SCAIL-2 DiT checkpoint for one-time conversion into %s",
340
- local_dir,
341
- )
342
  snapshot_download(
343
  repo_id=MODEL_REPO_ID,
344
  local_dir=str(local_dir),
@@ -350,7 +646,6 @@ def _download_original_dit_for_conversion() -> Path:
350
 
351
 
352
  def _prepare_assets_for_runtime() -> str:
353
- """Download CPU-side assets before any ZeroGPU function starts."""
354
  global _ASSET_STATUS, _ASSET_ERROR
355
  try:
356
  ckpt_dir = _download_checkpoint_if_needed(include_original_dit=False)
@@ -360,16 +655,11 @@ def _prepare_assets_for_runtime() -> str:
360
  scail_path = _maybe_convert_checkpoint(original_dir, None)
361
  if scail_path is None:
362
  _ASSET_STATUS = (
363
- "Base checkpoint assets are present, but no converted safetensors file "
364
- "was found. Set SCAIL_SAFETENSORS_REPO_ID plus SCAIL_SAFETENSORS_FILENAME, "
365
- "or set SCAIL_SAFETENSORS_PATH. Automatic startup conversion is disabled "
366
- "only when SCAIL_AUTO_CONVERT=0."
367
  )
368
  else:
369
- _ASSET_STATUS = (
370
- "Assets ready. Base checkpoint: "
371
- f"{ckpt_dir}. Converted DiT safetensors: {scail_path}."
372
- )
373
  _ASSET_ERROR = None
374
  except Exception:
375
  _ASSET_ERROR = traceback.format_exc()
@@ -385,10 +675,8 @@ def _maybe_convert_checkpoint(ckpt_dir: Path, scail_path: Path | None) -> Path:
385
 
386
  if not AUTO_CONVERT:
387
  raise RuntimeError(
388
- "Converted SCAIL-2 safetensors file was not found. For the wan-scail2 branch, "
389
- "provide SCAIL_SAFETENSORS_PATH=/path/to/SCAIL-2.safetensors, or place "
390
- "SCAIL-2.safetensors at the repo root. Automatic startup conversion is disabled "
391
- "because SCAIL_AUTO_CONVERT=0."
392
  )
393
 
394
  persistent_dir = Path(os.getenv("SCAIL_CONVERTED_DIR", str(STORAGE_ROOT / "scail2_converted")))
@@ -396,9 +684,8 @@ def _maybe_convert_checkpoint(ckpt_dir: Path, scail_path: Path | None) -> Path:
396
  if persistent_path.exists():
397
  return persistent_path
398
 
399
- if CONVERT_TO_STAGING_FIRST:
400
- save_dir = Path(os.getenv("SCAIL_CONVERSION_WORK_DIR", str(STAGING_ROOT / "scail2_converted_work")))
401
- else:
402
  save_dir = persistent_dir
403
  save_dir.mkdir(parents=True, exist_ok=True)
404
  save_path = save_dir / "SCAIL-2.safetensors"
@@ -410,9 +697,6 @@ def _maybe_convert_checkpoint(ckpt_dir: Path, scail_path: Path | None) -> Path:
410
 
411
  logging.info("Converting checkpoint to safetensors: %s", save_path)
412
  convert_env = os.environ.copy()
413
- # PyTorch >= 2.6 changed torch.load's default to weights_only=True.
414
- # The official SCAIL-2 FSDP checkpoint needs the legacy trusted-pickle path
415
- # during conversion. Only do this for the official checkpoint conversion step.
416
  convert_env["TORCH_FORCE_NO_WEIGHTS_ONLY_LOAD"] = "1"
417
  subprocess.run(
418
  [
@@ -434,11 +718,6 @@ def _maybe_convert_checkpoint(ckpt_dir: Path, scail_path: Path | None) -> Path:
434
 
435
 
436
  def _install_attention_patch():
437
- """Use HF Kernels flash-attn2 when available; otherwise fall back to SDPA.
438
-
439
- SCAIL-2 imports `flash_attention` directly in model_scail2.py. This monkey patch
440
- avoids requiring a locally built `flash_attn` wheel on Spaces.
441
- """
442
  import wan.modules.attention as attention_mod
443
 
444
  hf_flash_attn2 = None
@@ -508,7 +787,6 @@ def _install_attention_patch():
508
 
509
  cu_q = torch.cat([q_lens_t.new_zeros([1]), q_lens_t]).cumsum(0, dtype=torch.int32)
510
  cu_k = torch.cat([k_lens_t.new_zeros([1]), k_lens_t]).cumsum(0, dtype=torch.int32)
511
-
512
  try:
513
  out = hf_flash_attn2.flash_attn_varlen_func(
514
  q=q_var,
@@ -550,9 +828,6 @@ def _install_attention_patch():
550
  return out.transpose(1, 2).contiguous().type(out_dtype)
551
 
552
  attention_mod.flash_attention = patched_flash_attention
553
-
554
- # Several modules import `flash_attention` directly at import time
555
- # (`from .attention import flash_attention`), so patch those bound names too.
556
  for module_name in (
557
  "wan.modules.clip",
558
  "wan.modules.model",
@@ -589,7 +864,6 @@ def _import_runtime():
589
 
590
 
591
  def _prepare_runtime_for_startup() -> str:
592
- """Import Wan/SCAIL and resolve HF Kernels before any ZeroGPU call."""
593
  global _RUNTIME_STATUS, _RUNTIME_ERROR
594
  try:
595
  _import_runtime()
@@ -613,6 +887,7 @@ def _get_pipeline():
613
  scail_path = _maybe_convert_checkpoint(original_dir, None)
614
  else:
615
  scail_path = _maybe_convert_checkpoint(ckpt_dir, scail_path)
 
616
  scail_load_path = _stage_safetensors_for_load(scail_path)
617
  config_path = Path(os.getenv("SCAIL_CONFIG_PATH", _SCAIL_CONFIG_PATHS[MODEL_NAME]))
618
  if not config_path.is_absolute():
@@ -645,7 +920,6 @@ def _get_pipeline():
645
 
646
 
647
  def _prepare_pipeline_for_startup() -> str:
648
- """Preload the CUDA pipeline at module startup for ZeroGPU CUDA emulation."""
649
  global _PIPELINE_STATUS, _PIPELINE_ERROR
650
  try:
651
  _get_pipeline()
@@ -658,6 +932,27 @@ def _prepare_pipeline_for_startup() -> str:
658
  return _PIPELINE_STATUS if _PIPELINE_ERROR is None else _PIPELINE_STATUS + "\n\n" + _PIPELINE_ERROR
659
 
660
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
661
  def _duration_for_job(*args, **kwargs):
662
  if _PIPELINE is None:
663
  return int(os.getenv("SCAIL_GPU_DURATION", str(GPU_DURATION_COLD)))
@@ -679,6 +974,7 @@ def _run_scail_job(
679
  seed,
680
  segment_len,
681
  segment_overlap,
 
682
  progress=None,
683
  ):
684
  if progress is not None:
@@ -686,6 +982,7 @@ def _run_scail_job(
686
  pipeline = _get_pipeline()
687
  cfg = _SCAIL_CONFIGS[MODEL_NAME]
688
  save_file = OUTPUT_DIR / f"scail2_{uuid.uuid4().hex}.mp4"
 
689
  if progress is not None:
690
  progress(0.12, desc="Preparing inputs")
691
  args = SimpleNamespace(
@@ -704,6 +1001,13 @@ def _run_scail_job(
704
  prompt=prompt or "",
705
  )
706
 
 
 
 
 
 
 
 
707
  if progress is not None:
708
  progress(0.15, desc="Generating video")
709
  _GENERATE_VIDEO(
@@ -719,16 +1023,20 @@ def _run_scail_job(
719
  cfg=cfg,
720
  input_idx=None,
721
  replace_flag=bool(replace_flag),
722
- additional_task_input=None,
723
  )
 
724
  if progress is not None:
725
  progress(0.95, desc="Finalizing output")
726
  gc.collect()
727
  if torch.cuda.is_available():
728
  torch.cuda.empty_cache()
 
 
 
729
  if progress is not None:
730
  progress(1.0, desc="Done")
731
- return str(save_file)
732
 
733
 
734
  @spaces.GPU(duration=_duration_for_job, size=GPU_SIZE)
@@ -751,6 +1059,10 @@ def generate_from_example(
751
  raise RuntimeError(f"Example is missing from this checkout: {example_name}")
752
  example = examples[example_name]
753
  target_w, target_h = [int(v) for v in str(target_size).split("x")]
 
 
 
 
754
  output = _run_scail_job(
755
  _abs(example.image),
756
  _abs(example.mask_image),
@@ -766,6 +1078,53 @@ def generate_from_example(
766
  seed,
767
  segment_len,
768
  segment_overlap,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
769
  progress=progress,
770
  )
771
  return output, "Done."
@@ -827,10 +1186,27 @@ def generate_from_uploads(
827
  return None, traceback.format_exc()
828
 
829
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
830
  def load_example_preview(example_name):
831
  examples = _existing_examples()
832
  if example_name not in examples:
833
- return None, None, None, None, "", "animation"
834
  example = examples[example_name]
835
  mode = "replacement" if example.replace_flag else "animation"
836
  return (
@@ -840,6 +1216,8 @@ def load_example_preview(example_name):
840
  _abs(example.mask_video),
841
  example.prompt,
842
  mode,
 
 
843
  )
844
 
845
 
@@ -860,49 +1238,65 @@ def _startup_message():
860
  return str(exc)
861
 
862
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
863
  def build_ui():
864
  examples = _existing_examples()
865
  example_names = list(examples.keys())
866
  default_example = example_names[0] if example_names else None
867
- default_preview = load_example_preview(default_example) if default_example else (None, None, None, None, "", "animation")
 
 
 
 
868
 
869
- with gr.Blocks(title="SCAIL-2 ZeroGPU Demo") as demo:
870
  gr.Markdown(
871
- "# SCAIL-2 Demo\n"
872
- "V1 inference-only Space. It runs SCAIL-2 from prepared inputs: reference image, "
873
- "reference mask, driving/rendered video, and driving mask video. SCAIL-Pose/SAM3 "
874
- "preprocessing is intentionally not imported in this version.\n\n"
875
- "On ZeroGPU, each generation should be treated as a cold start: the GPU can be "
876
- "released between calls, so the 14B stack may need to be loaded again before "
877
- "sampling."
878
  )
879
  startup = gr.Textbox(value=_startup_message(), label="Startup status", interactive=False)
880
 
881
  with gr.Tab("Prepared Examples"):
882
  with gr.Row():
883
- example_dropdown = gr.Dropdown(
884
- choices=example_names,
885
- value=default_example,
886
- label="Example",
887
- )
888
  mode_view = gr.Textbox(value=default_preview[5], label="Mode", interactive=False)
889
- with gr.Row():
890
- ref_preview = gr.Image(value=default_preview[0], label="Reference", interactive=False)
891
- driving_preview = gr.Video(value=default_preview[1], label="Driving / rendered video")
892
- with gr.Row():
893
- ref_mask_preview = gr.Image(value=default_preview[2], label="Reference mask", interactive=False)
894
- driving_mask_preview = gr.Video(value=default_preview[3], label="Driving mask")
895
- prompt = gr.Textbox(value=default_preview[4], label="Prompt", lines=3)
 
 
 
 
 
 
 
 
 
896
 
897
- with gr.Row():
898
- sample_steps = gr.Slider(4, 40, value=8, step=1, label="Steps")
899
- guide_scale = gr.Slider(1.0, 8.0, value=DEFAULT_GUIDE_SCALE, step=0.1, label="CFG")
900
- sample_shift = gr.Slider(1.0, 6.0, value=DEFAULT_SHIFT, step=0.1, label="Shift")
901
- with gr.Row():
902
- seed = gr.Number(value=42, precision=0, label="Seed")
903
- target_size = gr.Dropdown(["896x512", "512x896", "1280x704", "704x1280"], value=f"{DEFAULT_TARGET_W}x{DEFAULT_TARGET_H}", label="Target size")
904
- segment_len = gr.Number(value=DEFAULT_SEGMENT_LEN, precision=0, label="Segment length")
905
- segment_overlap = gr.Number(value=DEFAULT_SEGMENT_OVERLAP, precision=0, label="Segment overlap")
906
 
907
  run_example = gr.Button("Generate", variant="primary")
908
  output_video = gr.Video(label="Output")
@@ -911,7 +1305,16 @@ def build_ui():
911
  example_dropdown.change(
912
  load_example_preview,
913
  inputs=[example_dropdown],
914
- outputs=[ref_preview, driving_preview, ref_mask_preview, driving_mask_preview, prompt, mode_view],
 
 
 
 
 
 
 
 
 
915
  )
916
  run_example.click(
917
  generate_from_example,
@@ -929,9 +1332,11 @@ def build_ui():
929
  outputs=[output_video, status],
930
  )
931
 
932
- with gr.Tab("Advanced Uploads"):
933
  gr.Markdown(
934
- "Upload already-preprocessed inputs. For V1, this tab does not run SAM3 or SCAIL-Pose."
 
 
935
  )
936
  with gr.Row():
937
  up_image = gr.Image(type="filepath", label="Reference image")
@@ -941,15 +1346,8 @@ def build_ui():
941
  up_mask_video = gr.Video(label="Driving mask / replace mask")
942
  up_mode = gr.Radio(["animation", "replacement"], value="animation", label="Mode")
943
  up_prompt = gr.Textbox(label="Prompt", lines=3)
944
- with gr.Row():
945
- up_steps = gr.Slider(4, 40, value=8, step=1, label="Steps")
946
- up_cfg = gr.Slider(1.0, 8.0, value=DEFAULT_GUIDE_SCALE, step=0.1, label="CFG")
947
- up_shift = gr.Slider(1.0, 6.0, value=DEFAULT_SHIFT, step=0.1, label="Shift")
948
- with gr.Row():
949
- up_seed = gr.Number(value=42, precision=0, label="Seed")
950
- up_target_size = gr.Dropdown(["896x512", "512x896", "1280x704", "704x1280"], value=f"{DEFAULT_TARGET_W}x{DEFAULT_TARGET_H}", label="Target size")
951
- up_segment_len = gr.Number(value=DEFAULT_SEGMENT_LEN, precision=0, label="Segment length")
952
- up_segment_overlap = gr.Number(value=DEFAULT_SEGMENT_OVERLAP, precision=0, label="Segment overlap")
953
  run_upload = gr.Button("Generate from uploads", variant="primary")
954
  upload_output = gr.Video(label="Output")
955
  upload_status = gr.Textbox(label="Run status", lines=8)
@@ -973,6 +1371,116 @@ def build_ui():
973
  outputs=[upload_output, upload_status],
974
  )
975
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
976
  return demo
977
 
978
 
@@ -983,4 +1491,7 @@ if __name__ == "__main__":
983
  _prepare_runtime_for_startup()
984
  if PRELOAD_PIPELINE:
985
  _prepare_pipeline_for_startup()
986
- build_ui().queue(max_size=8).launch()
 
 
 
 
1
  import gc
2
+ import json
3
  import logging
4
  import os
5
  import shutil
 
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
 
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"))
 
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 = [
 
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
 
97
  mask_video: str
98
  prompt: str
99
  replace_flag: bool = False
100
+ additional_refs: tuple[ReferencePair, ...] = ()
101
 
102
 
103
  PREPARED_EXAMPLES = {
 
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",
 
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"):
 
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,
 
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
 
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:
 
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:
 
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
 
 
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),
 
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),
 
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)
 
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()
 
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")))
 
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"
 
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
  [
 
718
 
719
 
720
  def _install_attention_patch():
 
 
 
 
 
721
  import wan.modules.attention as attention_mod
722
 
723
  hf_flash_attn2 = None
 
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,
 
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",
 
864
 
865
 
866
  def _prepare_runtime_for_startup() -> str:
 
867
  global _RUNTIME_STATUS, _RUNTIME_ERROR
868
  try:
869
  _import_runtime()
 
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():
 
920
 
921
 
922
  def _prepare_pipeline_for_startup() -> str:
 
923
  global _PIPELINE_STATUS, _PIPELINE_ERROR
924
  try:
925
  _get_pipeline()
 
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)))
 
974
  seed,
975
  segment_len,
976
  segment_overlap,
977
+ additional_refs: tuple[ReferencePair, ...] = (),
978
  progress=None,
979
  ):
980
  if progress is not None:
 
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(
 
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(
 
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)
 
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),
 
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."
 
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 (
 
1216
  _abs(example.mask_video),
1217
  example.prompt,
1218
  mode,
1219
+ _reference_gallery(example),
1220
+ _reference_note(example),
1221
  )
1222
 
1223
 
 
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
  def build_ui():
1259
  examples = _existing_examples()
1260
  example_names = list(examples.keys())
1261
  default_example = example_names[0] if example_names else None
1262
+ default_preview = (
1263
+ load_example_preview(default_example)
1264
+ if default_example
1265
+ else (None, None, None, None, "", "animation", [], "No prepared examples found.")
1266
+ )
1267
 
1268
+ with gr.Blocks(title="SCAIL-2 Character Animation Demo") as demo:
1269
  gr.Markdown(
1270
+ "# SCAIL-2 Character Animation Demo\n"
1271
+ "Try SCAIL-2 from curated examples or from already-prepared custom inputs. "
1272
+ "The multi-reference example from the repo is handled as a prepared example: "
1273
+ "its additional references are passed to the model automatically."
 
 
 
1274
  )
1275
  startup = gr.Textbox(value=_startup_message(), label="Startup status", interactive=False)
1276
 
1277
  with gr.Tab("Prepared Examples"):
1278
  with gr.Row():
1279
+ example_dropdown = gr.Dropdown(choices=example_names, value=default_example, label="Example")
 
 
 
 
1280
  mode_view = gr.Textbox(value=default_preview[5], label="Mode", interactive=False)
1281
+ reference_note = gr.Markdown(default_preview[7])
1282
+ with gr.Accordion("Input preview", open=False):
1283
+ with gr.Row():
1284
+ ref_preview = gr.Image(value=default_preview[0], label="Primary reference", interactive=False)
1285
+ driving_preview = gr.Video(value=default_preview[1], label="Driving / rendered video")
1286
+ with gr.Row():
1287
+ ref_mask_preview = gr.Image(value=default_preview[2], label="Primary mask", interactive=False)
1288
+ driving_mask_preview = gr.Video(value=default_preview[3], label="Driving mask")
1289
+ reference_gallery = gr.Gallery(
1290
+ value=default_preview[6],
1291
+ label="Reference set",
1292
+ columns=4,
1293
+ height=260,
1294
+ selected_index=0,
1295
+ preview=True,
1296
+ )
1297
 
1298
+ prompt = gr.Textbox(value=default_preview[4], label="Prompt", lines=3)
1299
+ sample_steps, guide_scale, sample_shift, seed, target_size, segment_len, segment_overlap = _sampling_controls()
 
 
 
 
 
 
 
1300
 
1301
  run_example = gr.Button("Generate", variant="primary")
1302
  output_video = gr.Video(label="Output")
 
1305
  example_dropdown.change(
1306
  load_example_preview,
1307
  inputs=[example_dropdown],
1308
+ outputs=[
1309
+ ref_preview,
1310
+ driving_preview,
1311
+ ref_mask_preview,
1312
+ driving_mask_preview,
1313
+ prompt,
1314
+ mode_view,
1315
+ reference_gallery,
1316
+ reference_note,
1317
+ ],
1318
  )
1319
  run_example.click(
1320
  generate_from_example,
 
1332
  outputs=[output_video, status],
1333
  )
1334
 
1335
+ with gr.Tab("Custom Uploads"):
1336
  gr.Markdown(
1337
+ "Upload a prepared SCAIL-2 input set: reference image, reference mask, "
1338
+ "driving/rendered video, and driving mask video. This tab is intentionally "
1339
+ "single-reference; use prepared examples for the official multi-reference case."
1340
  )
1341
  with gr.Row():
1342
  up_image = gr.Image(type="filepath", label="Reference image")
 
1346
  up_mask_video = gr.Video(label="Driving mask / replace mask")
1347
  up_mode = gr.Radio(["animation", "replacement"], value="animation", label="Mode")
1348
  up_prompt = gr.Textbox(label="Prompt", lines=3)
1349
+ up_steps, up_cfg, up_shift, up_seed, up_target_size, up_segment_len, up_segment_overlap = _sampling_controls()
1350
+
 
 
 
 
 
 
 
1351
  run_upload = gr.Button("Generate from uploads", variant="primary")
1352
  upload_output = gr.Video(label="Output")
1353
  upload_status = gr.Textbox(label="Run status", lines=8)
 
1371
  outputs=[upload_output, upload_status],
1372
  )
1373
 
1374
+ with gr.Tab("Advanced Pack"):
1375
+ gr.Markdown(
1376
+ "Upload a `.zip` pack for multi-reference or multi-character inputs. "
1377
+ "The app validates the file structure, selects one primary reference, and "
1378
+ "passes every other image/mask pair as additional references to SCAIL-2."
1379
+ )
1380
+ with gr.Accordion("Pack format", open=False):
1381
+ gr.Markdown(
1382
+ "### Canonical zip structure\n"
1383
+ "Use this layout when one or more characters have several reference views. "
1384
+ "Each image must have a matching mask with the same stem plus `_mask`.\n\n"
1385
+ "```text\n"
1386
+ "scail2_input_pack/\n"
1387
+ "|-- rendered_v2.mp4\n"
1388
+ "|-- rendered_mask_v2.mp4\n"
1389
+ "|-- prompt.txt # optional\n"
1390
+ "|-- metadata.json # optional\n"
1391
+ "|-- characters/\n"
1392
+ "| |-- character_0/\n"
1393
+ "| | |-- front.png\n"
1394
+ "| | |-- front_mask.png\n"
1395
+ "| | |-- back.png\n"
1396
+ "| | `-- back_mask.png\n"
1397
+ "| `-- character_1/\n"
1398
+ "| |-- front.png\n"
1399
+ "| `-- front_mask.png\n"
1400
+ "`-- environment/\n"
1401
+ " |-- background.png\n"
1402
+ " `-- background_mask.png\n"
1403
+ "```\n\n"
1404
+ "### Mask convention\n"
1405
+ "Colors represent identity slots, not individual views. If `character_0` has "
1406
+ "front, back, and close-up references, all masks for those views should use the "
1407
+ "same identity color. A different character gets a different color. The driving "
1408
+ "mask video should use the same color assignments.\n\n"
1409
+ "### Mapping to SCAIL-2\n"
1410
+ "SCAIL-2 receives one primary reference plus a list of additional refs. The parser "
1411
+ "uses `metadata.json` when a primary is declared. Otherwise it uses "
1412
+ "`characters/character_0/front.*` or the first available view from `character_0`. "
1413
+ "All remaining image/mask pairs become additional references.\n\n"
1414
+ "### Legacy repo-style pack\n"
1415
+ "The official multi-reference example also uses this flat layout, which is supported:\n\n"
1416
+ "```text\n"
1417
+ "ref.png\n"
1418
+ "ref_mask.jpg\n"
1419
+ "rendered_v2.mp4\n"
1420
+ "rendered_mask_v2.mp4\n"
1421
+ "background.png\n"
1422
+ "background_mask.png\n"
1423
+ "character_0.png\n"
1424
+ "character_0_mask.png\n"
1425
+ "character_1.png\n"
1426
+ "character_1_mask.png\n"
1427
+ "```\n"
1428
+ )
1429
+
1430
+ pack_state = gr.State(None)
1431
+ pack_file = gr.File(
1432
+ label="SCAIL-2 input pack (.zip)",
1433
+ file_types=[".zip"],
1434
+ type="filepath",
1435
+ )
1436
+ validate_pack = gr.Button("Validate pack")
1437
+ pack_summary = gr.Markdown("Upload a pack and validate it before generating.")
1438
+ pack_gallery = gr.Gallery(
1439
+ label="Parsed reference set",
1440
+ columns=4,
1441
+ height=260,
1442
+ selected_index=0,
1443
+ preview=True,
1444
+ )
1445
+ with gr.Row():
1446
+ pack_driving_preview = gr.Video(label="Driving / rendered video")
1447
+ pack_mask_preview = gr.Video(label="Driving mask / replace mask")
1448
+ pack_mode = gr.Textbox(value="animation", label="Mode", interactive=False)
1449
+ pack_prompt = gr.Textbox(label="Prompt", lines=3)
1450
+ pack_steps, pack_cfg, pack_shift, pack_seed, pack_target_size, pack_segment_len, pack_segment_overlap = _sampling_controls()
1451
+ run_pack = gr.Button("Generate from pack", variant="primary")
1452
+ pack_output = gr.Video(label="Output")
1453
+ pack_status = gr.Textbox(label="Run status", lines=8)
1454
+
1455
+ validate_pack.click(
1456
+ validate_input_pack,
1457
+ inputs=[pack_file],
1458
+ outputs=[
1459
+ pack_state,
1460
+ pack_summary,
1461
+ pack_gallery,
1462
+ pack_driving_preview,
1463
+ pack_mask_preview,
1464
+ pack_prompt,
1465
+ pack_mode,
1466
+ ],
1467
+ )
1468
+ run_pack.click(
1469
+ generate_from_pack,
1470
+ inputs=[
1471
+ pack_state,
1472
+ pack_prompt,
1473
+ pack_steps,
1474
+ pack_cfg,
1475
+ pack_shift,
1476
+ pack_seed,
1477
+ pack_target_size,
1478
+ pack_segment_len,
1479
+ pack_segment_overlap,
1480
+ ],
1481
+ outputs=[pack_output, pack_status],
1482
+ )
1483
+
1484
  return demo
1485
 
1486
 
 
1491
  _prepare_runtime_for_startup()
1492
  if PRELOAD_PIPELINE:
1493
  _prepare_pipeline_for_startup()
1494
+ build_ui().queue(max_size=8).launch(
1495
+ allowed_paths=[str(OUTPUT_DIR.resolve())],
1496
+ show_error=True,
1497
+ )