Spaces:
Sleeping
Sleeping
Add ZIP download functionality for segment alpha masks with loading modal
Browse filesImplements download feature for Segmentation Editing tab that packages only alpha mask PNG files into a ZIP archive named with the segment UUID.
- Modified download_segment() to include only alpha masks (no frame images)
- Changed ZIP filename from segment_{uuid}.zip to {uuid}.zip
- Added custom loading modal overlay with spinner and "Preparing download…" text
- Added button interactivity: disabled when no segment/masks, enabled when masks exist
- Added status feedback textbox showing download success or error messages
- JavaScript automatically shows/hides modal during download process
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
app.py
CHANGED
|
@@ -445,7 +445,7 @@ def handle_segment_selection(segment_id, magic_code):
|
|
| 445 |
Handle segment selection: download all files and initialize the view.
|
| 446 |
"""
|
| 447 |
if not segment_id or not magic_code:
|
| 448 |
-
return None, gr.update(maximum=0, value=0), {}, {}, magic_code
|
| 449 |
|
| 450 |
logger.info(f"Segment selected: {segment_id}")
|
| 451 |
|
|
@@ -454,7 +454,7 @@ def handle_segment_selection(segment_id, magic_code):
|
|
| 454 |
|
| 455 |
if not frames_dict:
|
| 456 |
logger.error("No frames downloaded")
|
| 457 |
-
return None, gr.update(maximum=0, value=0), {}, {}, magic_code
|
| 458 |
|
| 459 |
# Load first frame
|
| 460 |
frame_0 = frames_dict.get(0, None)
|
|
@@ -465,12 +465,16 @@ def handle_segment_selection(segment_id, magic_code):
|
|
| 465 |
# Initial display without mask
|
| 466 |
result_image = composite_image_with_mask(frame_0, masks_dict.get(0, None), False)
|
| 467 |
|
|
|
|
|
|
|
|
|
|
| 468 |
return (
|
| 469 |
result_image,
|
| 470 |
gr.update(minimum=0, maximum=max_frame, value=0),
|
| 471 |
frames_dict,
|
| 472 |
masks_dict,
|
| 473 |
-
magic_code
|
|
|
|
| 474 |
)
|
| 475 |
|
| 476 |
|
|
@@ -479,7 +483,7 @@ def handle_image_click(segment_id, frame_number, magic_code, frames_state, masks
|
|
| 479 |
Handle click on ImageEditor: send coordinates to SAM-3, get new mask, update display.
|
| 480 |
"""
|
| 481 |
if not segment_id or frames_state is None or evt is None:
|
| 482 |
-
return None, masks_state
|
| 483 |
|
| 484 |
# Extract click coordinates
|
| 485 |
x, y = evt.index[0], evt.index[1]
|
|
@@ -489,7 +493,7 @@ def handle_image_click(segment_id, frame_number, magic_code, frames_state, masks
|
|
| 489 |
|
| 490 |
if frame_idx not in frames_state:
|
| 491 |
logger.error(f"Frame {frame_idx} not in state")
|
| 492 |
-
return None, masks_state
|
| 493 |
|
| 494 |
try:
|
| 495 |
# Get the original frame
|
|
@@ -518,7 +522,8 @@ def handle_image_click(segment_id, frame_number, magic_code, frames_state, masks
|
|
| 518 |
|
| 519 |
logger.info(f"Successfully updated mask for frame {frame_idx}")
|
| 520 |
|
| 521 |
-
|
|
|
|
| 522 |
|
| 523 |
except Exception as e:
|
| 524 |
logger.error(f"Error calling SAM-3 function: {e}")
|
|
@@ -526,45 +531,58 @@ def handle_image_click(segment_id, frame_number, magic_code, frames_state, masks
|
|
| 526 |
frame = frames_state[frame_idx]
|
| 527 |
mask = masks_state.get(frame_idx, None)
|
| 528 |
result_image = composite_image_with_mask(frame, mask, True)
|
| 529 |
-
return result_image, masks_state
|
| 530 |
|
| 531 |
except Exception as e:
|
| 532 |
logger.error(f"Error handling image click: {e}")
|
| 533 |
-
return None, masks_state
|
| 534 |
|
| 535 |
|
| 536 |
def download_segment(segment_id, frames_state, masks_state):
|
| 537 |
"""
|
| 538 |
-
Package and download the
|
|
|
|
|
|
|
|
|
|
|
|
|
| 539 |
"""
|
| 540 |
-
if not segment_id
|
| 541 |
-
return "No segment selected"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 542 |
|
| 543 |
logger.info(f"Download requested for segment: {segment_id}")
|
| 544 |
|
| 545 |
try:
|
| 546 |
-
|
|
|
|
|
|
|
| 547 |
with tempfile.TemporaryDirectory() as tmpdir:
|
| 548 |
-
# Save
|
| 549 |
-
for frame_idx,
|
| 550 |
-
|
| 551 |
-
|
| 552 |
-
|
| 553 |
-
|
| 554 |
-
|
| 555 |
-
masks_state[frame_idx].save(mask_path)
|
| 556 |
-
|
| 557 |
-
# Create ZIP
|
| 558 |
-
import shutil
|
| 559 |
-
zip_path = f"/tmp/segment_{segment_id}.zip"
|
| 560 |
shutil.make_archive(zip_path.replace('.zip', ''), 'zip', tmpdir)
|
| 561 |
|
| 562 |
-
logger.info(f"Created ZIP at {zip_path}")
|
| 563 |
-
return
|
|
|
|
|
|
|
|
|
|
|
|
|
| 564 |
|
| 565 |
except Exception as e:
|
| 566 |
logger.error(f"Error creating download package: {e}")
|
| 567 |
-
return
|
|
|
|
|
|
|
|
|
|
|
|
|
| 568 |
|
| 569 |
|
| 570 |
# Create a professional Gradio interface using the Golden ratio (1.618) for proportions
|
|
@@ -753,6 +771,53 @@ label {
|
|
| 753 |
padding: 15px;
|
| 754 |
}
|
| 755 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 756 |
"""
|
| 757 |
|
| 758 |
# Create a Blocks interface for more customization
|
|
@@ -854,11 +919,69 @@ with gr.Blocks(css=css, theme=gr.themes.Soft(primary_hue="indigo", secondary_hue
|
|
| 854 |
# Row 6: Download Segment button
|
| 855 |
with gr.Row(visible=False) as seg_row6:
|
| 856 |
seg_download_btn = gr.Button("Download Segment", variant="secondary")
|
|
|
|
| 857 |
seg_download_file = gr.File(label="Download", visible=False)
|
| 858 |
|
| 859 |
# Hidden component for keyboard event capture
|
| 860 |
seg_keyboard_input = gr.Textbox(visible=False, elem_id="seg_keyboard_input")
|
| 861 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 862 |
# Wire Content Moderation processing
|
| 863 |
cm_process_btn.click(
|
| 864 |
fn=process_video,
|
|
@@ -877,7 +1000,7 @@ with gr.Blocks(css=css, theme=gr.themes.Soft(primary_hue="indigo", secondary_hue
|
|
| 877 |
seg_id_dropdown.change(
|
| 878 |
fn=handle_segment_selection,
|
| 879 |
inputs=[seg_id_dropdown, seg_magic_code],
|
| 880 |
-
outputs=[seg_image_editor, seg_frame_slider, frames_state, masks_state, magic_code_state]
|
| 881 |
)
|
| 882 |
|
| 883 |
# Frame slider handler
|
|
@@ -898,14 +1021,14 @@ with gr.Blocks(css=css, theme=gr.themes.Soft(primary_hue="indigo", secondary_hue
|
|
| 898 |
seg_image_editor.select(
|
| 899 |
fn=handle_image_click,
|
| 900 |
inputs=[seg_id_dropdown, seg_frame_slider, magic_code_state, frames_state, masks_state],
|
| 901 |
-
outputs=[seg_image_editor, masks_state]
|
| 902 |
)
|
| 903 |
|
| 904 |
# Download button handler
|
| 905 |
seg_download_btn.click(
|
| 906 |
fn=download_segment,
|
| 907 |
inputs=[seg_id_dropdown, frames_state, masks_state],
|
| 908 |
-
outputs=[seg_download_file]
|
| 909 |
)
|
| 910 |
|
| 911 |
# Keyboard navigation handler
|
|
|
|
| 445 |
Handle segment selection: download all files and initialize the view.
|
| 446 |
"""
|
| 447 |
if not segment_id or not magic_code:
|
| 448 |
+
return None, gr.update(maximum=0, value=0), {}, {}, magic_code, gr.update(interactive=False)
|
| 449 |
|
| 450 |
logger.info(f"Segment selected: {segment_id}")
|
| 451 |
|
|
|
|
| 454 |
|
| 455 |
if not frames_dict:
|
| 456 |
logger.error("No frames downloaded")
|
| 457 |
+
return None, gr.update(maximum=0, value=0), {}, {}, magic_code, gr.update(interactive=False)
|
| 458 |
|
| 459 |
# Load first frame
|
| 460 |
frame_0 = frames_dict.get(0, None)
|
|
|
|
| 465 |
# Initial display without mask
|
| 466 |
result_image = composite_image_with_mask(frame_0, masks_dict.get(0, None), False)
|
| 467 |
|
| 468 |
+
# Enable download button only if masks are available
|
| 469 |
+
has_masks = len(masks_dict) > 0
|
| 470 |
+
|
| 471 |
return (
|
| 472 |
result_image,
|
| 473 |
gr.update(minimum=0, maximum=max_frame, value=0),
|
| 474 |
frames_dict,
|
| 475 |
masks_dict,
|
| 476 |
+
magic_code,
|
| 477 |
+
gr.update(interactive=has_masks)
|
| 478 |
)
|
| 479 |
|
| 480 |
|
|
|
|
| 483 |
Handle click on ImageEditor: send coordinates to SAM-3, get new mask, update display.
|
| 484 |
"""
|
| 485 |
if not segment_id or frames_state is None or evt is None:
|
| 486 |
+
return None, masks_state, gr.update()
|
| 487 |
|
| 488 |
# Extract click coordinates
|
| 489 |
x, y = evt.index[0], evt.index[1]
|
|
|
|
| 493 |
|
| 494 |
if frame_idx not in frames_state:
|
| 495 |
logger.error(f"Frame {frame_idx} not in state")
|
| 496 |
+
return None, masks_state, gr.update()
|
| 497 |
|
| 498 |
try:
|
| 499 |
# Get the original frame
|
|
|
|
| 522 |
|
| 523 |
logger.info(f"Successfully updated mask for frame {frame_idx}")
|
| 524 |
|
| 525 |
+
# Enable download button since we now have at least one mask
|
| 526 |
+
return result_image, masks_state, gr.update(interactive=True)
|
| 527 |
|
| 528 |
except Exception as e:
|
| 529 |
logger.error(f"Error calling SAM-3 function: {e}")
|
|
|
|
| 531 |
frame = frames_state[frame_idx]
|
| 532 |
mask = masks_state.get(frame_idx, None)
|
| 533 |
result_image = composite_image_with_mask(frame, mask, True)
|
| 534 |
+
return result_image, masks_state, gr.update()
|
| 535 |
|
| 536 |
except Exception as e:
|
| 537 |
logger.error(f"Error handling image click: {e}")
|
| 538 |
+
return None, masks_state, gr.update()
|
| 539 |
|
| 540 |
|
| 541 |
def download_segment(segment_id, frames_state, masks_state):
|
| 542 |
"""
|
| 543 |
+
Package and download only the alpha masks for the selected segment as a ZIP file.
|
| 544 |
+
ZIP filename will be {segment_id}.zip.
|
| 545 |
+
|
| 546 |
+
Returns:
|
| 547 |
+
Tuple of (status_message, file_path, status_visibility)
|
| 548 |
"""
|
| 549 |
+
if not segment_id:
|
| 550 |
+
return gr.update(value="No segment selected", visible=True), None, gr.update(visible=True)
|
| 551 |
+
|
| 552 |
+
if not masks_state:
|
| 553 |
+
logger.warning(f"No alpha masks available for segment: {segment_id}")
|
| 554 |
+
return gr.update(value="No alpha masks available", visible=True), None, gr.update(visible=True)
|
| 555 |
|
| 556 |
logger.info(f"Download requested for segment: {segment_id}")
|
| 557 |
|
| 558 |
try:
|
| 559 |
+
import shutil
|
| 560 |
+
|
| 561 |
+
# Create temporary directory for alpha mask files only
|
| 562 |
with tempfile.TemporaryDirectory() as tmpdir:
|
| 563 |
+
# Save only alpha masks
|
| 564 |
+
for frame_idx, mask_img in masks_state.items():
|
| 565 |
+
mask_path = os.path.join(tmpdir, f"alpha_frame_{frame_idx:06d}.png")
|
| 566 |
+
mask_img.save(mask_path)
|
| 567 |
+
|
| 568 |
+
# Create ZIP with segment UUID as filename
|
| 569 |
+
zip_path = f"/tmp/{segment_id}.zip"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 570 |
shutil.make_archive(zip_path.replace('.zip', ''), 'zip', tmpdir)
|
| 571 |
|
| 572 |
+
logger.info(f"Created ZIP at {zip_path} with {len(masks_state)} alpha masks")
|
| 573 |
+
return (
|
| 574 |
+
gr.update(value=f"✓ Downloaded {len(masks_state)} alpha masks", visible=True),
|
| 575 |
+
zip_path,
|
| 576 |
+
gr.update(visible=True)
|
| 577 |
+
)
|
| 578 |
|
| 579 |
except Exception as e:
|
| 580 |
logger.error(f"Error creating download package: {e}")
|
| 581 |
+
return (
|
| 582 |
+
gr.update(value=f"Error: {str(e)}", visible=True),
|
| 583 |
+
None,
|
| 584 |
+
gr.update(visible=True)
|
| 585 |
+
)
|
| 586 |
|
| 587 |
|
| 588 |
# Create a professional Gradio interface using the Golden ratio (1.618) for proportions
|
|
|
|
| 771 |
padding: 15px;
|
| 772 |
}
|
| 773 |
}
|
| 774 |
+
|
| 775 |
+
/* Loading modal overlay */
|
| 776 |
+
#download-loading-modal {
|
| 777 |
+
display: none;
|
| 778 |
+
position: fixed;
|
| 779 |
+
top: 0;
|
| 780 |
+
left: 0;
|
| 781 |
+
width: 100%;
|
| 782 |
+
height: 100%;
|
| 783 |
+
background-color: rgba(0, 0, 0, 0.7);
|
| 784 |
+
z-index: 9999;
|
| 785 |
+
justify-content: center;
|
| 786 |
+
align-items: center;
|
| 787 |
+
}
|
| 788 |
+
|
| 789 |
+
#download-loading-modal.show {
|
| 790 |
+
display: flex;
|
| 791 |
+
}
|
| 792 |
+
|
| 793 |
+
.loading-content {
|
| 794 |
+
background-color: var(--card-bg);
|
| 795 |
+
padding: 40px;
|
| 796 |
+
border-radius: var(--border-radius);
|
| 797 |
+
text-align: center;
|
| 798 |
+
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5);
|
| 799 |
+
}
|
| 800 |
+
|
| 801 |
+
.spinner {
|
| 802 |
+
border: 4px solid var(--border-color);
|
| 803 |
+
border-top: 4px solid var(--primary-color);
|
| 804 |
+
border-radius: 50%;
|
| 805 |
+
width: 50px;
|
| 806 |
+
height: 50px;
|
| 807 |
+
animation: spin 1s linear infinite;
|
| 808 |
+
margin: 0 auto 20px;
|
| 809 |
+
}
|
| 810 |
+
|
| 811 |
+
@keyframes spin {
|
| 812 |
+
0% { transform: rotate(0deg); }
|
| 813 |
+
100% { transform: rotate(360deg); }
|
| 814 |
+
}
|
| 815 |
+
|
| 816 |
+
.loading-text {
|
| 817 |
+
color: var(--text-color);
|
| 818 |
+
font-size: 18px;
|
| 819 |
+
font-weight: 500;
|
| 820 |
+
}
|
| 821 |
"""
|
| 822 |
|
| 823 |
# Create a Blocks interface for more customization
|
|
|
|
| 919 |
# Row 6: Download Segment button
|
| 920 |
with gr.Row(visible=False) as seg_row6:
|
| 921 |
seg_download_btn = gr.Button("Download Segment", variant="secondary")
|
| 922 |
+
seg_download_status = gr.Textbox(label="Status", value="", visible=False, interactive=False)
|
| 923 |
seg_download_file = gr.File(label="Download", visible=False)
|
| 924 |
|
| 925 |
# Hidden component for keyboard event capture
|
| 926 |
seg_keyboard_input = gr.Textbox(visible=False, elem_id="seg_keyboard_input")
|
| 927 |
|
| 928 |
+
# Loading modal HTML
|
| 929 |
+
gr.HTML("""
|
| 930 |
+
<div id="download-loading-modal">
|
| 931 |
+
<div class="loading-content">
|
| 932 |
+
<div class="spinner"></div>
|
| 933 |
+
<div class="loading-text">Preparing download…</div>
|
| 934 |
+
</div>
|
| 935 |
+
</div>
|
| 936 |
+
<script>
|
| 937 |
+
function showDownloadLoading() {
|
| 938 |
+
const modal = document.getElementById('download-loading-modal');
|
| 939 |
+
if (modal) modal.classList.add('show');
|
| 940 |
+
}
|
| 941 |
+
|
| 942 |
+
function hideDownloadLoading() {
|
| 943 |
+
const modal = document.getElementById('download-loading-modal');
|
| 944 |
+
if (modal) modal.classList.remove('show');
|
| 945 |
+
}
|
| 946 |
+
|
| 947 |
+
// Listen for download button clicks
|
| 948 |
+
document.addEventListener('DOMContentLoaded', function() {
|
| 949 |
+
const checkButton = setInterval(function() {
|
| 950 |
+
const downloadBtn = document.querySelector('button:has-text("Download Segment")');
|
| 951 |
+
if (!downloadBtn) {
|
| 952 |
+
// Fallback: find button by content
|
| 953 |
+
const buttons = document.querySelectorAll('button');
|
| 954 |
+
for (let btn of buttons) {
|
| 955 |
+
if (btn.textContent.includes('Download Segment')) {
|
| 956 |
+
setupDownloadButton(btn);
|
| 957 |
+
clearInterval(checkButton);
|
| 958 |
+
break;
|
| 959 |
+
}
|
| 960 |
+
}
|
| 961 |
+
} else {
|
| 962 |
+
setupDownloadButton(downloadBtn);
|
| 963 |
+
clearInterval(checkButton);
|
| 964 |
+
}
|
| 965 |
+
}, 500);
|
| 966 |
+
|
| 967 |
+
function setupDownloadButton(btn) {
|
| 968 |
+
btn.addEventListener('click', function() {
|
| 969 |
+
showDownloadLoading();
|
| 970 |
+
// Hide modal after function completes (watch for Gradio loading to finish)
|
| 971 |
+
setTimeout(function checkLoading() {
|
| 972 |
+
const gradioLoading = document.querySelector('.loading');
|
| 973 |
+
if (!gradioLoading) {
|
| 974 |
+
setTimeout(hideDownloadLoading, 500);
|
| 975 |
+
} else {
|
| 976 |
+
setTimeout(checkLoading, 200);
|
| 977 |
+
}
|
| 978 |
+
}, 200);
|
| 979 |
+
});
|
| 980 |
+
}
|
| 981 |
+
});
|
| 982 |
+
</script>
|
| 983 |
+
""")
|
| 984 |
+
|
| 985 |
# Wire Content Moderation processing
|
| 986 |
cm_process_btn.click(
|
| 987 |
fn=process_video,
|
|
|
|
| 1000 |
seg_id_dropdown.change(
|
| 1001 |
fn=handle_segment_selection,
|
| 1002 |
inputs=[seg_id_dropdown, seg_magic_code],
|
| 1003 |
+
outputs=[seg_image_editor, seg_frame_slider, frames_state, masks_state, magic_code_state, seg_download_btn]
|
| 1004 |
)
|
| 1005 |
|
| 1006 |
# Frame slider handler
|
|
|
|
| 1021 |
seg_image_editor.select(
|
| 1022 |
fn=handle_image_click,
|
| 1023 |
inputs=[seg_id_dropdown, seg_frame_slider, magic_code_state, frames_state, masks_state],
|
| 1024 |
+
outputs=[seg_image_editor, masks_state, seg_download_btn]
|
| 1025 |
)
|
| 1026 |
|
| 1027 |
# Download button handler
|
| 1028 |
seg_download_btn.click(
|
| 1029 |
fn=download_segment,
|
| 1030 |
inputs=[seg_id_dropdown, frames_state, masks_state],
|
| 1031 |
+
outputs=[seg_download_status, seg_download_file, seg_download_status]
|
| 1032 |
)
|
| 1033 |
|
| 1034 |
# Keyboard navigation handler
|