Secking's picture
Add ZIP download functionality for segment alpha masks with loading modal
bd7ca9e
raw
history blame
67.4 kB
## ENVIRONMENT VARIABLES
# MODAL_VOLUME
# MODAL_TOKEN_ID
# MODAL_ENVIRONMENT
# MODAL_TOKEN_SECRET
import os
import re
import cv2
import io
import time
import base64
import modal
import logging
import tempfile
import numpy as np
import gradio as gr
from PIL import Image
from typing import Dict, Optional, Tuple
logging.basicConfig(level=logging.DEBUG)
logger = logging.getLogger(__name__)
def process_video(video_path, notes, email, company_name) -> str:
"""
Process the input video for content moderation using Modal.
Steps:
1. Upload the provided video to the configured Modal Volume.
2. Obtain the video dimensions (width, height).
3. Call the Content-Moderation reception_function via Modal (synchronously with .remote).
4. Download the processed video returned by the function to /tmp with a random UUID filename.
5. Return the local path to the downloaded video.
"""
# Validate inputs
if not video_path or not os.path.exists(video_path):
logger.error("Invalid video path provided to process_video.")
return "Invalid video path."
# Helper to obtain width and height
def _get_video_dimensions(path: str):
width, height = 1920, 1080 # Default values
try:
# type: ignore
cap = cv2.VideoCapture(path)
if cap.isOpened():
width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
cap.release()
except Exception as e:
logger.debug(f"OpenCV not available or failed to read video dimensions: {e}")
return width, height
try:
# 1. Setup Modal app and volume
_ = os.environ.get('MODAL_TOKEN_ID') # Read to ensure environment readiness (kept for parity with process_audio)
_ = os.environ.get('MODAL_TOKEN_SECRET')
_ = os.environ.get('MODAL_ENVIRONMENT')
modal_volume_name = os.environ['MODERATION_MODAL_VOLUME']
# Unique processing folder and paths
processing_id = str(int(time.time()))
ext = os.path.splitext(video_path)[1]
remote_input_path = f"/{processing_id}/input_video{ext}"
# 2. Upload video to Modal Volume
volume = modal.Volume.from_name(modal_volume_name)
try:
with volume.batch_upload() as batch:
batch.put_file(video_path, remote_input_path)
except Exception as e:
logger.error(f"Error uploading video to Modal Storage: {e}")
return "Error uploading video to Cloud Storage."
# 3. Obtain video dimensions
width, height = _get_video_dimensions(video_path)
# 4. Call Modal function synchronously
try:
moderation_function = modal.Function.from_name("Content-Moderation", "professional_reception_function")
moderation_function.spawn(
input_text=str(notes) if notes is not None else "",
video_path=remote_input_path,
size=(int(width), int(height)),
email=email,
company_name=company_name
)
except Exception as e:
logger.error(f"Error calling Modal reception_function: {e}")
return "Error calling Outpost to trigger processing."
return "Video Request Obtained"
except Exception as e:
logger.error(f"Unexpected error in process_video: {e}")
return "Unexpected error during video processing."
# UUID regex pattern for filtering segment sub-directories
UUID_PATTERN = re.compile(r'^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$')
def submit_magic_code(magic_code):
"""
Validate magic code and retrieve segment list from Modal volume.
Decodes the Base64 magic code to obtain the epoch-based folder name,
mounts the moderation_volume, checks folder existence, and lists
UUID-pattern sub-directories.
Returns:
Tuple of (dropdown_update, status_message, row2_visibility, row3_visibility, row4_visibility, row5_visibility)
"""
if not magic_code:
return (
gr.update(choices=[], label="Segment IDs"),
"Please enter a magic code",
gr.update(visible=False),
gr.update(visible=False),
gr.update(visible=False),
gr.update(visible=False)
)
try:
# Decode Base64 magic code to get folder name
try:
decoded_bytes = base64.b64decode(magic_code, validate=True)
folder_name = decoded_bytes.decode('utf-8').strip()
logger.info(f"Magic code decoded to folder: {folder_name}")
except Exception as e:
logger.error(f"Failed to decode magic code: {e}")
return (
gr.update(choices=[], label="Invalid magic code"),
"Invalid magic code format – please verify your code",
gr.update(visible=False),
gr.update(visible=False),
gr.update(visible=False),
gr.update(visible=False)
)
# Get volume name from environment
try:
modal_volume_name = os.environ['MODERATION_MODAL_VOLUME']
except KeyError:
logger.error("MODERATION_MODAL_VOLUME environment variable not set")
return (
gr.update(choices=[], label="Configuration error"),
"Server configuration error – contact support",
gr.update(visible=False),
gr.update(visible=False),
gr.update(visible=False),
gr.update(visible=False)
)
# Mount volume and check folder existence
try:
volume = modal.Volume.from_name(modal_volume_name)
# List contents at the folder path
folder_path = f"/{folder_name}"
try:
# Use listdir to check if folder exists and list contents
entries = volume.listdir(folder_path)
except Exception as list_error:
logger.error(f"Folder not found or inaccessible: {folder_path} - {list_error}")
return (
gr.update(choices=[], label="Folder not found"),
"Folder not found – please verify your magic code",
gr.update(visible=False),
gr.update(visible=False),
gr.update(visible=False),
gr.update(visible=False)
)
# Filter for UUID-pattern sub-directories
uuid_segments = []
for entry in entries:
# Extract just the name (last component of path)
entry_name = entry.path.rstrip('/').split('/')[-1]
# Check if it's a directory and matches UUID pattern
if entry.type == modal.volume.FileEntryType.DIRECTORY and UUID_PATTERN.match(entry_name):
uuid_segments.append(entry_name)
uuid_segments.sort() # Sort alphabetically for consistent display
if not uuid_segments:
logger.warning(f"No UUID segments found in folder: {folder_name}")
return (
gr.update(choices=[], label="No segments found"),
"No segment IDs found in this folder",
gr.update(visible=False),
gr.update(visible=False),
gr.update(visible=False),
gr.update(visible=False),
gr.update(visible=False)
)
logger.info(f"Found {len(uuid_segments)} UUID segments in folder {folder_name}")
return (
gr.update(choices=uuid_segments, value=uuid_segments[0], label="Segment IDs"),
f"Loaded {len(uuid_segments)} segment(s)",
gr.update(visible=True),
gr.update(visible=True),
gr.update(visible=True),
gr.update(visible=True),
gr.update(visible=True)
)
except Exception as e:
logger.error(f"Error accessing Modal volume: {e}")
return (
gr.update(choices=[], label="Volume access error"),
f"Error accessing storage: {str(e)}",
gr.update(visible=False),
gr.update(visible=False),
gr.update(visible=False),
gr.update(visible=False),
gr.update(visible=False)
)
except Exception as e:
logger.error(f"Unexpected error in submit_magic_code: {e}")
return (
gr.update(choices=[], label="Error"),
"Unexpected error – please try again",
gr.update(visible=False),
gr.update(visible=False),
gr.update(visible=False),
gr.update(visible=False),
gr.update(visible=False)
)
def download_segment_files(magic_code: str, segment_id: str) -> Tuple[Dict[int, Image.Image], Dict[int, Image.Image], int]:
"""
Download all frame images and alpha masks for a given segment from Modal volume.
Returns:
frames_dict: Dict mapping frame_index -> PIL.Image (original frames)
masks_dict: Dict mapping frame_index -> PIL.Image (alpha masks)
max_frame: Maximum frame index found
"""
try:
modal_volume_name = os.environ['MODERATION_MODAL_VOLUME']
volume = modal.Volume.from_name(modal_volume_name)
segment_path = f"/{magic_code}/{segment_id}"
frames_dict = {}
masks_dict = {}
logger.info(f"Downloading files from {segment_path}")
# List all files in the segment directory
try:
files = list(volume.listdir(segment_path))
except Exception as e:
logger.error(f"Failed to list segment directory: {e}")
return {}, {}, 0
# Parse filenames and download
frame_pattern = re.compile(r'^frame_(\d+)\.(jpg|png)$')
alpha_pattern = re.compile(r'^alpha_frame_(\d+)\.png$')
for entry in files:
if entry.type != modal.volume.FileEntryType.FILE:
continue
filename = os.path.basename(entry.path)
# Check if it's a frame file
frame_match = frame_pattern.match(filename)
if frame_match:
frame_idx = int(frame_match.group(1))
try:
# Download frame
file_data = volume.read_file(f"{segment_path}/{filename}")
img = Image.open(io.BytesIO(file_data))
frames_dict[frame_idx] = img.copy()
logger.debug(f"Downloaded frame {frame_idx}")
except Exception as e:
logger.error(f"Failed to download {filename}: {e}")
continue
# Check if it's an alpha mask file
alpha_match = alpha_pattern.match(filename)
if alpha_match:
frame_idx = int(alpha_match.group(1))
try:
# Download alpha mask
file_data = volume.read_file(f"{segment_path}/{filename}")
img = Image.open(io.BytesIO(file_data))
masks_dict[frame_idx] = img.copy()
logger.debug(f"Downloaded alpha mask {frame_idx}")
except Exception as e:
logger.error(f"Failed to download {filename}: {e}")
continue
max_frame = max(frames_dict.keys()) if frames_dict else 0
logger.info(f"Downloaded {len(frames_dict)} frames and {len(masks_dict)} masks. Max frame: {max_frame}")
return frames_dict, masks_dict, max_frame
except Exception as e:
logger.error(f"Error downloading segment files: {e}")
return {}, {}, 0
def composite_image_with_mask(frame: Image.Image, mask: Optional[Image.Image], show_mask: bool) -> Image.Image:
"""
Composite the original frame with the alpha mask overlay.
Args:
frame: Original RGB/RGBA image
mask: Alpha mask (grayscale or RGBA)
show_mask: Whether to show the mask overlay
Returns:
Composited PIL Image
"""
if not show_mask or mask is None:
return frame.copy()
# Convert frame to RGBA if needed
if frame.mode != 'RGBA':
frame_rgba = frame.convert('RGBA')
else:
frame_rgba = frame.copy()
# Convert mask to 'L' (grayscale) if needed
if mask.mode != 'L':
mask_gray = mask.convert('L')
else:
mask_gray = mask.copy()
# Resize mask to match frame if needed
if mask_gray.size != frame_rgba.size:
mask_gray = mask_gray.resize(frame_rgba.size, Image.Resampling.LANCZOS)
# Create a colored overlay (semi-transparent red)
overlay = Image.new('RGBA', frame_rgba.size, (255, 0, 0, 128))
# Use mask as alpha channel for the overlay
overlay.putalpha(mask_gray)
# Composite
result = Image.alpha_composite(frame_rgba, overlay)
return result
def load_segment_frame(segment_id, frame_number, show_mask, magic_code_state, frames_state, masks_state):
"""
Load and display a specific frame with optional alpha mask overlay.
"""
if not segment_id or frames_state is None:
return None, gr.update()
frames_dict = frames_state
masks_dict = masks_state
frame_idx = int(frame_number)
if frame_idx not in frames_dict:
logger.warning(f"Frame {frame_idx} not found in downloaded frames")
return None, gr.update()
frame = frames_dict[frame_idx]
mask = masks_dict.get(frame_idx, None)
# Composite image with mask
result_image = composite_image_with_mask(frame, mask, show_mask)
logger.info(f"Loaded frame {frame_idx} with mask overlay: {show_mask}")
return result_image, gr.update()
def handle_keyboard_navigation(key_code, segment_id, current_frame, show_mask, magic_code_state, frames_state, masks_state):
"""
Handle left/right arrow key navigation for frame slider.
Args:
key_code: JavaScript key code ('ArrowLeft' or 'ArrowRight')
segment_id: Current segment ID
current_frame: Current frame number
show_mask: Whether to show alpha mask overlay
magic_code_state: Magic code state
frames_state: Frames dictionary state
masks_state: Masks dictionary state
Returns:
Tuple of (updated image, updated slider value)
"""
if not segment_id or frames_state is None:
return None, gr.update()
frames_dict = frames_state
masks_dict = masks_state
# Get min/max from available frames
available_frames = sorted(frames_dict.keys())
if not available_frames:
return None, gr.update()
min_frame = available_frames[0]
max_frame = available_frames[-1]
# Calculate new frame number
new_frame = int(current_frame)
if key_code == 'ArrowLeft':
new_frame = max(min_frame, new_frame - 1)
elif key_code == 'ArrowRight':
new_frame = min(max_frame, new_frame + 1)
else:
# Unknown key, no change
return None, gr.update()
# If frame didn't change (at boundary), return early
if new_frame == int(current_frame):
return None, gr.update()
logger.info(f"Keyboard navigation: {key_code} -> frame {new_frame}")
# Load the new frame using existing logic
if new_frame not in frames_dict:
logger.warning(f"Frame {new_frame} not found in downloaded frames")
return None, gr.update()
frame = frames_dict[new_frame]
mask = masks_dict.get(new_frame, None)
# Composite image with mask
result_image = composite_image_with_mask(frame, mask, show_mask)
# Return updated image and new slider value
return result_image, gr.update(value=new_frame)
def handle_segment_selection(segment_id, magic_code):
"""
Handle segment selection: download all files and initialize the view.
"""
if not segment_id or not magic_code:
return None, gr.update(maximum=0, value=0), {}, {}, magic_code, gr.update(interactive=False)
logger.info(f"Segment selected: {segment_id}")
# Download all frames and masks
frames_dict, masks_dict, max_frame = download_segment_files(magic_code, segment_id)
if not frames_dict:
logger.error("No frames downloaded")
return None, gr.update(maximum=0, value=0), {}, {}, magic_code, gr.update(interactive=False)
# Load first frame
frame_0 = frames_dict.get(0, None)
if frame_0 is None:
# Use first available frame
frame_0 = frames_dict[min(frames_dict.keys())]
# Initial display without mask
result_image = composite_image_with_mask(frame_0, masks_dict.get(0, None), False)
# Enable download button only if masks are available
has_masks = len(masks_dict) > 0
return (
result_image,
gr.update(minimum=0, maximum=max_frame, value=0),
frames_dict,
masks_dict,
magic_code,
gr.update(interactive=has_masks)
)
def handle_image_click(segment_id, frame_number, magic_code, frames_state, masks_state, evt: gr.SelectData):
"""
Handle click on ImageEditor: send coordinates to SAM-3, get new mask, update display.
"""
if not segment_id or frames_state is None or evt is None:
return None, masks_state, gr.update()
# Extract click coordinates
x, y = evt.index[0], evt.index[1]
frame_idx = int(frame_number)
logger.info(f"Click detected at ({x}, {y}) on frame {frame_idx}")
if frame_idx not in frames_state:
logger.error(f"Frame {frame_idx} not in state")
return None, masks_state, gr.update()
try:
# Get the original frame
frame = frames_state[frame_idx]
# Convert frame to bytes
img_byte_arr = io.BytesIO()
frame.save(img_byte_arr, format='PNG')
img_bytes = img_byte_arr.getvalue()
# Call Modal SAM-3 segmentation function
logger.info(f"Calling SAM-3 segmentation with coordinates ({x}, {y})")
try:
sam_function = modal.Function.from_name("Content-Moderation", "sam3_segmentation_function")
mask_bytes = sam_function.remote(image_bytes=img_bytes, x=x, y=y)
# Parse returned mask
new_mask = Image.open(io.BytesIO(mask_bytes))
# Update masks dict
masks_state[frame_idx] = new_mask.copy()
# Composite and return updated image (always show mask after click)
result_image = composite_image_with_mask(frame, new_mask, True)
logger.info(f"Successfully updated mask for frame {frame_idx}")
# Enable download button since we now have at least one mask
return result_image, masks_state, gr.update(interactive=True)
except Exception as e:
logger.error(f"Error calling SAM-3 function: {e}")
# Return current view without update
frame = frames_state[frame_idx]
mask = masks_state.get(frame_idx, None)
result_image = composite_image_with_mask(frame, mask, True)
return result_image, masks_state, gr.update()
except Exception as e:
logger.error(f"Error handling image click: {e}")
return None, masks_state, gr.update()
def download_segment(segment_id, frames_state, masks_state):
"""
Package and download only the alpha masks for the selected segment as a ZIP file.
ZIP filename will be {segment_id}.zip.
Returns:
Tuple of (status_message, file_path, status_visibility)
"""
if not segment_id:
return gr.update(value="No segment selected", visible=True), None, gr.update(visible=True)
if not masks_state:
logger.warning(f"No alpha masks available for segment: {segment_id}")
return gr.update(value="No alpha masks available", visible=True), None, gr.update(visible=True)
logger.info(f"Download requested for segment: {segment_id}")
try:
import shutil
# Create temporary directory for alpha mask files only
with tempfile.TemporaryDirectory() as tmpdir:
# Save only alpha masks
for frame_idx, mask_img in masks_state.items():
mask_path = os.path.join(tmpdir, f"alpha_frame_{frame_idx:06d}.png")
mask_img.save(mask_path)
# Create ZIP with segment UUID as filename
zip_path = f"/tmp/{segment_id}.zip"
shutil.make_archive(zip_path.replace('.zip', ''), 'zip', tmpdir)
logger.info(f"Created ZIP at {zip_path} with {len(masks_state)} alpha masks")
return (
gr.update(value=f"✓ Downloaded {len(masks_state)} alpha masks", visible=True),
zip_path,
gr.update(visible=True)
)
except Exception as e:
logger.error(f"Error creating download package: {e}")
return (
gr.update(value=f"Error: {str(e)}", visible=True),
None,
gr.update(visible=True)
)
# Create a professional Gradio interface using the Golden ratio (1.618) for proportions
# Define custom CSS for a professional look
css = """
:root {
--main-bg-color: #111827;
--primary-color: #3B82F6;
--secondary-color: #60A5FA;
--text-color: #F9FAFB;
--text-secondary: #9CA3AF;
--card-bg: #1F2937;
--border-color: #374151;
--accent-blue: #3B82F6;
--accent-yellow: #FBBF24;
--accent-red: #EF4444;
--accent-green: #22C55E;
--border-radius: 8px;
--golden-ratio: 1.618;
--font-header: 'Barlow', sans-serif;
--font-body: 'Work Sans', sans-serif;
}
body {
font-family: var(--font-body);
background-color: var(--main-bg-color);
color: var(--text-color);
}
.container {
max-width: 100%;
margin: 0 auto;
padding: calc(20px * var(--golden-ratio));
background-color: var(--main-bg-color);
border-radius: calc(var(--border-radius) * var(--golden-ratio));
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3);
}
.logo-container {
display: flex;
justify-content: center;
margin-bottom: calc(20px * var(--golden-ratio));
padding: 15px;
background-color: var(--card-bg);
border-radius: var(--border-radius);
border: 1px solid var(--border-color);
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.2);
}
.logo {
max-width: 300px;
max-height: 100px;
transition: transform 0.3s ease;
display: block; /* Ensure it's a block element */
margin: 0 auto; /* This will center a block element within its flex container */
}
.logo:hover {
transform: scale(1.05);
}
.header {
text-align: center;
margin-bottom: calc(30px * var(--golden-ratio));
padding: calc(15px * var(--golden-ratio));
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
color: white;
border-radius: var(--border-radius);
box-shadow: 0 4px 10px rgba(59, 130, 246, 0.3);
}
.header h1 {
color: white;
font-family: var(--font-header);
font-size: calc(1.5rem * var(--golden-ratio));
margin-bottom: calc(0.5rem * var(--golden-ratio));
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
font-weight: 600;
}
.header p {
color: rgba(255, 255, 255, 0.9);
font-size: 1rem;
max-width: calc(600px * var(--golden-ratio));
margin: 0 auto;
}
.input-section, .output-section {
background-color: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: var(--border-radius);
padding: calc(20px * var(--golden-ratio));
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.2);
margin-bottom: 20px;
transition: all 0.3s ease;
}
.input-section:hover, .output-section:hover {
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.3);
border-color: var(--primary-color);
}
.input-section {
flex: var(--golden-ratio);
}
.output-section {
flex: 1;
}
.footer {
text-align: center;
margin-top: calc(30px * var(--golden-ratio));
padding: 15px;
color: var(--text-secondary);
font-size: 0.9rem;
border-top: 1px solid var(--border-color);
}
/* Improve form elements */
.gradio-slider input[type=range] {
accent-color: var(--primary-color);
}
.gradio-textbox input, .gradio-textbox textarea {
background-color: var(--main-bg-color) !important;
border: 1px solid var(--border-color) !important;
border-radius: var(--border-radius) !important;
padding: 10px !important;
color: var(--text-color) !important;
transition: all 0.3s ease !important;
}
.gradio-textbox input:focus, .gradio-textbox textarea:focus {
border-color: var(--primary-color) !important;
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.3) !important;
}
.gradio-button {
background-color: var(--primary-color) !important;
color: white !important;
border-radius: var(--border-radius) !important;
padding: calc(10px * var(--golden-ratio)) calc(20px * var(--golden-ratio)) !important;
font-weight: 600 !important;
font-family: var(--font-header) !important;
transition: all 0.3s ease !important;
box-shadow: 0 4px 6px rgba(59, 130, 246, 0.3) !important;
border: none !important;
}
.gradio-button:hover {
background-color: var(--secondary-color) !important;
transform: translateY(-2px);
box-shadow: 0 6px 12px rgba(59, 130, 246, 0.4) !important;
}
/* Golden ratio spacing for elements */
.gradio-row {
margin-bottom: calc(16px * var(--golden-ratio)) !important;
}
/* Additional dark theme adjustments */
.gradio-container {
background-color: var(--main-bg-color) !important;
}
.gradio-form {
background-color: var(--card-bg) !important;
border: 1px solid var(--border-color) !important;
}
/* Labels and text styling */
label {
color: var(--text-color) !important;
font-family: var(--font-body) !important;
}
/* Responsive adjustments */
@media (max-width: 768px) {
.container {
padding: 15px;
}
.input-section, .output-section {
padding: 15px;
}
}
/* Loading modal overlay */
#download-loading-modal {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.7);
z-index: 9999;
justify-content: center;
align-items: center;
}
#download-loading-modal.show {
display: flex;
}
.loading-content {
background-color: var(--card-bg);
padding: 40px;
border-radius: var(--border-radius);
text-align: center;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5);
}
.spinner {
border: 4px solid var(--border-color);
border-top: 4px solid var(--primary-color);
border-radius: 50%;
width: 50px;
height: 50px;
animation: spin 1s linear infinite;
margin: 0 auto 20px;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.loading-text {
color: var(--text-color);
font-size: 18px;
font-weight: 500;
}
"""
# Create a Blocks interface for more customization
with gr.Blocks(css=css, theme=gr.themes.Soft(primary_hue="indigo", secondary_hue="purple")) as demo:
with gr.Column(elem_classes="container"):
# Header section
with gr.Column(elem_classes="header"):
gr.HTML("""
<img src="data:image/png;base64,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" class="logo" alt="Logo">
<h1 style="margin-top: 0;">BSOD.tv - Moderation Workbench</h1>
<p style="font-size: 1.1rem; line-height: 1.618;">
Professional content moderation and segmentation workflow.
<br>Submit jobs and manage content segmentation efficiently.
</p>
""")
# Main content with specified layout
with gr.Tabs():
with gr.Tab("Submit Job"):
# First Row: Left Department Notes (Textbox), Right Video input
with gr.Row(elem_classes="input-section"):
with gr.Column(scale=1):
cm_notes = gr.Textbox(label="Department Notes", lines=6, placeholder="Enter notes for the moderation team...")
with gr.Column(scale=1):
cm_video_in = gr.Video(label="Video Input", sources=["upload"], interactive=True)
# Second Row: Email and Company Name (2/3 and 1/3 columns)
with gr.Row(elem_classes="input-section"):
with gr.Column(scale=2):
cm_email = gr.Textbox(label="Email")
with gr.Column(scale=1):
cm_company_name = gr.Textbox(label="Company Name")
# Third Row: Single Video Output
with gr.Row(elem_classes="output-section"):
cm_video_out = gr.Textbox(label="Output")
# Final Row: Process button
with gr.Row():
cm_process_btn = gr.Button("Process", variant="primary")
with gr.Tab("Segmentation Editing"):
gr.Markdown("### Segmentation Editing Workspace")
# State management for session data
magic_code_state = gr.State(value=None)
frames_state = gr.State(value=None)
masks_state = gr.State(value=None)
# Row 1: Magic Code textbox + Submit button
with gr.Row(elem_classes="input-section"):
with gr.Column(scale=3):
seg_magic_code = gr.Textbox(
label="Magic Code",
placeholder="Enter your magic code...",
interactive=True
)
with gr.Column(scale=1):
seg_submit_btn = gr.Button("Submit Code", variant="primary")
# Row 2: Segment ID dropdown
with gr.Row(elem_classes="input-section", visible=False) as seg_row2:
seg_id_dropdown = gr.Dropdown(
label="Segment ID",
choices=[],
interactive=True,
info="Select a segment to edit"
)
# Row 3: Show Alpha Mask checkbox
with gr.Row(elem_classes="input-section", visible=False) as seg_row3_checkbox:
seg_show_mask = gr.Checkbox(
label="Show Alpha Mask",
value=False,
interactive=True
)
# Row 4: ImageEditor component (SAM-3 style interaction)
with gr.Row(elem_classes="input-section", visible=False) as seg_row4:
seg_image_editor = gr.ImageEditor(
label="Image Click Segmentation",
type="pil",
interactive=True,
brush=gr.Brush(colors=["#FF0000"], color_mode="fixed")
)
# Row 5: Frame number slider
with gr.Row(elem_classes="input-section", visible=False) as seg_row5:
seg_frame_slider = gr.Slider(
minimum=0,
maximum=100,
value=0,
step=1,
label="Frame Number",
interactive=True,
info="Select video frame to segment"
)
# Row 6: Download Segment button
with gr.Row(visible=False) as seg_row6:
seg_download_btn = gr.Button("Download Segment", variant="secondary")
seg_download_status = gr.Textbox(label="Status", value="", visible=False, interactive=False)
seg_download_file = gr.File(label="Download", visible=False)
# Hidden component for keyboard event capture
seg_keyboard_input = gr.Textbox(visible=False, elem_id="seg_keyboard_input")
# Loading modal HTML
gr.HTML("""
<div id="download-loading-modal">
<div class="loading-content">
<div class="spinner"></div>
<div class="loading-text">Preparing download…</div>
</div>
</div>
<script>
function showDownloadLoading() {
const modal = document.getElementById('download-loading-modal');
if (modal) modal.classList.add('show');
}
function hideDownloadLoading() {
const modal = document.getElementById('download-loading-modal');
if (modal) modal.classList.remove('show');
}
// Listen for download button clicks
document.addEventListener('DOMContentLoaded', function() {
const checkButton = setInterval(function() {
const downloadBtn = document.querySelector('button:has-text("Download Segment")');
if (!downloadBtn) {
// Fallback: find button by content
const buttons = document.querySelectorAll('button');
for (let btn of buttons) {
if (btn.textContent.includes('Download Segment')) {
setupDownloadButton(btn);
clearInterval(checkButton);
break;
}
}
} else {
setupDownloadButton(downloadBtn);
clearInterval(checkButton);
}
}, 500);
function setupDownloadButton(btn) {
btn.addEventListener('click', function() {
showDownloadLoading();
// Hide modal after function completes (watch for Gradio loading to finish)
setTimeout(function checkLoading() {
const gradioLoading = document.querySelector('.loading');
if (!gradioLoading) {
setTimeout(hideDownloadLoading, 500);
} else {
setTimeout(checkLoading, 200);
}
}, 200);
});
}
});
</script>
""")
# Wire Content Moderation processing
cm_process_btn.click(
fn=process_video,
inputs=[cm_video_in, cm_notes, cm_email, cm_company_name],
outputs=cm_video_out
)
# Wire Segmentation Editing callbacks
seg_submit_btn.click(
fn=submit_magic_code,
inputs=[seg_magic_code],
outputs=[seg_id_dropdown, seg_magic_code, seg_row2, seg_row3_checkbox, seg_row4, seg_row5, seg_row6]
)
# Segment selection handler
seg_id_dropdown.change(
fn=handle_segment_selection,
inputs=[seg_id_dropdown, seg_magic_code],
outputs=[seg_image_editor, seg_frame_slider, frames_state, masks_state, magic_code_state, seg_download_btn]
)
# Frame slider handler
seg_frame_slider.change(
fn=load_segment_frame,
inputs=[seg_id_dropdown, seg_frame_slider, seg_show_mask, magic_code_state, frames_state, masks_state],
outputs=[seg_image_editor, seg_show_mask]
)
# Show mask checkbox handler
seg_show_mask.change(
fn=load_segment_frame,
inputs=[seg_id_dropdown, seg_frame_slider, seg_show_mask, magic_code_state, frames_state, masks_state],
outputs=[seg_image_editor, seg_show_mask]
)
# Image click handler (for SAM-3 segmentation)
seg_image_editor.select(
fn=handle_image_click,
inputs=[seg_id_dropdown, seg_frame_slider, magic_code_state, frames_state, masks_state],
outputs=[seg_image_editor, masks_state, seg_download_btn]
)
# Download button handler
seg_download_btn.click(
fn=download_segment,
inputs=[seg_id_dropdown, frames_state, masks_state],
outputs=[seg_download_status, seg_download_file, seg_download_status]
)
# Keyboard navigation handler
seg_keyboard_input.change(
fn=handle_keyboard_navigation,
inputs=[
seg_keyboard_input,
seg_id_dropdown,
seg_frame_slider,
seg_show_mask,
magic_code_state,
frames_state,
masks_state
],
outputs=[seg_image_editor, seg_frame_slider]
)
# Add JavaScript to capture arrow key events
demo.load(
None,
None,
None,
js="""
() => {
// Wait for the DOM to be ready
setTimeout(() => {
const keyboardInput = document.getElementById('seg_keyboard_input');
if (!keyboardInput) {
console.warn('Keyboard input element not found');
return;
}
// Add keydown listener to document
document.addEventListener('keydown', (e) => {
// Only handle arrow keys
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
// Check if we're in the Segmentation Editing tab
const segTab = document.querySelector('[id*="segmentation-editing"]');
const activeTab = document.querySelector('.tab-nav button.selected');
if (activeTab && activeTab.textContent.includes('Segmentation Editing')) {
e.preventDefault();
// Update the hidden input to trigger the change event
const textarea = keyboardInput.querySelector('textarea');
if (textarea) {
textarea.value = e.key;
textarea.dispatchEvent(new Event('input', { bubbles: true }));
}
}
}
});
}, 1000);
}
"""
)
if __name__ == "__main__":
# To run this file locally, you'll need to install gradio and requests:
# pip install gradio requests
demo.launch()