Update app.py
Browse files
app.py
CHANGED
@@ -16,6 +16,13 @@ from einops import rearrange
|
|
16 |
from scipy.io import wavfile
|
17 |
from transformers import pipeline
|
18 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
19 |
# 환경 변수 설정으로 torch.load 체크 우회 (임시 해결책)
|
20 |
os.environ["TRANSFORMERS_ALLOW_UNSAFE_DESERIALIZATION"] = "1"
|
21 |
|
@@ -45,7 +52,29 @@ from mmaudio.model.networks import MMAudio, get_my_mmaudio
|
|
45 |
from mmaudio.model.sequence_config import SequenceConfig
|
46 |
from mmaudio.model.utils.features_utils import FeaturesUtils
|
47 |
|
48 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
49 |
try:
|
50 |
from controlnet_union import ControlNetModel_Union
|
51 |
from pipeline_fill_sd_xl import StableDiffusionXLFillPipeline
|
@@ -94,18 +123,14 @@ except Exception as e:
|
|
94 |
logging.error(f"Failed to load outpainting models: {str(e)}")
|
95 |
OUTPAINT_MODEL_LOADED = False
|
96 |
|
97 |
-
# MMAudio 모델 설정
|
98 |
if torch.cuda.is_available():
|
99 |
-
|
100 |
-
torch.backends.cuda.matmul.allow_tf32 = True
|
101 |
-
torch.backends.cudnn.allow_tf32 = True
|
102 |
-
torch.backends.cudnn.benchmark = True
|
103 |
else:
|
104 |
device = torch.device("cpu")
|
|
|
105 |
|
106 |
-
|
107 |
-
|
108 |
-
# MMAudio 모델 초기화
|
109 |
try:
|
110 |
model_mmaudio: ModelConfig = all_model_cfg['large_44k_v2']
|
111 |
model_mmaudio.download_if_needed()
|
@@ -155,7 +180,7 @@ VIDEO_API_URL = "http://211.233.58.201:7875"
|
|
155 |
# 로깅 설정
|
156 |
logging.basicConfig(level=logging.INFO)
|
157 |
|
158 |
-
# Image size presets
|
159 |
IMAGE_PRESETS = {
|
160 |
"커스텀": {"width": 1024, "height": 1024},
|
161 |
"1:1 정사각형": {"width": 1024, "height": 1024},
|
@@ -172,6 +197,7 @@ IMAGE_PRESETS = {
|
|
172 |
"LinkedIn 배너": {"width": 1584, "height": 396},
|
173 |
}
|
174 |
|
|
|
175 |
def update_dimensions(preset):
|
176 |
if preset in IMAGE_PRESETS:
|
177 |
return IMAGE_PRESETS[preset]["width"], IMAGE_PRESETS[preset]["height"]
|
@@ -431,6 +457,113 @@ def video_to_audio(video: gr.Video, prompt: str, negative_prompt: str, seed: int
|
|
431 |
duration_sec=seq_cfg.duration)
|
432 |
return video_save_path
|
433 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
434 |
# CSS
|
435 |
css = """
|
436 |
:root {
|
@@ -456,7 +589,7 @@ css = """
|
|
456 |
padding: 20px !important;
|
457 |
margin-bottom: 20px !important;
|
458 |
}
|
459 |
-
#generate-btn, #video-btn, #outpaint-btn, #preview-btn, #audio-btn {
|
460 |
background: linear-gradient(135deg, #ff9a9e, #fad0c4) !important;
|
461 |
font-size: 1.1rem !important;
|
462 |
padding: 12px 24px !important;
|
@@ -652,6 +785,110 @@ with demo:
|
|
652 |
|
653 |
if not MMAUDIO_MODEL_LOADED:
|
654 |
gr.Markdown("⚠️ MMAudio 모델을 로드하지 못했습니다. 이 기능은 사용할 수 없습니다.")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
655 |
|
656 |
# 이벤트 연결 - 첫 번째 탭
|
657 |
size_preset.change(update_dimensions, [size_preset], [width, height])
|
@@ -689,5 +926,29 @@ with demo:
|
|
689 |
[audio_video_input, audio_prompt, audio_negative_prompt, audio_seed, audio_steps, audio_cfg, audio_duration],
|
690 |
[output_video_with_audio]
|
691 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
692 |
|
693 |
demo.launch()
|
|
|
16 |
from scipy.io import wavfile
|
17 |
from transformers import pipeline
|
18 |
|
19 |
+
# 비디오 배경제거를 위한 추가 import
|
20 |
+
from transformers import AutoModelForImageSegmentation
|
21 |
+
from torchvision import transforms
|
22 |
+
from moviepy import VideoFileClip, vfx, concatenate_videoclips, ImageSequenceClip
|
23 |
+
import time
|
24 |
+
from concurrent.futures import ThreadPoolExecutor
|
25 |
+
|
26 |
# 환경 변수 설정으로 torch.load 체크 우회 (임시 해결책)
|
27 |
os.environ["TRANSFORMERS_ALLOW_UNSAFE_DESERIALIZATION"] = "1"
|
28 |
|
|
|
52 |
from mmaudio.model.sequence_config import SequenceConfig
|
53 |
from mmaudio.model.utils.features_utils import FeaturesUtils
|
54 |
|
55 |
+
# 기존 코드의 모든 설정과 초기화 부분 유지
|
56 |
+
torch.set_float32_matmul_precision("medium")
|
57 |
+
device = "cuda" if torch.cuda.is_available() else "cpu"
|
58 |
+
|
59 |
+
# BiRefNet 모델 로드
|
60 |
+
try:
|
61 |
+
birefnet = AutoModelForImageSegmentation.from_pretrained("ZhengPeng7/BiRefNet", trust_remote_code=True)
|
62 |
+
birefnet.to(device)
|
63 |
+
birefnet_lite = AutoModelForImageSegmentation.from_pretrained("ZhengPeng7/BiRefNet_lite", trust_remote_code=True)
|
64 |
+
birefnet_lite.to(device)
|
65 |
+
|
66 |
+
transform_image = transforms.Compose([
|
67 |
+
transforms.Resize((768, 768)),
|
68 |
+
transforms.ToTensor(),
|
69 |
+
transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]),
|
70 |
+
])
|
71 |
+
|
72 |
+
BIREFNET_MODEL_LOADED = True
|
73 |
+
except Exception as e:
|
74 |
+
logging.error(f"Failed to load BiRefNet models: {str(e)}")
|
75 |
+
BIREFNET_MODEL_LOADED = False
|
76 |
+
|
77 |
+
# ControlNet 모델 로드 (기존 코드)
|
78 |
try:
|
79 |
from controlnet_union import ControlNetModel_Union
|
80 |
from pipeline_fill_sd_xl import StableDiffusionXLFillPipeline
|
|
|
123 |
logging.error(f"Failed to load outpainting models: {str(e)}")
|
124 |
OUTPAINT_MODEL_LOADED = False
|
125 |
|
126 |
+
# MMAudio 모델 설정 (기존 코드)
|
127 |
if torch.cuda.is_available():
|
128 |
+
dtype = torch.bfloat16
|
|
|
|
|
|
|
129 |
else:
|
130 |
device = torch.device("cpu")
|
131 |
+
dtype = torch.float32
|
132 |
|
133 |
+
# MMAudio 모델 초기화 (기존 코드)
|
|
|
|
|
134 |
try:
|
135 |
model_mmaudio: ModelConfig = all_model_cfg['large_44k_v2']
|
136 |
model_mmaudio.download_if_needed()
|
|
|
180 |
# 로깅 설정
|
181 |
logging.basicConfig(level=logging.INFO)
|
182 |
|
183 |
+
# Image size presets (기존 코드)
|
184 |
IMAGE_PRESETS = {
|
185 |
"커스텀": {"width": 1024, "height": 1024},
|
186 |
"1:1 정사각형": {"width": 1024, "height": 1024},
|
|
|
197 |
"LinkedIn 배너": {"width": 1584, "height": 396},
|
198 |
}
|
199 |
|
200 |
+
# 기존 함수들 모두 유지
|
201 |
def update_dimensions(preset):
|
202 |
if preset in IMAGE_PRESETS:
|
203 |
return IMAGE_PRESETS[preset]["width"], IMAGE_PRESETS[preset]["height"]
|
|
|
457 |
duration_sec=seq_cfg.duration)
|
458 |
return video_save_path
|
459 |
|
460 |
+
# 비디오 배경제거 관련 함수들
|
461 |
+
def process_bg_image(image, bg, fast_mode=False):
|
462 |
+
"""단일 이미지 배경 처리"""
|
463 |
+
if not BIREFNET_MODEL_LOADED:
|
464 |
+
return image
|
465 |
+
|
466 |
+
image_size = image.size
|
467 |
+
input_images = transform_image(image).unsqueeze(0).to(device)
|
468 |
+
model = birefnet_lite if fast_mode else birefnet
|
469 |
+
|
470 |
+
with torch.no_grad():
|
471 |
+
preds = model(input_images)[-1].sigmoid().cpu()
|
472 |
+
pred = preds[0].squeeze()
|
473 |
+
pred_pil = transforms.ToPILImage()(pred)
|
474 |
+
mask = pred_pil.resize(image_size)
|
475 |
+
|
476 |
+
if isinstance(bg, str) and bg.startswith("#"):
|
477 |
+
color_rgb = tuple(int(bg[i:i+2], 16) for i in (1, 3, 5))
|
478 |
+
background = Image.new("RGBA", image_size, color_rgb + (255,))
|
479 |
+
elif isinstance(bg, Image.Image):
|
480 |
+
background = bg.convert("RGBA").resize(image_size)
|
481 |
+
else:
|
482 |
+
background = Image.open(bg).convert("RGBA").resize(image_size)
|
483 |
+
|
484 |
+
image = Image.composite(image, background, mask)
|
485 |
+
return image
|
486 |
+
|
487 |
+
def process_video_frame(frame, bg_type, bg, fast_mode, bg_frame_index, background_frames, color):
|
488 |
+
"""비디오 프레임 처리"""
|
489 |
+
try:
|
490 |
+
pil_image = Image.fromarray(frame)
|
491 |
+
if bg_type == "색상":
|
492 |
+
processed_image = process_bg_image(pil_image, color, fast_mode)
|
493 |
+
elif bg_type == "이미지":
|
494 |
+
processed_image = process_bg_image(pil_image, bg, fast_mode)
|
495 |
+
elif bg_type == "비디오":
|
496 |
+
background_frame = background_frames[bg_frame_index]
|
497 |
+
bg_frame_index += 1
|
498 |
+
background_image = Image.fromarray(background_frame)
|
499 |
+
processed_image = process_bg_image(pil_image, background_image, fast_mode)
|
500 |
+
else:
|
501 |
+
processed_image = pil_image
|
502 |
+
return np.array(processed_image), bg_frame_index
|
503 |
+
except Exception as e:
|
504 |
+
print(f"Error processing frame: {e}")
|
505 |
+
return frame, bg_frame_index
|
506 |
+
|
507 |
+
@spaces.GPU
|
508 |
+
def process_video_bg(vid, bg_type="색상", bg_image=None, bg_video=None, color="#00FF00",
|
509 |
+
fps=0, video_handling="slow_down", fast_mode=True, max_workers=10):
|
510 |
+
"""비디오 배경 처리 메인 함수"""
|
511 |
+
if not BIREFNET_MODEL_LOADED:
|
512 |
+
yield gr.update(visible=False), gr.update(visible=True), "BiRefNet 모델을 로드하지 못했습니다."
|
513 |
+
yield None, None, "BiRefNet 모델을 로드하지 못했습니다."
|
514 |
+
return
|
515 |
+
|
516 |
+
try:
|
517 |
+
start_time = time.time()
|
518 |
+
video = VideoFileClip(vid)
|
519 |
+
if fps == 0:
|
520 |
+
fps = video.fps
|
521 |
+
|
522 |
+
audio = video.audio
|
523 |
+
frames = list(video.iter_frames(fps=fps))
|
524 |
+
|
525 |
+
processed_frames = []
|
526 |
+
yield gr.update(visible=True), gr.update(visible=False), f"처리 시작... 경과 시간: 0초"
|
527 |
+
|
528 |
+
if bg_type == "비디오":
|
529 |
+
background_video = VideoFileClip(bg_video)
|
530 |
+
if background_video.duration < video.duration:
|
531 |
+
if video_handling == "slow_down":
|
532 |
+
background_video = background_video.fx(vfx.speedx, factor=video.duration / background_video.duration)
|
533 |
+
else: # video_handling == "loop"
|
534 |
+
background_video = concatenate_videoclips([background_video] * int(video.duration / background_video.duration + 1))
|
535 |
+
background_frames = list(background_video.iter_frames(fps=fps))
|
536 |
+
else:
|
537 |
+
background_frames = None
|
538 |
+
|
539 |
+
bg_frame_index = 0
|
540 |
+
|
541 |
+
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
542 |
+
futures = [executor.submit(process_video_frame, frames[i], bg_type, bg_image, fast_mode,
|
543 |
+
bg_frame_index + i, background_frames, color) for i in range(len(frames))]
|
544 |
+
for i, future in enumerate(futures):
|
545 |
+
result, _ = future.result()
|
546 |
+
processed_frames.append(result)
|
547 |
+
elapsed_time = time.time() - start_time
|
548 |
+
yield result, None, f"프레임 {i+1}/{len(frames)} 처리 중... 경과 시간: {elapsed_time:.2f}초"
|
549 |
+
|
550 |
+
processed_video = ImageSequenceClip(processed_frames, fps=fps)
|
551 |
+
processed_video = processed_video.with_audio(audio)
|
552 |
+
|
553 |
+
with tempfile.NamedTemporaryFile(suffix=".mp4", delete=False) as temp_file:
|
554 |
+
temp_filepath = temp_file.name
|
555 |
+
processed_video.write_videofile(temp_filepath, codec="libx264")
|
556 |
+
|
557 |
+
elapsed_time = time.time() - start_time
|
558 |
+
yield gr.update(visible=False), gr.update(visible=True), f"처리 완료! 경과 시간: {elapsed_time:.2f}초"
|
559 |
+
yield processed_frames[-1], temp_filepath, f"처리 완료! 경과 시간: {elapsed_time:.2f}초"
|
560 |
+
|
561 |
+
except Exception as e:
|
562 |
+
print(f"Error: {e}")
|
563 |
+
elapsed_time = time.time() - start_time
|
564 |
+
yield gr.update(visible=False), gr.update(visible=True), f"비디오 처리 오류: {e}. 경과 시간: {elapsed_time:.2f}초"
|
565 |
+
yield None, f"비디오 처리 오류: {e}", f"비디오 처리 오류: {e}. 경과 시간: {elapsed_time:.2f}초"
|
566 |
+
|
567 |
# CSS
|
568 |
css = """
|
569 |
:root {
|
|
|
589 |
padding: 20px !important;
|
590 |
margin-bottom: 20px !important;
|
591 |
}
|
592 |
+
#generate-btn, #video-btn, #outpaint-btn, #preview-btn, #audio-btn, #bg-remove-btn {
|
593 |
background: linear-gradient(135deg, #ff9a9e, #fad0c4) !important;
|
594 |
font-size: 1.1rem !important;
|
595 |
padding: 12px 24px !important;
|
|
|
785 |
|
786 |
if not MMAUDIO_MODEL_LOADED:
|
787 |
gr.Markdown("⚠️ MMAudio 모델을 로드하지 못했습니다. 이 기능은 사용할 수 없습니다.")
|
788 |
+
|
789 |
+
# 네 번째 탭: 비디오 배경제거/합성
|
790 |
+
with gr.Tab("비디오 배경제거/합성", elem_classes="tabitem"):
|
791 |
+
with gr.Row(equal_height=True):
|
792 |
+
# 입력 컬럼
|
793 |
+
with gr.Column(scale=1):
|
794 |
+
with gr.Group(elem_classes="panel-box"):
|
795 |
+
gr.Markdown("### 🎥 비디오 업로드")
|
796 |
+
|
797 |
+
bg_video_input = gr.Video(
|
798 |
+
label="입력 비디오",
|
799 |
+
interactive=True
|
800 |
+
)
|
801 |
+
|
802 |
+
with gr.Group(elem_classes="panel-box"):
|
803 |
+
gr.Markdown("### 🎨 배경 설정")
|
804 |
+
|
805 |
+
bg_type = gr.Radio(
|
806 |
+
["색상", "이미지", "비디오"],
|
807 |
+
label="배경 유형",
|
808 |
+
value="색상",
|
809 |
+
interactive=True
|
810 |
+
)
|
811 |
+
|
812 |
+
color_picker = gr.ColorPicker(
|
813 |
+
label="배경 색상",
|
814 |
+
value="#00FF00",
|
815 |
+
visible=True,
|
816 |
+
interactive=True
|
817 |
+
)
|
818 |
+
|
819 |
+
bg_image_input = gr.Image(
|
820 |
+
label="배경 이미지",
|
821 |
+
type="filepath",
|
822 |
+
visible=False,
|
823 |
+
interactive=True
|
824 |
+
)
|
825 |
+
|
826 |
+
bg_video_bg = gr.Video(
|
827 |
+
label="배경 비디오",
|
828 |
+
visible=False,
|
829 |
+
interactive=True
|
830 |
+
)
|
831 |
+
|
832 |
+
with gr.Column(visible=False) as video_handling_options:
|
833 |
+
video_handling_radio = gr.Radio(
|
834 |
+
["slow_down", "loop"],
|
835 |
+
label="비디오 처리 방식",
|
836 |
+
value="slow_down",
|
837 |
+
interactive=True,
|
838 |
+
info="slow_down: 배경 비디오를 느리게 재생, loop: 배경 비디오를 반복"
|
839 |
+
)
|
840 |
+
|
841 |
+
with gr.Group(elem_classes="panel-box"):
|
842 |
+
gr.Markdown("### ⚙️ 처리 설정")
|
843 |
+
|
844 |
+
fps_slider = gr.Slider(
|
845 |
+
minimum=0,
|
846 |
+
maximum=60,
|
847 |
+
step=1,
|
848 |
+
value=0,
|
849 |
+
label="출력 FPS (0 = 원본 FPS 유지)",
|
850 |
+
interactive=True
|
851 |
+
)
|
852 |
+
|
853 |
+
fast_mode_checkbox = gr.Checkbox(
|
854 |
+
label="빠른 모드 (BiRefNet_lite 사용)",
|
855 |
+
value=True,
|
856 |
+
interactive=True
|
857 |
+
)
|
858 |
+
|
859 |
+
max_workers_slider = gr.Slider(
|
860 |
+
minimum=1,
|
861 |
+
maximum=32,
|
862 |
+
step=1,
|
863 |
+
value=10,
|
864 |
+
label="최대 워커 수",
|
865 |
+
info="병렬로 처리할 프레임 수",
|
866 |
+
interactive=True
|
867 |
+
)
|
868 |
+
|
869 |
+
bg_remove_btn = gr.Button("🎬 배경 변경", variant="primary", elem_id="bg-remove-btn")
|
870 |
+
|
871 |
+
if not BIREFNET_MODEL_LOADED:
|
872 |
+
gr.Markdown("⚠️ BiRefNet 모델을 로드하지 못했습니다. 이 기능은 사용할 수 없습니다.")
|
873 |
+
|
874 |
+
# 출력 컬럼
|
875 |
+
with gr.Column(scale=1):
|
876 |
+
with gr.Group(elem_classes="panel-box"):
|
877 |
+
gr.Markdown("### 🎬 처리 결과")
|
878 |
+
|
879 |
+
stream_image = gr.Image(label="실시간 스트리밍", visible=False)
|
880 |
+
output_bg_video = gr.Video(label="최종 비디오")
|
881 |
+
time_textbox = gr.Textbox(label="경과 시간", interactive=False)
|
882 |
+
|
883 |
+
gr.Markdown("""
|
884 |
+
### ℹ️ 사용 방법
|
885 |
+
1. 비디오를 업로드하세요
|
886 |
+
2. 원하는 배경 유형을 선택하세요
|
887 |
+
3. 설정을 조정하고 '배경 변경' 버튼을 클릭하세요
|
888 |
+
|
889 |
+
**참고**: GPU 제한으로 한 번에 약 200프레임까지 처리 가능합니다.
|
890 |
+
긴 비디오는 작은 조각으로 나누어 처리하세요.
|
891 |
+
""")
|
892 |
|
893 |
# 이벤트 연결 - 첫 번째 탭
|
894 |
size_preset.change(update_dimensions, [size_preset], [width, height])
|
|
|
926 |
[audio_video_input, audio_prompt, audio_negative_prompt, audio_seed, audio_steps, audio_cfg, audio_duration],
|
927 |
[output_video_with_audio]
|
928 |
)
|
929 |
+
|
930 |
+
# 이벤트 연결 - 네 번째 탭
|
931 |
+
def update_bg_visibility(bg_type):
|
932 |
+
if bg_type == "색상":
|
933 |
+
return gr.update(visible=True), gr.update(visible=False), gr.update(visible=False), gr.update(visible=False)
|
934 |
+
elif bg_type == "이미지":
|
935 |
+
return gr.update(visible=False), gr.update(visible=True), gr.update(visible=False), gr.update(visible=False)
|
936 |
+
elif bg_type == "비디오":
|
937 |
+
return gr.update(visible=False), gr.update(visible=False), gr.update(visible=True), gr.update(visible=True)
|
938 |
+
else:
|
939 |
+
return gr.update(visible=False), gr.update(visible=False), gr.update(visible=False), gr.update(visible=False)
|
940 |
+
|
941 |
+
bg_type.change(
|
942 |
+
update_bg_visibility,
|
943 |
+
inputs=bg_type,
|
944 |
+
outputs=[color_picker, bg_image_input, bg_video_bg, video_handling_options]
|
945 |
+
)
|
946 |
+
|
947 |
+
bg_remove_btn.click(
|
948 |
+
process_video_bg,
|
949 |
+
inputs=[bg_video_input, bg_type, bg_image_input, bg_video_bg, color_picker,
|
950 |
+
fps_slider, video_handling_radio, fast_mode_checkbox, max_workers_slider],
|
951 |
+
outputs=[stream_image, output_bg_video, time_textbox]
|
952 |
+
)
|
953 |
|
954 |
demo.launch()
|