fffiloni commited on
Commit
557df65
·
verified ·
1 Parent(s): cf7c908

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +426 -5
app.py CHANGED
@@ -1,6 +1,7 @@
1
  import json
2
  import logging
3
  import os
 
4
  import shutil
5
  import subprocess
6
  import sys
@@ -9,6 +10,7 @@ import traceback
9
  import uuid
10
  import zipfile
11
  from pathlib import Path
 
12
 
13
  import gradio as gr
14
  import spaces
@@ -34,6 +36,26 @@ GPU_DURATION = int(os.getenv("SCAIL_POSE_GPU_DURATION", "90"))
34
  IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".webp"}
35
  VIDEO_EXTS = {".mp4", ".mov", ".webm", ".mkv"}
36
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
37
 
38
  def _repo_status() -> str:
39
  required = [
@@ -141,6 +163,116 @@ def _copy_video(upload, dest: Path, label: str) -> Path:
141
  return dest
142
 
143
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
144
  def _text_args(text_prompt: str) -> list[str]:
145
  words = [part.strip() for part in (text_prompt or "").split() if part.strip()]
146
  return words or ["human", "character"]
@@ -212,6 +344,84 @@ def _require_outputs(job_dir: Path, names: list[str]) -> None:
212
  raise RuntimeError("SCAIL-Pose did not produce expected output(s): " + ", ".join(missing))
213
 
214
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
215
  def _animation_command(
216
  job_dir: Path,
217
  sam3_model: Path,
@@ -396,6 +606,133 @@ def build_replacement_pack(
396
  return None, None, None, None, traceback.format_exc()
397
 
398
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
399
  def build_ui():
400
  with gr.Blocks(title="SCAIL-Pose Pack Builder") as demo:
401
  gr.Markdown(
@@ -443,6 +780,74 @@ def build_ui():
443
  outputs=[anim_ref_mask, anim_rendered, anim_mask_video, anim_zip, anim_status],
444
  )
445
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
446
  with gr.Tab("Replacement Pack"):
447
  gr.Markdown(
448
  "Create a replacement pack from a replacement reference image and a source video. "
@@ -478,9 +883,9 @@ def build_ui():
478
 
479
  with gr.Tab("Pack Format"):
480
  gr.Markdown(
481
- "The exported zip is intentionally flat for V1, so it is easy to inspect and "
482
- "compatible with the SCAIL-2 Advanced Pack parser.\n\n"
483
- "Animation pack:\n"
484
  "```text\n"
485
  "ref.png\n"
486
  "ref_mask.jpg\n"
@@ -498,8 +903,24 @@ def build_ui():
498
  "prompt.txt\n"
499
  "metadata.json\n"
500
  "```\n\n"
501
- "For multi-character curation, run several focused jobs first, inspect the masks, "
502
- "then combine the selected references into a canonical Advanced Pack."
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
503
  )
504
 
505
  return demo
 
1
  import json
2
  import logging
3
  import os
4
+ import re
5
  import shutil
6
  import subprocess
7
  import sys
 
10
  import uuid
11
  import zipfile
12
  from pathlib import Path
13
+ from dataclasses import dataclass
14
 
15
  import gradio as gr
16
  import spaces
 
36
  IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".webp"}
37
  VIDEO_EXTS = {".mp4", ".mov", ".webm", ".mkv"}
38
 
39
+ SCAIL_COLORS = [
40
+ (0, 0, 255),
41
+ (255, 0, 0),
42
+ (0, 255, 0),
43
+ (255, 255, 0),
44
+ (255, 0, 255),
45
+ (0, 255, 255),
46
+ ]
47
+
48
+ _SAM3_VIDEO_PREDICTOR = None
49
+ _SAM3_IMAGE_PREDICTOR = None
50
+ _SAM3_PREDICTOR_KEY = None
51
+
52
+
53
+ @dataclass(frozen=True)
54
+ class CharacterRef:
55
+ character: str
56
+ view: str
57
+ path: Path
58
+
59
 
60
  def _repo_status() -> str:
61
  required = [
 
163
  return dest
164
 
165
 
166
+ def _copy_reference_to_png(source: Path, dest: Path) -> Path:
167
+ dest.parent.mkdir(parents=True, exist_ok=True)
168
+ try:
169
+ image = Image.open(source).convert("RGB")
170
+ image.save(dest)
171
+ except Exception as exc:
172
+ raise RuntimeError(f"Could not read reference image: {source}") from exc
173
+ return dest
174
+
175
+
176
+ def _safe_name(value: str) -> str:
177
+ value = value.strip().replace(" ", "_")
178
+ value = re.sub(r"[^A-Za-z0-9_.-]+", "_", value)
179
+ value = value.strip("._-")
180
+ return value or "view"
181
+
182
+
183
+ def _character_sort_key(character: str) -> tuple[int, str]:
184
+ match = re.fullmatch(r"character_(\d+)", character)
185
+ if match:
186
+ return int(match.group(1)), character
187
+ return 9999, character
188
+
189
+
190
+ def _parse_character_ref_name(path: Path) -> tuple[str, str] | None:
191
+ if path.suffix.lower() not in IMAGE_EXTS:
192
+ return None
193
+ if path.stem.endswith("_mask"):
194
+ return None
195
+
196
+ parent = path.parent.name
197
+ if re.fullmatch(r"character_\d+", parent):
198
+ return parent, _safe_name(path.stem)
199
+
200
+ stem = path.stem
201
+ match = re.match(r"^(character_\d+)(?:__|--|_)(.+)$", stem)
202
+ if not match:
203
+ return None
204
+ return match.group(1), _safe_name(match.group(2))
205
+
206
+
207
+ def _normalize_upload_list(files) -> list[Path]:
208
+ if not files:
209
+ return []
210
+ if not isinstance(files, list):
211
+ files = [files]
212
+ return [_as_path(file, "reference file") for file in files]
213
+
214
+
215
+ def _safe_extract_zip(zip_path: str | Path, label: str) -> Path:
216
+ zip_path = _as_path(zip_path, label)
217
+ if zip_path.suffix.lower() != ".zip":
218
+ raise RuntimeError(f"{label} must be a .zip file.")
219
+
220
+ extract_root = JOB_ROOT / "uploads" / uuid.uuid4().hex
221
+ extract_root.mkdir(parents=True, exist_ok=True)
222
+ with zipfile.ZipFile(zip_path) as zf:
223
+ for item in zf.infolist():
224
+ item_path = Path(item.filename)
225
+ if item_path.is_absolute() or ".." in item_path.parts:
226
+ raise RuntimeError(f"Unsafe path in zip: {item.filename}")
227
+ zf.extractall(extract_root)
228
+
229
+ visible = [p for p in extract_root.iterdir() if p.name not in {".DS_Store", "__MACOSX"}]
230
+ if len(visible) == 1 and visible[0].is_dir():
231
+ return visible[0]
232
+ return extract_root
233
+
234
+
235
+ def _collect_character_refs(reference_files, reference_zip) -> list[CharacterRef]:
236
+ paths = []
237
+ if reference_zip is not None:
238
+ root = _safe_extract_zip(reference_zip, "reference zip")
239
+ paths.extend(p for p in root.rglob("*") if p.is_file())
240
+ paths.extend(_normalize_upload_list(reference_files))
241
+
242
+ refs = []
243
+ ignored = []
244
+ for path in paths:
245
+ parsed = _parse_character_ref_name(path)
246
+ if parsed is None:
247
+ if path.suffix.lower() in IMAGE_EXTS:
248
+ ignored.append(path.name)
249
+ continue
250
+ character, view = parsed
251
+ refs.append(CharacterRef(character=character, view=view, path=path))
252
+
253
+ if not refs:
254
+ raise RuntimeError(
255
+ "No character references found. Use names like `character_0__front.png`, "
256
+ "`character_0__back.png`, `character_1__front.png`, or upload a zip with "
257
+ "`characters/character_0/front.png`."
258
+ )
259
+
260
+ seen = set()
261
+ duplicates = []
262
+ for ref in refs:
263
+ key = (ref.character, ref.view)
264
+ if key in seen:
265
+ duplicates.append(f"{ref.character}/{ref.view}")
266
+ seen.add(key)
267
+ if duplicates:
268
+ raise RuntimeError("Duplicate character view(s): " + ", ".join(sorted(duplicates)))
269
+
270
+ if ignored:
271
+ logging.info("Ignored reference image(s) without character naming: %s", ", ".join(ignored))
272
+
273
+ return sorted(refs, key=lambda ref: (_character_sort_key(ref.character), ref.view.lower()))
274
+
275
+
276
  def _text_args(text_prompt: str) -> list[str]:
277
  words = [part.strip() for part in (text_prompt or "").split() if part.strip()]
278
  return words or ["human", "character"]
 
344
  raise RuntimeError("SCAIL-Pose did not produce expected output(s): " + ", ".join(missing))
345
 
346
 
347
+ def _load_sam3_predictors(sam3_model: Path):
348
+ global _SAM3_VIDEO_PREDICTOR, _SAM3_IMAGE_PREDICTOR, _SAM3_PREDICTOR_KEY
349
+ key = str(sam3_model)
350
+ if _SAM3_VIDEO_PREDICTOR is not None and _SAM3_PREDICTOR_KEY == key:
351
+ return _SAM3_VIDEO_PREDICTOR, _SAM3_IMAGE_PREDICTOR
352
+
353
+ from ultralytics.models.sam import SAM3SemanticPredictor, SAM3VideoSemanticPredictor
354
+
355
+ overrides = dict(
356
+ conf=0.25,
357
+ task="segment",
358
+ mode="predict",
359
+ imgsz=640,
360
+ model=str(sam3_model),
361
+ half=True,
362
+ save=False,
363
+ verbose=False,
364
+ )
365
+ _SAM3_VIDEO_PREDICTOR = SAM3VideoSemanticPredictor(overrides=overrides, new_det_thresh=1.0)
366
+ _SAM3_IMAGE_PREDICTOR = SAM3SemanticPredictor(overrides=overrides)
367
+ _SAM3_PREDICTOR_KEY = key
368
+ return _SAM3_VIDEO_PREDICTOR, _SAM3_IMAGE_PREDICTOR
369
+
370
+
371
+ def _write_canonical_pack_zip(pack_root: Path, mode: str) -> Path:
372
+ zip_path = pack_root.parent / f"scail2_{mode}_advanced_pack.zip"
373
+ with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED) as zf:
374
+ for path in sorted(pack_root.rglob("*")):
375
+ if path.is_file():
376
+ zf.write(path, arcname=path.relative_to(pack_root).as_posix())
377
+ return zip_path
378
+
379
+
380
+ def _advanced_gallery(pack_root: Path):
381
+ items = []
382
+ for image_path in sorted((pack_root / "characters").rglob("*")):
383
+ if image_path.is_file() and image_path.suffix.lower() in IMAGE_EXTS:
384
+ items.append((str(image_path), image_path.relative_to(pack_root).as_posix()))
385
+ return items
386
+
387
+
388
+ def _build_advanced_metadata(
389
+ pack_root: Path,
390
+ mode: str,
391
+ prompt: str,
392
+ primary_ref: CharacterRef,
393
+ character_names: list[str],
394
+ text_prompt: str,
395
+ ) -> None:
396
+ primary_image = f"characters/{primary_ref.character}/{primary_ref.view}.png"
397
+ primary_mask = f"characters/{primary_ref.character}/{primary_ref.view}_mask.png"
398
+ metadata = {
399
+ "mode": mode,
400
+ "source": "scail-pose-gradio-advanced-pack-builder",
401
+ "primary": {
402
+ "image": primary_image,
403
+ "mask": primary_mask,
404
+ },
405
+ "driving": {
406
+ "video": "rendered_v2.mp4",
407
+ "mask_video": "rendered_mask_v2.mp4" if mode == "animation" else "replace_mask.mp4",
408
+ },
409
+ "characters": character_names,
410
+ "sam3_text": _text_args(text_prompt),
411
+ }
412
+ (pack_root / "metadata.json").write_text(json.dumps(metadata, indent=2), encoding="utf-8")
413
+ (pack_root / "prompt.txt").write_text(prompt or "", encoding="utf-8")
414
+
415
+
416
+ def _prepare_advanced_refs(refs: list[CharacterRef], pack_root: Path) -> list[CharacterRef]:
417
+ prepared = []
418
+ for ref in refs:
419
+ dest = pack_root / "characters" / ref.character / f"{ref.view}.png"
420
+ _copy_reference_to_png(ref.path, dest)
421
+ prepared.append(CharacterRef(character=ref.character, view=ref.view, path=dest))
422
+ return prepared
423
+
424
+
425
  def _animation_command(
426
  job_dir: Path,
427
  sam3_model: Path,
 
606
  return None, None, None, None, traceback.format_exc()
607
 
608
 
609
+ @spaces.GPU(duration=GPU_DURATION, size=GPU_SIZE)
610
+ def build_advanced_character_pack(
611
+ reference_files,
612
+ reference_zip,
613
+ driving_video,
614
+ prompt,
615
+ sam3_text,
616
+ max_subjects,
617
+ progress=gr.Progress(track_tqdm=True),
618
+ ):
619
+ try:
620
+ progress(0.0, desc="Checking SCAIL-Pose repo")
621
+ _require_repo_layout()
622
+ progress(0.04, desc="Preparing SAM3 weights")
623
+ sam3_model = _ensure_sam3_weights()
624
+
625
+ refs = _collect_character_refs(reference_files, reference_zip)
626
+ character_names = sorted({ref.character for ref in refs}, key=_character_sort_key)
627
+ if len(character_names) > len(SCAIL_COLORS):
628
+ raise RuntimeError(f"Too many characters: {len(character_names)}. Limit is {len(SCAIL_COLORS)}.")
629
+
630
+ max_targets = max(int(max_subjects or len(character_names)), len(character_names))
631
+ max_targets = max(1, min(max_targets, len(SCAIL_COLORS)))
632
+ colors = SCAIL_COLORS[:max_targets]
633
+
634
+ job_dir = _new_job_dir("advanced_animation")
635
+ pack_root = job_dir / "scail2_input_pack"
636
+ pack_root.mkdir(parents=True, exist_ok=True)
637
+
638
+ progress(0.08, desc="Preparing references and driving video")
639
+ work_driving = job_dir / "driving.mp4"
640
+ _copy_video(driving_video, work_driving, "driving video")
641
+ shutil.copy2(work_driving, pack_root / "rendered_v2.mp4")
642
+
643
+ prepared_refs = _prepare_advanced_refs(refs, pack_root)
644
+ primary = sorted(
645
+ prepared_refs,
646
+ key=lambda ref: (
647
+ 0 if ref.character == "character_0" else 1,
648
+ 0 if ref.view in {"front", "main", "ref", "reference"} else 1,
649
+ _character_sort_key(ref.character),
650
+ ref.view,
651
+ ),
652
+ )[0]
653
+ _build_advanced_metadata(pack_root, "animation", prompt, primary, character_names, sam3_text)
654
+
655
+ progress(0.12, desc="Loading SAM3 predictors")
656
+ video_predictor, image_predictor = _load_sam3_predictors(sam3_model)
657
+
658
+ from TrackSam3.track import get_mask_from_image, get_mask_from_image_via_video, get_mask_from_video
659
+ from NLFPoseExtract.v2_helper import save_colored_mask_image, write_colored_mask_video
660
+
661
+ text = _text_args(sam3_text)
662
+
663
+ progress(0.20, desc="Tracking subjects in driving video")
664
+ drv_masks, drv_colors = get_mask_from_video(
665
+ str(work_driving),
666
+ video_predictor,
667
+ max_targets=max_targets,
668
+ sort_by="x",
669
+ fixed_colors=colors,
670
+ text=text,
671
+ )
672
+ if len(drv_masks) == 0:
673
+ raise RuntimeError("SAM3 did not detect any valid subject in the driving video.")
674
+
675
+ import cv2
676
+
677
+ cap = cv2.VideoCapture(str(work_driving))
678
+ fps = cap.get(cv2.CAP_PROP_FPS)
679
+ cap.release()
680
+ fps_int = max(1, int(round(fps or 24)))
681
+ write_colored_mask_video(
682
+ drv_masks,
683
+ drv_colors,
684
+ str(pack_root / "rendered_mask_v2.mp4"),
685
+ fps_int,
686
+ bg_color=(255, 255, 255),
687
+ )
688
+
689
+ progress(0.52, desc="Generating reference masks")
690
+ logs = [
691
+ f"Characters: {', '.join(character_names)}",
692
+ f"Reference views: {len(prepared_refs)}",
693
+ f"Driving tracks detected: {len(drv_masks)}",
694
+ f"Primary: {primary.character}/{primary.view}",
695
+ ]
696
+ for idx, ref in enumerate(prepared_refs):
697
+ character_idx = character_names.index(ref.character)
698
+ color = drv_colors[character_idx] if character_idx < len(drv_colors) else SCAIL_COLORS[character_idx]
699
+ mask_path = ref.path.with_name(f"{ref.view}_mask.png")
700
+ progress(None, desc=f"Masking {ref.character}/{ref.view}")
701
+ try:
702
+ ref_masks, _ = get_mask_from_image_via_video(
703
+ str(ref.path),
704
+ video_predictor,
705
+ max_targets=1,
706
+ sort_by="x",
707
+ fixed_colors=[color],
708
+ text=text,
709
+ )
710
+ except Exception as exc:
711
+ logging.warning("Video-based image masking failed for %s, trying image predictor: %s", ref.path, exc)
712
+ ref_masks, _ = get_mask_from_image(
713
+ str(ref.path),
714
+ image_predictor,
715
+ max_targets=1,
716
+ sort_by="x",
717
+ fixed_colors=[color],
718
+ text=text,
719
+ )
720
+ if len(ref_masks) == 0:
721
+ raise RuntimeError(f"SAM3 did not detect a subject in {ref.character}/{ref.view}.")
722
+ save_colored_mask_image([ref_masks[0]], [color], str(mask_path), bg_color=(255, 255, 255))
723
+ logs.append(f"- {ref.character}/{ref.view}: {mask_path.relative_to(pack_root).as_posix()}")
724
+
725
+ progress(0.92, desc="Packaging canonical Advanced Pack")
726
+ zip_path = _write_canonical_pack_zip(pack_root, "animation")
727
+ gallery = _advanced_gallery(pack_root)
728
+ progress(1.0, desc="Done")
729
+ status = f"Done. Advanced pack created at {zip_path}\n\n" + "\n".join(logs)
730
+ return gallery, str(pack_root / "rendered_v2.mp4"), str(pack_root / "rendered_mask_v2.mp4"), str(zip_path), status
731
+ except Exception:
732
+ logging.exception("Advanced character pack generation failed")
733
+ return [], None, None, None, traceback.format_exc()
734
+
735
+
736
  def build_ui():
737
  with gr.Blocks(title="SCAIL-Pose Pack Builder") as demo:
738
  gr.Markdown(
 
780
  outputs=[anim_ref_mask, anim_rendered, anim_mask_video, anim_zip, anim_status],
781
  )
782
 
783
+ with gr.Tab("Advanced Character Pack"):
784
+ gr.Markdown(
785
+ "Create a canonical SCAIL-2 animation pack for multi-reference or multi-character cases. "
786
+ "Use this when one character has several views, or when several characters need separate reference slots."
787
+ )
788
+ with gr.Accordion("Reference naming", open=True):
789
+ gr.Markdown(
790
+ "Upload reference images with names like:\n\n"
791
+ "```text\n"
792
+ "character_0__front.png\n"
793
+ "character_0__back.png\n"
794
+ "character_0__closeup.png\n"
795
+ "character_1__front.png\n"
796
+ "```\n\n"
797
+ "Or upload a zip with this structure:\n\n"
798
+ "```text\n"
799
+ "characters/\n"
800
+ " character_0/\n"
801
+ " front.png\n"
802
+ " back.png\n"
803
+ " character_1/\n"
804
+ " front.png\n"
805
+ "```\n\n"
806
+ "Character colors are assigned by slot order: `character_0`, then `character_1`, etc. "
807
+ "The driving video is tracked left-to-right with the same color order."
808
+ )
809
+ with gr.Row():
810
+ adv_refs = gr.Files(
811
+ label="Reference images",
812
+ file_types=[".png", ".jpg", ".jpeg", ".webp"],
813
+ type="filepath",
814
+ )
815
+ adv_zip_in = gr.File(
816
+ label="Reference zip instead of files",
817
+ file_types=[".zip"],
818
+ type="filepath",
819
+ )
820
+ adv_driving = gr.Video(label="Driving video")
821
+ adv_prompt = gr.Textbox(label="Prompt for SCAIL-2", lines=3)
822
+ with gr.Row():
823
+ adv_sam3_text = gr.Textbox(value="human character", label="SAM3 text prompt")
824
+ adv_max_subjects = gr.Number(value=2, precision=0, label="Max tracked subjects")
825
+ adv_run = gr.Button("Generate advanced character pack", variant="primary")
826
+ adv_gallery = gr.Gallery(
827
+ label="Parsed references and generated masks",
828
+ columns=4,
829
+ height=320,
830
+ selected_index=0,
831
+ preview=True,
832
+ )
833
+ with gr.Row():
834
+ adv_rendered = gr.Video(label="Rendered / driving video")
835
+ adv_mask_video = gr.Video(label="Driving mask video")
836
+ adv_zip = gr.File(label="Download canonical SCAIL-2 Advanced Pack")
837
+ adv_status = gr.Textbox(label="Run logs", lines=14)
838
+ adv_run.click(
839
+ build_advanced_character_pack,
840
+ inputs=[
841
+ adv_refs,
842
+ adv_zip_in,
843
+ adv_driving,
844
+ adv_prompt,
845
+ adv_sam3_text,
846
+ adv_max_subjects,
847
+ ],
848
+ outputs=[adv_gallery, adv_rendered, adv_mask_video, adv_zip, adv_status],
849
+ )
850
+
851
  with gr.Tab("Replacement Pack"):
852
  gr.Markdown(
853
  "Create a replacement pack from a replacement reference image and a source video. "
 
883
 
884
  with gr.Tab("Pack Format"):
885
  gr.Markdown(
886
+ "The simple tabs export flat packs. The advanced tab exports a canonical "
887
+ "character pack with explicit character folders.\n\n"
888
+ "Simple animation pack:\n"
889
  "```text\n"
890
  "ref.png\n"
891
  "ref_mask.jpg\n"
 
903
  "prompt.txt\n"
904
  "metadata.json\n"
905
  "```\n\n"
906
+ "Advanced character pack:\n"
907
+ "```text\n"
908
+ "rendered_v2.mp4\n"
909
+ "rendered_mask_v2.mp4\n"
910
+ "prompt.txt\n"
911
+ "metadata.json\n"
912
+ "characters/\n"
913
+ " character_0/\n"
914
+ " front.png\n"
915
+ " front_mask.png\n"
916
+ " back.png\n"
917
+ " back_mask.png\n"
918
+ " character_1/\n"
919
+ " front.png\n"
920
+ " front_mask.png\n"
921
+ "```\n\n"
922
+ "For multi-reference, use several views under the same `character_N`. "
923
+ "For multi-character, use one folder per identity slot."
924
  )
925
 
926
  return demo