obvious-research/OnlyFlow
1
1import os2 3import imageio4import numpy as np5import torch6import random7 8import spaces9 10import gradio as gr11 12import torchvision13import torchvision.transforms as T14from einops import rearrange15from huggingface_hub import hf_hub_download16from torchvision.models.optical_flow import raft_large, Raft_Large_Weights17from torchvision.utils import flow_to_image18 19from diffusers import AutoencoderKL, MotionAdapter, UNet2DConditionModel20from diffusers import DDIMScheduler21from transformers import CLIPTextModel, CLIPTokenizer22 23from onlyflow.models.flow_adaptor import FlowEncoder, FlowAdaptor24from onlyflow.models.unet import UNetMotionModel25from onlyflow.pipelines.pipeline_animation_long import FlowCtrlPipeline26from tools.optical_flow import get_optical_flow27 28 29def save_videos_grid(videos: torch.Tensor, path: str, rescale=False, n_rows=6, fps=8):30 videos = rearrange(videos, "b c t h w -> t b c h w")31 outputs = []32 for x in videos:33 x = torchvision.utils.make_grid(x, nrow=n_rows)34 x = x.transpose(0, 1).transpose(1, 2).squeeze(-1)35 if rescale:36 x = (x + 1.0) / 2.0 # -1,1 -> 0,137 x = (x * 255).numpy().astype(np.uint8)38 outputs.append(x)39 40 os.makedirs(os.path.dirname(path), exist_ok=True)41 imageio.mimsave(path, outputs, fps=fps)42 43css = """44.toolbutton {45 margin-buttom: 0em 0em 0em 0em;46 max-width: 2.5em;47 min-width: 2.5em !important;48 height: 2.5em;49}50"""51 52 53class AnimateController:54 def __init__(self):55 56 # config dirs57 self.basedir = os.getcwd()58 self.stable_diffusion_dir = os.path.join(self.basedir, "models", "StableDiffusion")59 self.motion_module_dir = os.path.join(self.basedir, "models", "Motion_Module")60 self.personalized_model_dir = os.path.join(self.basedir, "models", "DreamBooth_LoRA")61 self.savedir = os.path.join(self.basedir, "samples")62 os.makedirs(self.savedir, exist_ok=True)63 64 65 ckpt_path = hf_hub_download('obvious-research/onlyflow', 'weights_fp16.ckpt')66 ckpt = torch.load(ckpt_path, map_location="cpu", weights_only=True)67 self.flow_encoder_state_dict = ckpt['flow_encoder_state_dict']68 self.attention_processor_state_dict = ckpt['attention_processor_state_dict']69 70 self.tokenizer = None71 self.text_encoder = None72 self.vae = None73 self.unet = None74 self.motion_adapter = None75 76 def update_base_model(self, base_model_id, progress=gr.Progress()):77 78 progress(0, desc="Starting...")79 80 self.tokenizer = CLIPTokenizer.from_pretrained(base_model_id, subfolder="tokenizer")81 self.text_encoder = CLIPTextModel.from_pretrained(base_model_id, subfolder="text_encoder")82 self.vae = AutoencoderKL.from_pretrained(base_model_id, subfolder="vae")83 self.unet = UNet2DConditionModel.from_pretrained(base_model_id, subfolder="unet")84 85 return base_model_id86 87 def update_motion_module(self, motion_module_id, progress=gr.Progress()):88 self.motion_adapter = MotionAdapter.from_pretrained(motion_module_id)89 90 def animate(91 self,92 id_base_model,93 id_motion_module,94 prompt_textbox_positive,95 prompt_textbox_negative,96 seed_textbox,97 input_video,98 height,99 width,100 flow_scale,101 cfg,102 diffusion_steps,103 temporal_ds,104 ctx_stride105 ):106 #if any([x is None for x in [self.tokenizer, self.text_encoder, self.vae, self.unet, self.motion_adapter]]) or isinstance(self.unet, str):107 self.update_base_model(id_base_model)108 self.update_motion_module(id_motion_module)109 110 self.unet = UNetMotionModel.from_unet2d(111 self.unet,112 motion_adapter=self.motion_adapter113 )114 115 self.raft = raft_large(weights=Raft_Large_Weights.DEFAULT, progress=False).eval()116 117 self.flow_encoder = FlowEncoder(118 downscale_factor=8,119 channels=[320, 640, 1280, 1280],120 nums_rb=2,121 ksize=1,122 sk=True,123 use_conv=False,124 compression_factor=1,125 temporal_attention_nhead=8,126 positional_embeddings="sinusoidal",127 num_positional_embeddings=16,128 checkpointing=False129 ).eval()130 131 self.vae.requires_grad_(False)132 self.text_encoder.requires_grad_(False)133 self.unet.requires_grad_(False)134 self.raft.requires_grad_(False)135 self.flow_encoder.requires_grad_(False)136 137 self.unet.set_all_attn(138 flow_channels=[320, 640, 1280, 1280],139 add_spatial=False,140 add_temporal=True,141 encoder_only=False,142 query_condition=True,143 key_value_condition=True,144 flow_scale=1.0,145 )146 147 self.flow_adaptor = FlowAdaptor(self.unet, self.flow_encoder).eval()148 149 # load the flow encoder weights150 pose_enc_m, pose_enc_u = self.flow_adaptor.flow_encoder.load_state_dict(151 self.flow_encoder_state_dict,152 strict=False153 )154 assert len(pose_enc_m) == 0 and len(pose_enc_u) == 0155 156 # load the attention processor weights157 _, attention_processor_u = self.flow_adaptor.unet.load_state_dict(158 self.attention_processor_state_dict,159 strict=False160 )161 assert len(attention_processor_u) == 0162 163 pipeline = FlowCtrlPipeline(164 vae=self.vae,165 text_encoder=self.text_encoder,166 tokenizer=self.tokenizer,167 unet=self.unet,168 motion_adapter=self.motion_adapter,169 flow_encoder=self.flow_encoder,170 scheduler=DDIMScheduler.from_pretrained(id_base_model, subfolder="scheduler"),171 )172 173 if int(seed_textbox) > 0:174 seed = int(seed_textbox)175 else:176 seed = random.randint(1, int(1e16))177 178 return animate_diffusion(seed, pipeline, self.raft, input_video, prompt_textbox_positive, prompt_textbox_negative, width, height, flow_scale, cfg, diffusion_steps, temporal_ds, ctx_stride)179 180@spaces.GPU(duration=150)181def animate_diffusion(seed, pipeline, raft_model, base_video, prompt_textbox, negative_prompt_textbox, width_slider, height_slider, flow_scale, cfg, diffusion_steps, temporal_ds, context_stride):182 savedir = './samples'183 device = "cuda" if torch.cuda.is_available() else "mps" if torch.backends.mps.is_available() else "cpu"184 generator = torch.Generator(device="cpu")185 generator.manual_seed(seed)186 187 raft_model = raft_model.to(device)188 pipeline = pipeline.to(device)189 190 pixel_values = torchvision.io.read_video(base_video, output_format="TCHW", pts_unit='sec')[0][::temporal_ds]191 print("Video loaded, shape:", pixel_values.shape)192 if width_slider/height_slider > pixel_values.shape[3]/pixel_values.shape[2]:193 print("Resizing video to fit width cause input video is not wide enough")194 temp_height = int(width_slider * pixel_values.shape[2]/pixel_values.shape[3])195 temp_width = width_slider196 else:197 print("Resizing video to fit height cause input video is not tall enough")198 temp_height = height_slider199 temp_width = int(height_slider * pixel_values.shape[3]/pixel_values.shape[2])200 print("Resizing video to:", temp_height, temp_width)201 pixel_values = T.Resize((temp_height, temp_width))(pixel_values)202 pixel_values = T.CenterCrop((height_slider, width_slider))(pixel_values)203 pixel_values = T.ConvertImageDtype(torch.float32)(pixel_values)[None, ...].contiguous().to(device)204 205 save_sample_path_input = os.path.join(savedir, f"input.mp4")206 pixel_values_save = pixel_values[0] * 255207 pixel_values_save = pixel_values_save.cpu()208 pixel_values_save = torch.permute(pixel_values_save, (0, 2, 3, 1))209 torchvision.io.write_video(save_sample_path_input, pixel_values_save, fps=8)210 del pixel_values_save211 212 print("Video loaded, shape:", pixel_values.shape)213 flow = get_optical_flow(214 raft_model,215 (pixel_values * 2) - 1,216 pixel_values.shape[1] - 1,217 encode_chunk_size=16,218 ).to('cpu')219 220 sample_flow = (flow_to_image(rearrange(flow[0], "c f h w -> f c h w"))) # N, 3, H, W221 save_sample_path_flow = os.path.join(savedir, f"flow.mp4")222 sample_flow = (sample_flow).cpu().to(torch.uint8).permute(0, 2, 3, 1)223 torchvision.io.write_video(save_sample_path_flow, sample_flow, fps=8)224 del sample_flow225 226 original_flow_shape = flow.shape227 print("Optical flow computed, shape:", flow.shape)228 if flow.shape[2] < 16:229 print("Video is too short, padding to 16 frames")230 video_length = 16231 n = 16 - flow.shape[2]232 # create a tensor containing the last frame optical flow repeated n times233 to_add = flow[:, :, -1].unsqueeze(2).expand(-1, -1, n, -1, -1)234 flow = torch.cat([flow, to_add], dim=2).to(device)235 elif flow.shape[2] > 16:236 print("Video is too long, enabling windowing")237 print("Enabling model CPU offload")238 pipeline.enable_model_cpu_offload()239 print("Enabling VAE slicing")240 pipeline.enable_vae_slicing()241 print("Enabling VAE tiling")242 pipeline.enable_vae_tiling()243 244 print("Enabling free noise")245 pipeline.enable_free_noise(246 context_length=16,247 context_stride=context_stride,248 )249 250 import math251 252 def find_divisors(n: int):253 """254 Return sorted list of all positive divisors of n.255 Uses a sqrt(n) approach for efficiency.256 """257 divs = set()258 limit = int(math.isqrt(n))259 for i in range(1, limit + 1):260 if n % i == 0:261 divs.add(i)262 divs.add(n // i)263 return sorted(divs)264 265 def multiples_in_range(k: int, min_val: int, max_val: int):266 """267 Return all multiples of k within [min_val, max_val].268 """269 if k == 0:270 return []271 272 # First multiple of k >= min_val273 start = ((min_val + k - 1) // k) * k274 # Last multiple of k <= max_val275 end = (max_val // k) * k276 277 return list(range(start, end + 1, k)) if start <= end else []278 279 def adjust_video_length(original_length: int,280 context_stride: int,281 chunk_size: int,282 temporal_split_size: int) -> int:283 """284 Find the minimal video_length >= original_length satisfying:285 1) (video_length - 16) is divisible by context_stride.286 2) EITHER (2*video_length) is divisible by temporal_split_size287 OR (2*video_length) is divisible by chunk_size288 (when 2*video_length is not multiple of temporal_split_size).289 """290 291 # We start at least at 16 (though in practice original_length likely > 16)292 candidate = max(original_length, 16)293 294 # We want (candidate - 16) % context_stride == 0295 # so let n be the multiple to step.296 # n is how many times we add `context_stride` beyond 16.297 # This ensures (candidate - 16) is a multiple of context_stride.298 # Then we check the second condition, else keep stepping.299 300 # If candidate < 16, bump it to 16301 if candidate < 16:302 candidate = 16303 304 # Make sure we jump to the correct "starting multiple" of context_stride305 offset = (candidate - 16) % context_stride306 if offset != 0:307 candidate += (context_stride - offset) # jump to the next multiple308 309 while True:310 # Condition: (candidate - 16) is multiple of context_stride (already enforced by stepping)311 # Check second part:312 # - if (2*candidate) % temporal_split_size == 0, we are good313 # - else we require (2*candidate) % chunk_size == 0314 twoL = 2 * candidate315 if (twoL % temporal_split_size == 0) or (twoL % chunk_size == 0):316 return candidate317 318 # Go to next valid candidate319 candidate += context_stride320 321 def find_valid_configs(original_video_length: int,322 width: int,323 height: int,324 context_stride: int):325 """326 Generate all valid tuples (chunk_size, spatial_split_size, temporal_split_size, video_length)327 subject to the constraints:328 1) chunk_size divides temporal_split_size329 2) chunk_size divides spatial_split_size330 3) chunk_size divides (2 * (width//64) * (height//64))331 4) if (2*video_length) % temporal_split_size != 0, then chunk_size divides (2*video_length)332 5) context_stride divides (video_length - 16)333 6) 128 <= spatial_split_size <= 512334 7) 1 <= temporal_split_size <= 32335 8) 1 <= chunk_size <= 16336 337 We allow increasing original_video_length minimally if needed to satisfy constraints #4 and #5.338 """339 340 factor = 2 * (width // 64) * (height // 64)341 342 # 1) find all possible chunk_size as divisors of factor, in [1..16]343 possible_chunks = [d for d in find_divisors(factor) if 1 <= d <= 32]344 345 # For storing results346 valid_tuples = []347 348 for chunk_size in possible_chunks:349 # 2) generate all spatial_split_size in [128..512] that are multiples of chunk_size350 spatial_splits = multiples_in_range(chunk_size, 480, 512)351 352 # 3) generate all temporal_split_size in [1..32] that are multiples of chunk_size353 temporal_splits = multiples_in_range(chunk_size, 1, 32)354 355 for ssp in spatial_splits:356 for tsp in temporal_splits:357 # 4) & 5) Adjust video_length minimally to satisfy constraints358 final_length = adjust_video_length(original_video_length,359 context_stride,360 chunk_size,361 tsp)362 # Now we have a valid (chunk_size, ssp, tsp, final_length)363 valid_tuples.append((chunk_size, ssp, tsp, final_length))364 365 return valid_tuples366 367 def find_pareto_optimal(configs):368 """369 Given a list of tuples (chunk_size, spatial_split_size, temporal_split_size, video_length),370 return the Pareto-optimal subset under the criteria:371 - chunk_size: larger is better372 - spatial_split_size: larger is better373 - temporal_split_size: larger is better374 - video_length: smaller is better375 """376 377 def dominates(A, B):378 cA, sA, tA, lA = A379 cB, sB, tB, lB = B380 381 # A dominates B if:382 # cA >= cB, sA >= sB, tA >= tB, and lA <= lB383 # AND at least one of these is a strict inequality.384 385 better_or_equal = (cA >= cB) and (tA >= tB) and (lA <= lB)386 strictly_better = (cA > cB) or (tA > tB) or (lA < lB)387 388 return better_or_equal and strictly_better389 390 pareto = []391 for i, cfg_i in enumerate(configs):392 # Check if cfg_i is dominated by any cfg_j393 is_dominated = False394 for j, cfg_j in enumerate(configs):395 if i == j:396 continue397 if dominates(cfg_j, cfg_i):398 is_dominated = True399 break400 if not is_dominated:401 pareto.append(cfg_i)402 403 return pareto404 405 print("Finding valid configurations...")406 valid_configs = find_valid_configs(407 original_video_length=flow.shape[2],408 width=width_slider,409 height=height_slider,410 context_stride=context_stride411 )412 413 print("Found", len(valid_configs), "valid configurations")414 print("Finding Pareto-optimal configurations...")415 pareto_optimal = find_pareto_optimal(valid_configs)416 417 print("Found", pareto_optimal)418 419 criteria = lambda cs, sss, tss, vl: cs + tss - 3 * int(abs(flow.shape[2] - vl) / 10)420 pareto_optimal.sort(key=lambda x: criteria(*x), reverse=True)421 422 print("Found sorted", pareto_optimal)423 424 solution = pareto_optimal[0]425 chunk_size, spatial_split_size, temporal_split_size, video_length = solution426 427 n = video_length - original_flow_shape[2]428 to_add = flow[:, :, -1].unsqueeze(2).expand(-1, -1, n, -1, -1)429 flow = torch.cat([flow, to_add], dim=2)430 431 pipeline.enable_free_noise_split_inference(432 temporal_split_size=temporal_split_size,433 spatial_split_size=spatial_split_size434 )435 pipeline.unet.enable_forward_chunking(chunk_size)436 437 print("Chunking enabled with chunk size:", chunk_size)438 print("Temporal split size:", temporal_split_size)439 print("Spatial split size:", spatial_split_size)440 print("Context stride:", context_stride)441 print("Temporal downscale:", temporal_ds)442 print("Video length:", video_length)443 print("Flow shape:", flow.shape)444 else:445 print("Video is just right, no padding or windowing needed")446 flow = flow.to(device)447 video_length = flow.shape[2]448 449 sample_vid = pipeline(450 prompt_textbox,451 negative_prompt=negative_prompt_textbox,452 optical_flow=flow,453 num_inference_steps=diffusion_steps,454 guidance_scale=cfg,455 width=width_slider,456 height=height_slider,457 num_frames=video_length,458 val_scale_factor_temporal=flow_scale,459 generator=generator,460 ).frames[0]461 462 del flow463 if device == "cuda":464 torch.cuda.synchronize()465 torch.cuda.empty_cache()466 467 save_sample_path_video = os.path.join(savedir, f"sample.mp4")468 sample_vid = sample_vid[:original_flow_shape[2]] * 255.469 sample_vid = sample_vid.cpu().numpy()470 sample_vid = np.transpose(sample_vid, axes=(0, 2, 3, 1))471 torchvision.io.write_video(save_sample_path_video, sample_vid, fps=8)472 473 return gr.Video(value=save_sample_path_flow), gr.Video(value=save_sample_path_video)474 475controller = AnimateController()476 477 478def find_closest_ratio(target_ratio):479 width_list = list(reversed(range(256, 1025, 64)))480 height_list = list(reversed(range(256, 1025, 64)))481 ratio_list = [(h, w, w/h) for h in height_list for w in width_list]482 ratio_list.sort(key=lambda x: abs(x[2] - target_ratio))483 ratio_list = list(filter(lambda x: x[2] == ratio_list[0][2], ratio_list))484 ratio_list.sort(key=lambda x: abs(x[0]*x[1] - 512*512))485 return ratio_list[0][:2]486 487 488def find_dimension(video):489 import av490 container = av.open(open(video, 'rb'))491 height, width = container.streams.video[0].height, container.streams.video[0].width492 target_ratio = width / height493 return find_closest_ratio(target_ratio)494 495 496def ui():497 with gr.Blocks(css=css) as demo:498 gr.Markdown(499 """500 # <p style="text-align:center;">OnlyFlow: Optical Flow based Motion Conditioning for Video Diffusion Models</p>501 Mathis Koroglu, Hugo Caselles-Dupré, Guillaume Jeanneret Sanmiguel, Matthieu Cord<br>502 [Arxiv Report](https://arxiv.org/abs/2411.10501) | [Project Page](https://obvious-research.github.io/onlyflow/) | [Github](https://github.com/obvious-research/onlyflow/)503 """504 )505 gr.Markdown(506 """507 ### Quick Start:508 509 1. Select desired `Base Model`.510 2. Select `Motion Module`. We recommend trying guoyww/animatediff-motion-adapter-v1-5-3 for the best results.511 3. Provide `Positive Prompt` and `Negative Prompt`. You are encouraged to refer to each model's webpage on HuggingFace Hub or CivitAI to learn how to write prompts for them.512 4. Upload a video to extract optical flow from.513 5. Select a 'Flow Scale' to modulate the input video optical flow conditioning.514 6. Select a 'CFG' and 'Diffusion Steps' to control the quality of the generated video and prompt adherence.515 7. Select a 'Temporal Downsample' to reduce the number of frames in the input video.516 8. If you want to use a custom dimension, check the `Custom Dimension` box and adjust the `Width` and `Height` sliders.517 9. If the video is too long, you can adjust the generation window offset with the `Context Stride` slider.518 10. Click `Generate`, wait for ~1/3 min, and enjoy the result!519 520 If you have any error concerning GPU limits, please try again later when your ZeroGPU quota is reset, or try with a shorter video.521 Otherwise, you can also duplicate this space and select a custom GPU plan.522 """523 )524 with gr.Row():525 with gr.Column():526 527 gr.Markdown("# INPUTS")528 529 with gr.Row(equal_height=True, show_progress=True):530 base_model = gr.Dropdown(531 label="Select or type a base model id",532 choices=[533 "stable-diffusion-v1-5/stable-diffusion-v1-5",534 "digiplay/Photon_v1",535 ],536 interactive=True,537 scale=4,538 allow_custom_value=True,539 show_label=True540 )541 base_model_btn = gr.Button(value="Update", scale=1, size='lg')542 with gr.Row(equal_height=True, show_progress=True):543 motion_module = gr.Dropdown(544 label="Select or type a motion module id",545 choices=[546 "guoyww/animatediff-motion-adapter-v1-5-3",547 "guoyww/animatediff-motion-adapter-v1-5-2"548 ],549 interactive=True,550 scale=4551 )552 motion_module_btn = gr.Button(value="Update", scale=1, size='lg')553 554 base_model_btn.click(fn=controller.update_base_model, inputs=[base_model])555 motion_module_btn.click(fn=controller.update_motion_module, inputs=[motion_module])556 557 prompt_textbox_positive = gr.Textbox(label="Positive Prompt", lines=3)558 prompt_textbox_negative = gr.Textbox(label="Negative Prompt", lines=2, value="worst quality, low quality, nsfw, logo")559 560 flow_scale = gr.Slider(label="Flow Scale", value=1.0, minimum=0, maximum=2, step=0.025)561 diffusion_steps = gr.Slider(label="Diffusion Steps", value=25, minimum=0, maximum=100, step=1)562 cfg = gr.Slider(label="CFG", value=7.5, minimum=0, maximum=30, step=0.1)563 564 temporal_ds = gr.Slider(label="Temporal Downsample", value=1, minimum=1, maximum=30, step=1)565 566 input_video = gr.Video(label="Input Video", interactive=True)567 ctx_stride = gr.State(12)568 569 with gr.Accordion("Advanced", open=False):570 use_custom_dim = gr.Checkbox(label="Custom Dimension", value=False)571 572 with gr.Row(equal_height=True):573 574 height, width = gr.State(512), gr.State(512)575 576 @gr.render(inputs=[use_custom_dim, input_video])577 def render_custom_dim(use_custom_dim, input_video):578 if input_video is not None:579 loc_height, loc_width = find_dimension(input_video)580 else:581 loc_height, loc_width = 512, 512582 slider_width = gr.Slider(label="Width", value=loc_width, minimum=256, maximum=1024,583 step=64, visible=use_custom_dim)584 slider_height = gr.Slider(label="Height", value=loc_height, minimum=256, maximum=1024,585 step=64, visible=use_custom_dim)586 587 slider_width.change(lambda x: x, inputs=[slider_width], outputs=[width])588 slider_height.change(lambda x: x, inputs=[slider_height], outputs=[height])589 590 591 with gr.Row():592 @gr.render(inputs=input_video)593 def render_ctx_stride(input_video):594 if input_video is not None:595 video = open(input_video, 'rb')596 import av597 container = av.open(video)598 num_frames = container.streams.video[0].frames599 if num_frames > 17:600 stride_slider = gr.Slider(label="Context Stride", value=12, minimum=1, maximum=16, step=1)601 stride_slider.input(lambda x: x, inputs=[stride_slider], outputs=[ctx_stride])602 if num_frames > 32:603 gr.Warning(f"Video is long ({num_frames} frames), consider using a shorter video, increasing the context stride, or selecting a custom GPU plan.")604 elif num_frames > 64:605 raise gr.Error(f"Video is too long ({num_frames} frames), please use a shorter video, increase the context stride, or select a custom GPU plan. The current parameters won't allow generation on ZeroGPU.")606 607 with gr.Row(equal_height=True):608 seed_textbox = gr.Textbox(label="Seed", value='-1')609 610 seed_button = gr.Button(value="\U0001F3B2", elem_classes="toolbutton")611 seed_button.click(612 fn=lambda: random.randint(1, int(1e16)),613 inputs=[],614 outputs=[seed_textbox]615 )616 617 with gr.Row():618 clear_btn = gr.ClearButton(value="Clear & Reset", size='lg', variant='secondary', scale=1)619 generate_button = gr.Button(value="Generate", variant='primary', scale=2, size='lg')620 621 clear_btn.add([base_model, motion_module, input_video, prompt_textbox_positive, prompt_textbox_negative, seed_textbox, use_custom_dim, ctx_stride])622 623 with gr.Column():624 625 gr.Markdown("# OUTPUTS")626 627 result_optical_flow = gr.Video(label="Optical Flow", interactive=False)628 result_video = gr.Video(label="Generated Animation", interactive=False)629 630 inputs = [base_model, motion_module, prompt_textbox_positive, prompt_textbox_negative, seed_textbox, input_video, height, width, flow_scale, cfg, diffusion_steps, temporal_ds, ctx_stride]631 outputs = [result_optical_flow, result_video]632 633 generate_button.click(fn=controller.animate, inputs=inputs, outputs=outputs)634 635 return demo636 637 638if __name__ == "__main__":639 demo = ui()640 demo.queue(max_size=20)641 demo.launch()