angle / app.py
seawolf2357's picture
Update app.py
9874720 verified
raw
history blame
29.9 kB
import gradio as gr
import numpy as np
import random
import torch
import spaces
from PIL import Image
from diffusers import FlowMatchEulerDiscreteScheduler
from qwenimage.pipeline_qwenimage_edit_plus import QwenImageEditPlusPipeline
from qwenimage.transformer_qwenimage import QwenImageTransformer2DModel
import math
from huggingface_hub import hf_hub_download
from safetensors.torch import load_file
from PIL import Image
import os
import gradio as gr
from gradio_client import Client, handle_file
import tempfile
from typing import Optional, Tuple, Any
# --- Model Loading ---
dtype = torch.bfloat16
device = "cuda" if torch.cuda.is_available() else "cpu"
pipe = QwenImageEditPlusPipeline.from_pretrained(
"Qwen/Qwen-Image-Edit-2509",
transformer=QwenImageTransformer2DModel.from_pretrained(
"linoyts/Qwen-Image-Edit-Rapid-AIO",
subfolder='transformer',
torch_dtype=dtype,
device_map='cuda'
),
torch_dtype=dtype
).to(device)
pipe.load_lora_weights(
"dx8152/Qwen-Edit-2509-Multiple-angles",
weight_name="镜头转换.safetensors",
adapter_name="angles"
)
pipe.set_adapters(["angles"], adapter_weights=[1.])
pipe.fuse_lora(adapter_names=["angles"], lora_scale=1.25)
pipe.unload_lora_weights()
spaces.aoti_blocks_load(pipe.transformer, "zerogpu-aoti/Qwen-Image", variant="fa3")
MAX_SEED = np.iinfo(np.int32).max
def _generate_video_segment(
input_image_path: str,
output_image_path: str,
prompt: str,
request: gr.Request
) -> str:
"""
Generate a single video segment between two frames by calling an external
Wan 2.2 image-to-video service hosted on Hugging Face Spaces.
"""
x_ip_token = request.headers['x-ip-token']
video_client = Client(
"multimodalart/wan-2-2-first-last-frame",
headers={"x-ip-token": x_ip_token}
)
result = video_client.predict(
start_image_pil=handle_file(input_image_path),
end_image_pil=handle_file(output_image_path),
prompt=prompt,
api_name="/generate_video",
)
return result[0]["video"]
def build_camera_prompt(
rotate_deg: float = 0.0,
move_forward: float = 0.0,
vertical_tilt: float = 0.0,
wideangle: bool = False
) -> str:
"""
Build a camera movement prompt based on the chosen controls.
"""
prompt_parts = []
# Rotation
if rotate_deg != 0:
direction = "left" if rotate_deg > 0 else "right"
if direction == "left":
prompt_parts.append(
f"将镜头向左旋转{abs(rotate_deg)}度 Rotate the camera {abs(rotate_deg)} degrees to the left."
)
else:
prompt_parts.append(
f"将镜头向右旋转{abs(rotate_deg)}度 Rotate the camera {abs(rotate_deg)} degrees to the right."
)
# Move forward / close-up
if move_forward > 5:
prompt_parts.append("将镜头转为特写镜头 Turn the camera to a close-up.")
elif move_forward >= 1:
prompt_parts.append("将镜头向前移动 Move the camera forward.")
# Vertical tilt
if vertical_tilt <= -1:
prompt_parts.append("将相机转向鸟瞰视角 Turn the camera to a bird's-eye view.")
elif vertical_tilt >= 1:
prompt_parts.append("将相机切换到仰视视角 Turn the camera to a worm's-eye view.")
# Lens option
if wideangle:
prompt_parts.append(" 将镜头转为广角镜头 Turn the camera to a wide-angle lens.")
final_prompt = " ".join(prompt_parts).strip()
return final_prompt if final_prompt else "no camera movement"
@spaces.GPU
def infer_camera_edit(
image: Optional[Image.Image] = None,
rotate_deg: float = 0.0,
move_forward: float = 0.0,
vertical_tilt: float = 0.0,
wideangle: bool = False,
seed: int = 0,
randomize_seed: bool = True,
true_guidance_scale: float = 1.0,
num_inference_steps: int = 4,
height: Optional[int] = None,
width: Optional[int] = None,
prev_output: Optional[Image.Image] = None,
) -> Tuple[Image.Image, int, str]:
"""
Edit the camera angles/view of an image with Qwen Image Edit 2509.
"""
progress = gr.Progress(track_tqdm=True)
prompt = build_camera_prompt(rotate_deg, move_forward, vertical_tilt, wideangle)
print(f"Generated Prompt: {prompt}")
if randomize_seed:
seed = random.randint(0, MAX_SEED)
generator = torch.Generator(device=device).manual_seed(seed)
# Choose input image (prefer uploaded, else last output)
pil_images = []
if image is not None:
if isinstance(image, Image.Image):
pil_images.append(image.convert("RGB"))
elif hasattr(image, "name"):
pil_images.append(Image.open(image.name).convert("RGB"))
elif prev_output:
pil_images.append(prev_output.convert("RGB"))
if len(pil_images) == 0:
raise gr.Error("Please upload an image first.")
if prompt == "no camera movement":
return image, seed, prompt
result = pipe(
image=pil_images,
prompt=prompt,
height=height if height != 0 else None,
width=width if width != 0 else None,
num_inference_steps=num_inference_steps,
generator=generator,
true_cfg_scale=true_guidance_scale,
num_images_per_prompt=1,
).images[0]
return result, seed, prompt
def create_video_between_images(
input_image: Optional[Image.Image],
output_image: Optional[np.ndarray],
prompt: str,
request: gr.Request
) -> str:
"""
Create a short transition video between the input and output images.
"""
if input_image is None or output_image is None:
raise gr.Error("Both input and output images are required to create a video.")
try:
with tempfile.NamedTemporaryFile(delete=False, suffix=".png") as tmp:
input_image.save(tmp.name)
input_image_path = tmp.name
output_pil = Image.fromarray(output_image.astype('uint8'))
with tempfile.NamedTemporaryFile(delete=False, suffix=".png") as tmp:
output_pil.save(tmp.name)
output_image_path = tmp.name
video_path = _generate_video_segment(
input_image_path,
output_image_path,
prompt if prompt else "Camera movement transformation",
request
)
return video_path
except Exception as e:
raise gr.Error(f"Video generation failed: {e}")
# ============================================
# 🎨 Comic Classic Theme - Toon Playground
# ============================================
css = """
/* ===== 🎨 Google Fonts Import ===== */
@import url('https://fonts.googleapis.com/css2?family=Bangers&family=Comic+Neue:wght@400;700&display=swap');
/* ===== 🎨 Comic Classic 배경 - 빈티지 페이퍼 + 도트 패턴 ===== */
.gradio-container {
background-color: #FEF9C3 !important;
background-image:
radial-gradient(#1F2937 1px, transparent 1px) !important;
background-size: 20px 20px !important;
min-height: 100vh !important;
font-family: 'Comic Neue', cursive, sans-serif !important;
}
/* ===== 허깅페이스 상단 요소 숨김 ===== */
.huggingface-space-header,
#space-header,
.space-header,
[class*="space-header"],
.svelte-1ed2p3z,
.space-header-badge,
.header-badge,
[data-testid="space-header"],
.svelte-kqij2n,
.svelte-1ax1toq,
.embed-container > div:first-child {
display: none !important;
visibility: hidden !important;
height: 0 !important;
width: 0 !important;
overflow: hidden !important;
opacity: 0 !important;
pointer-events: none !important;
}
/* ===== Footer 완전 숨김 ===== */
footer,
.footer,
.gradio-container footer,
.built-with,
[class*="footer"],
.gradio-footer,
.main-footer,
div[class*="footer"],
.show-api,
.built-with-gradio,
a[href*="gradio.app"],
a[href*="huggingface.co/spaces"] {
display: none !important;
visibility: hidden !important;
height: 0 !important;
padding: 0 !important;
margin: 0 !important;
}
/* ===== 메인 컨테이너 ===== */
#col-container {
max-width: 1000px;
margin: 0 auto;
}
/* ===== 🎨 헤더 타이틀 - 코믹 스타일 ===== */
.header-text h1 {
font-family: 'Bangers', cursive !important;
color: #1F2937 !important;
font-size: 3.5rem !important;
font-weight: 400 !important;
text-align: center !important;
margin-bottom: 0.5rem !important;
text-shadow:
4px 4px 0px #FACC15,
6px 6px 0px #1F2937 !important;
letter-spacing: 3px !important;
-webkit-text-stroke: 2px #1F2937 !important;
}
/* ===== 🎨 서브타이틀 ===== */
.subtitle {
text-align: center !important;
font-family: 'Comic Neue', cursive !important;
font-size: 1.2rem !important;
color: #1F2937 !important;
margin-bottom: 1.5rem !important;
font-weight: 700 !important;
}
/* ===== 🎨 카드/패널 - 만화 프레임 스타일 ===== */
.gr-panel,
.gr-box,
.gr-form,
.block,
.gr-group {
background: #FFFFFF !important;
border: 3px solid #1F2937 !important;
border-radius: 8px !important;
box-shadow: 6px 6px 0px #1F2937 !important;
transition: all 0.2s ease !important;
}
.gr-panel:hover,
.block:hover {
transform: translate(-2px, -2px) !important;
box-shadow: 8px 8px 0px #1F2937 !important;
}
/* ===== 🎨 입력 필드 (Textbox) ===== */
textarea,
input[type="text"],
input[type="number"] {
background: #FFFFFF !important;
border: 3px solid #1F2937 !important;
border-radius: 8px !important;
color: #1F2937 !important;
font-family: 'Comic Neue', cursive !important;
font-size: 1rem !important;
font-weight: 700 !important;
transition: all 0.2s ease !important;
}
textarea:focus,
input[type="text"]:focus,
input[type="number"]:focus {
border-color: #3B82F6 !important;
box-shadow: 4px 4px 0px #3B82F6 !important;
outline: none !important;
}
textarea::placeholder {
color: #9CA3AF !important;
font-weight: 400 !important;
}
/* ===== 🎨 Primary 버튼 - 코믹 블루 ===== */
.gr-button-primary,
button.primary,
.gr-button.primary {
background: #3B82F6 !important;
border: 3px solid #1F2937 !important;
border-radius: 8px !important;
color: #FFFFFF !important;
font-family: 'Bangers', cursive !important;
font-weight: 400 !important;
font-size: 1.3rem !important;
letter-spacing: 2px !important;
padding: 14px 28px !important;
box-shadow: 5px 5px 0px #1F2937 !important;
transition: all 0.1s ease !important;
text-shadow: 1px 1px 0px #1F2937 !important;
}
.gr-button-primary:hover,
button.primary:hover,
.gr-button.primary:hover {
background: #2563EB !important;
transform: translate(-2px, -2px) !important;
box-shadow: 7px 7px 0px #1F2937 !important;
}
.gr-button-primary:active,
button.primary:active,
.gr-button.primary:active {
transform: translate(3px, 3px) !important;
box-shadow: 2px 2px 0px #1F2937 !important;
}
/* ===== 🎨 Secondary 버튼 - 코믹 레드 ===== */
.gr-button-secondary,
button.secondary,
.generate-btn {
background: #EF4444 !important;
border: 3px solid #1F2937 !important;
border-radius: 8px !important;
color: #FFFFFF !important;
font-family: 'Bangers', cursive !important;
font-weight: 400 !important;
font-size: 1.1rem !important;
letter-spacing: 1px !important;
box-shadow: 4px 4px 0px #1F2937 !important;
transition: all 0.1s ease !important;
text-shadow: 1px 1px 0px #1F2937 !important;
}
.gr-button-secondary:hover,
button.secondary:hover,
.generate-btn:hover {
background: #DC2626 !important;
transform: translate(-2px, -2px) !important;
box-shadow: 6px 6px 0px #1F2937 !important;
}
.gr-button-secondary:active,
button.secondary:active,
.generate-btn:active {
transform: translate(2px, 2px) !important;
box-shadow: 2px 2px 0px #1F2937 !important;
}
/* ===== 🎨 Reset 버튼 - 코믹 옐로우 ===== */
.reset-btn {
background: #FACC15 !important;
border: 3px solid #1F2937 !important;
border-radius: 8px !important;
color: #1F2937 !important;
font-family: 'Bangers', cursive !important;
font-weight: 400 !important;
font-size: 1.1rem !important;
letter-spacing: 1px !important;
box-shadow: 4px 4px 0px #1F2937 !important;
transition: all 0.1s ease !important;
}
.reset-btn:hover {
background: #EAB308 !important;
transform: translate(-2px, -2px) !important;
box-shadow: 6px 6px 0px #1F2937 !important;
}
.reset-btn:active {
transform: translate(2px, 2px) !important;
box-shadow: 2px 2px 0px #1F2937 !important;
}
/* ===== 🎨 Video 버튼 - 코믹 퍼플 ===== */
.video-btn {
background: #8B5CF6 !important;
border: 3px solid #1F2937 !important;
border-radius: 8px !important;
color: #FFFFFF !important;
font-family: 'Bangers', cursive !important;
font-weight: 400 !important;
font-size: 1.1rem !important;
letter-spacing: 1px !important;
box-shadow: 4px 4px 0px #1F2937 !important;
transition: all 0.1s ease !important;
text-shadow: 1px 1px 0px #1F2937 !important;
}
.video-btn:hover {
background: #7C3AED !important;
transform: translate(-2px, -2px) !important;
box-shadow: 6px 6px 0px #1F2937 !important;
}
.video-btn:active {
transform: translate(2px, 2px) !important;
box-shadow: 2px 2px 0px #1F2937 !important;
}
/* ===== 🎨 로그 출력 영역 ===== */
.info-log textarea,
.prompt-preview textarea {
background: #1F2937 !important;
color: #10B981 !important;
font-family: 'Courier New', monospace !important;
font-size: 0.9rem !important;
font-weight: 400 !important;
border: 3px solid #10B981 !important;
border-radius: 8px !important;
box-shadow: 4px 4px 0px #10B981 !important;
}
/* ===== 🎨 이미지 업로드 영역 ===== */
.image-upload {
border: 4px dashed #3B82F6 !important;
border-radius: 12px !important;
background: #EFF6FF !important;
transition: all 0.2s ease !important;
}
.image-upload:hover {
border-color: #EF4444 !important;
background: #FEF2F2 !important;
}
/* ===== 🎨 아코디언 - 말풍선 스타일 ===== */
.gr-accordion {
background: #FACC15 !important;
border: 3px solid #1F2937 !important;
border-radius: 8px !important;
box-shadow: 4px 4px 0px #1F2937 !important;
}
.gr-accordion-header {
color: #1F2937 !important;
font-family: 'Comic Neue', cursive !important;
font-weight: 700 !important;
font-size: 1.1rem !important;
}
/* ===== 🎨 탭 스타일 - 코믹 스타일 ===== */
.gr-tab-nav {
background: #FFFFFF !important;
border: 3px solid #1F2937 !important;
border-radius: 8px 8px 0 0 !important;
box-shadow: 4px 0px 0px #1F2937 !important;
}
.gr-tab-nav button {
font-family: 'Comic Neue', cursive !important;
font-weight: 700 !important;
color: #1F2937 !important;
border: none !important;
background: transparent !important;
}
.gr-tab-nav button.selected {
background: #3B82F6 !important;
color: #FFFFFF !important;
border-radius: 6px !important;
}
/* ===== 🎨 슬라이더 스타일 ===== */
input[type="range"] {
accent-color: #3B82F6 !important;
}
.gr-slider {
background: #FFFFFF !important;
}
/* ===== 🎨 체크박스 스타일 ===== */
input[type="checkbox"] {
accent-color: #3B82F6 !important;
width: 20px !important;
height: 20px !important;
border: 2px solid #1F2937 !important;
border-radius: 4px !important;
}
/* ===== 🎨 이미지 출력 영역 ===== */
.gr-image,
.image-container {
border: 4px solid #1F2937 !important;
border-radius: 8px !important;
box-shadow: 8px 8px 0px #1F2937 !important;
overflow: hidden !important;
background: #FFFFFF !important;
}
/* ===== 🎨 비디오 출력 영역 ===== */
.gr-video {
border: 4px solid #1F2937 !important;
border-radius: 8px !important;
box-shadow: 8px 8px 0px #8B5CF6 !important;
overflow: hidden !important;
background: #FFFFFF !important;
}
/* ===== 🎨 라벨 스타일 ===== */
label,
.gr-input-label,
.gr-block-label {
color: #1F2937 !important;
font-family: 'Comic Neue', cursive !important;
font-weight: 700 !important;
font-size: 1rem !important;
}
span.gr-label {
color: #1F2937 !important;
}
/* ===== 🎨 정보 텍스트 ===== */
.gr-info,
.info {
color: #6B7280 !important;
font-family: 'Comic Neue', cursive !important;
font-size: 0.9rem !important;
}
/* ===== 🎨 프로그레스 바 ===== */
.progress-bar,
.gr-progress-bar {
background: #3B82F6 !important;
border: 2px solid #1F2937 !important;
border-radius: 4px !important;
}
.progress-text {
color: #1F2937 !important;
font-family: 'Comic Neue', cursive !important;
font-weight: 700 !important;
}
/* ===== 🎨 스크롤바 - 코믹 스타일 ===== */
::-webkit-scrollbar {
width: 12px;
height: 12px;
}
::-webkit-scrollbar-track {
background: #FEF9C3;
border: 2px solid #1F2937;
}
::-webkit-scrollbar-thumb {
background: #3B82F6;
border: 2px solid #1F2937;
border-radius: 0px;
}
::-webkit-scrollbar-thumb:hover {
background: #EF4444;
}
/* ===== 🎨 선택 하이라이트 ===== */
::selection {
background: #FACC15;
color: #1F2937;
}
/* ===== 🎨 링크 스타일 ===== */
a {
color: #3B82F6 !important;
text-decoration: none !important;
font-weight: 700 !important;
}
a:hover {
color: #EF4444 !important;
}
/* ===== 🎨 Row/Column 간격 ===== */
.gr-row {
gap: 1.5rem !important;
}
.gr-column {
gap: 1rem !important;
}
/* ===== 🎨 Examples 섹션 ===== */
#examples {
max-width: 1000px;
margin: 0 auto;
}
#examples .gr-sample {
border: 3px solid #1F2937 !important;
border-radius: 8px !important;
box-shadow: 4px 4px 0px #1F2937 !important;
background: #FFFFFF !important;
transition: all 0.2s ease !important;
}
#examples .gr-sample:hover {
transform: translate(-2px, -2px) !important;
box-shadow: 6px 6px 0px #1F2937 !important;
}
/* ===== 반응형 조정 ===== */
@media (max-width: 768px) {
.header-text h1 {
font-size: 2.2rem !important;
text-shadow:
3px 3px 0px #FACC15,
4px 4px 0px #1F2937 !important;
}
.gr-button-primary,
button.primary {
padding: 12px 20px !important;
font-size: 1.1rem !important;
}
.gr-panel,
.block {
box-shadow: 4px 4px 0px #1F2937 !important;
}
}
/* ===== 🎨 다크모드 비활성화 (코믹은 밝아야 함) ===== */
@media (prefers-color-scheme: dark) {
.gradio-container {
background-color: #FEF9C3 !important;
}
.progress-text {
color: #1F2937 !important;
}
}
"""
def reset_all() -> list:
"""
Reset all camera control knobs and flags to their default values.
"""
return [0, 0, 0, False, True]
def end_reset() -> bool:
"""
Mark the end of a reset cycle.
"""
return False
def update_dimensions_on_upload(
image: Optional[Image.Image]
) -> Tuple[int, int]:
"""
Compute recommended (width, height) for the output resolution.
"""
if image is None:
return 1024, 1024
original_width, original_height = image.size
if original_width > original_height:
new_width = 1024
aspect_ratio = original_height / original_width
new_height = int(new_width * aspect_ratio)
else:
new_height = 1024
aspect_ratio = original_width / original_height
new_width = int(new_height * aspect_ratio)
# Ensure dimensions are multiples of 8
new_width = (new_width // 8) * 8
new_height = (new_height // 8) * 8
return new_width, new_height
# Build the Gradio interface with Comic Classic Theme
with gr.Blocks() as demo:
gr.LoginButton(value="Option: HuggingFace 'Login' for extra GPU quota +", size="sm")
# CSS를 HTML style 태그로 삽입
gr.HTML(f"<style>{css}</style>")
# HOME Badge
gr.HTML("""
<div style="text-align: center; margin: 20px 0 10px 0;">
<a href="https://www.humangen.ai" target="_blank" style="text-decoration: none;">
<img src="https://img.shields.io/static/v1?label=🏠 HOME&message=HUMANGEN.AI&color=0000ff&labelColor=ffcc00&style=for-the-badge" alt="HOME">
</a>
</div>
""")
# Header Title
gr.Markdown(
"""
# 🎬 CAMERA ANGLE CONTROL 📷
""",
elem_classes="header-text"
)
gr.Markdown(
"""
<p class="subtitle">🖼️ Upload an image and control camera angles with AI magic! ✨</p>
""",
)
with gr.Column(elem_id="col-container"):
with gr.Row(equal_height=False):
# Left column - Input
with gr.Column(scale=1, min_width=320):
image = gr.Image(
label="📷 Upload Your Image",
type="pil",
elem_classes="image-upload"
)
prev_output = gr.Image(value=None, visible=False)
is_reset = gr.Checkbox(value=False, visible=False)
with gr.Accordion("🎮 Camera Controls", open=True):
rotate_deg = gr.Slider(
label="↔️ Rotate Right-Left (degrees °)",
minimum=-90,
maximum=90,
step=45,
value=0
)
move_forward = gr.Slider(
label="🔍 Move Forward → Close-Up",
minimum=0,
maximum=10,
step=5,
value=0
)
vertical_tilt = gr.Slider(
label="↕️ Vertical Angle (Bird ↔ Worm)",
minimum=-1,
maximum=1,
step=1,
value=0
)
wideangle = gr.Checkbox(
label="📐 Wide-Angle Lens",
value=False
)
with gr.Row():
reset_btn = gr.Button(
"🔄 RESET",
variant="secondary",
elem_classes="reset-btn"
)
run_btn = gr.Button(
"🎬 GENERATE!",
variant="primary",
size="lg",
elem_classes="generate-btn"
)
with gr.Accordion("⚙️ Advanced Settings", open=False):
seed = gr.Slider(
label="🎲 Seed",
minimum=0,
maximum=MAX_SEED,
step=1,
value=0
)
randomize_seed = gr.Checkbox(
label="🔀 Randomize Seed",
value=True
)
true_guidance_scale = gr.Slider(
label="📊 True Guidance Scale",
minimum=1.0,
maximum=10.0,
step=0.1,
value=1.0
)
num_inference_steps = gr.Slider(
label="🔢 Inference Steps",
minimum=1,
maximum=40,
step=1,
value=20
)
height = gr.Slider(
label="📏 Height",
minimum=256,
maximum=2048,
step=8,
value=1024
)
width = gr.Slider(
label="📐 Width",
minimum=256,
maximum=2048,
step=8,
value=1024
)
# Right column - Output
with gr.Column(scale=1, min_width=320):
result = gr.Image(
label="🖼️ Output Image",
interactive=False,
height=400,
)
prompt_preview = gr.Textbox(
label="📝 Generated Prompt",
interactive=False,
lines=3,
elem_classes="prompt-preview"
)
create_video_button = gr.Button(
"🎥 CREATE VIDEO BETWEEN IMAGES!",
variant="secondary",
visible=False,
elem_classes="video-btn"
)
with gr.Group(visible=False) as video_group:
video_output = gr.Video(
label="🎬 Generated Video",
autoplay=True
)
gr.Markdown(
"""
<p style="text-align: center; margin-top: 10px; font-weight: 700; color: #1F2937;">
💡 Right-click on the image to save, or use the download button!
</p>
"""
)
inputs = [
image, rotate_deg, move_forward,
vertical_tilt, wideangle,
seed, randomize_seed, true_guidance_scale, num_inference_steps, height, width, prev_output
]
outputs = [result, seed, prompt_preview]
# Reset behavior
reset_btn.click(
fn=reset_all,
inputs=None,
outputs=[rotate_deg, move_forward, vertical_tilt, wideangle, is_reset],
queue=False
).then(fn=end_reset, inputs=None, outputs=[is_reset], queue=False)
# Manual generation with video button visibility control
def infer_and_show_video_button(*args: Any):
"""
Wrapper around infer_camera_edit that also controls the visibility
of the 'Create Video Between Images' button.
"""
result_img, result_seed, result_prompt = infer_camera_edit(*args)
show_button = args[0] is not None and result_img is not None
return result_img, result_seed, result_prompt, gr.update(visible=show_button)
run_event = run_btn.click(
fn=infer_and_show_video_button,
inputs=inputs,
outputs=outputs + [create_video_button]
)
# Video creation
create_video_button.click(
fn=lambda: gr.update(visible=True),
outputs=[video_group],
api_visibility="private"
).then(
fn=create_video_between_images,
inputs=[image, result, prompt_preview],
outputs=[video_output],
api_visibility="private"
)
# Examples
gr.Examples(
examples=[
["tool_of_the_sea.png", 90, 0, 0, False, 0, True, 1.0, 4, 568, 1024],
["monkey.jpg", -90, 0, 0, False, 0, True, 1.0, 4, 704, 1024],
["metropolis.jpg", 0, 0, -1, False, 0, True, 1.0, 4, 816, 1024],
["disaster_girl.jpg", -45, 0, 1, False, 0, True, 1.0, 4, 768, 1024],
["grumpy.png", 90, 0, 1, False, 0, True, 1.0, 4, 576, 1024]
],
inputs=[
image, rotate_deg, move_forward,
vertical_tilt, wideangle,
seed, randomize_seed, true_guidance_scale, num_inference_steps, height, width
],
outputs=outputs,
fn=infer_camera_edit,
cache_examples=True,
cache_mode="lazy",
elem_id="examples"
)
# Image upload triggers dimension update and control reset
image.upload(
fn=update_dimensions_on_upload,
inputs=[image],
outputs=[width, height]
).then(
fn=reset_all,
inputs=None,
outputs=[rotate_deg, move_forward, vertical_tilt, wideangle, is_reset],
queue=False
).then(
fn=end_reset,
inputs=None,
outputs=[is_reset],
queue=False
)
# Live updates
def maybe_infer(
is_reset: bool,
progress: gr.Progress = gr.Progress(track_tqdm=True),
*args: Any
):
if is_reset:
return gr.update(), gr.update(), gr.update(), gr.update()
else:
result_img, result_seed, result_prompt = infer_camera_edit(*args)
show_button = args[0] is not None and result_img is not None
return result_img, result_seed, result_prompt, gr.update(visible=show_button)
control_inputs = [
image, rotate_deg, move_forward,
vertical_tilt, wideangle,
seed, randomize_seed, true_guidance_scale, num_inference_steps, height, width, prev_output
]
control_inputs_with_flag = [is_reset] + control_inputs
for control in [rotate_deg, move_forward, vertical_tilt]:
control.release(
fn=maybe_infer,
inputs=control_inputs_with_flag,
outputs=outputs + [create_video_button]
)
wideangle.input(
fn=maybe_infer,
inputs=control_inputs_with_flag,
outputs=outputs + [create_video_button]
)
run_event.then(lambda img, *_: img, inputs=[result], outputs=[prev_output])
gr.api(infer_camera_edit, api_name="infer_edit_camera_angles")
gr.api(create_video_between_images, api_name="create_video_between_images")
if __name__ == "__main__":
demo.launch(mcp_server=True)