File size: 13,503 Bytes
9b79705
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
import gradio as gr
import cv2
import numpy as np
import os
import io
import base64
import tempfile # Make sure tempfile is imported
import math   # Import math for ceiling function

# --- Helper Function ---
# (Keep the get_frame_from_video function exactly as it was)
def get_frame_from_video(video_path, frame_number):
    """Reads a specific frame from a video file."""
    if not video_path or not os.path.exists(video_path):
        return None, "Error: Video file not found or path is invalid."

    cap = cv2.VideoCapture(video_path)
    if not cap.isOpened():
        try: cap.release()
        except Exception: pass
        return None, f"Error: Could not open video file: {video_path}"

    total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
    frame_number = int(frame_number)

    if total_frames <= 0:
        cap.release()
        return None, f"Error: Video file seems to have no frames or is corrupted: {os.path.basename(video_path)}"

    max_frame = total_frames - 1
    if frame_number < 0 or frame_number > max_frame:
        cap.release()
        return None, f"Error: Frame number {frame_number} is out of bounds (0-{max_frame})."

    # Ensure frame number doesn't exceed max_frame even after calculation
    frame_number = min(frame_number, max_frame)

    cap.set(cv2.CAP_PROP_POS_FRAMES, frame_number)
    ret, frame = cap.read()
    cap.release()

    if not ret:
        # Attempt to read the last valid frame if the target failed (might happen near the end)
        if frame_number > 0:
            cap = cv2.VideoCapture(video_path)
            cap.set(cv2.CAP_PROP_POS_FRAMES, max_frame)
            ret_last, frame_last = cap.read()
            cap.release()
            if ret_last:
                 return cv2.cvtColor(frame_last, cv2.COLOR_BGR2RGB), f"Could not read frame {frame_number}. Showing last valid frame {max_frame}/{max_frame}."
        return None, f"Error: Could not read frame {frame_number}."


    frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
    status_message = f"Showing frame {frame_number}/{max_frame}." # Basic status
    return frame_rgb, status_message

# --- Gradio Callback Functions ---

# 1. Function triggered when a video file is uploaded
def load_video(video_path):
    """Loads the video, gets its properties, updates the slider, displays the first frame, and hides download links."""
    if video_path is None:
        # Reset UI elements, including FPS state
        return {
            video_path_state: None,
            fps_state: 0.0, # Reset FPS
            frame_slider: gr.Slider(minimum=0, maximum=0, value=0, step=1, interactive=False),
            frame_display: None,
            info_text: "Please upload a video file (e.g., MP4).",
            jpg_download_output: gr.File(value=None, visible=False),
            png_download_output: gr.File(value=None, visible=False)
        }

    cap = cv2.VideoCapture(video_path)
    fps = 0.0 # Initialize fps
    if not cap.isOpened():
        try: cap.release();
        except: pass
        return {
            video_path_state: None,
            fps_state: 0.0, # Reset FPS
            frame_slider: gr.Slider(minimum=0, maximum=0, value=0, step=1, interactive=False),
            frame_display: None,
            info_text: f"Error: Could not open video file: {os.path.basename(video_path)}",
            jpg_download_output: gr.File(value=None, visible=False),
            png_download_output: gr.File(value=None, visible=False)
        }

    total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
    fps = cap.get(cv2.CAP_PROP_FPS) # Get FPS here

    # Validate total_frames and fps
    if total_frames <= 0 or not fps or fps <= 0:
         cap.release()
         error_msg = f"Error: Could not read valid metadata (frames={total_frames}, fps={fps}) from {os.path.basename(video_path)}."
         return {
            video_path_state: None,
            fps_state: 0.0, # Reset FPS
            frame_slider: gr.Slider(minimum=0, maximum=0, value=0, step=1, interactive=False),
            frame_display: None,
            info_text: error_msg,
            jpg_download_output: gr.File(value=None, visible=False),
            png_download_output: gr.File(value=None, visible=False)
        }

    duration = total_frames / fps
    cap.release()

    first_frame, status = get_frame_from_video(video_path, 0)

    if first_frame is None:
        # FPS is valid here, but first frame failed
        return {
            video_path_state: None,
            fps_state: 0.0, # Reset FPS as we can't proceed
            frame_slider: gr.Slider(minimum=0, maximum=0, value=0, step=1, interactive=False),
            frame_display: None,
            info_text: f"Error reading first frame: {status}",
            jpg_download_output: gr.File(value=None, visible=False),
            png_download_output: gr.File(value=None, visible=False)
        }

    info = (
        f"Video: {os.path.basename(video_path)}\n"
        f"Total Frames: {total_frames}\n"
        f"FPS: {fps:.2f}\n"
        f"Duration: {duration:.2f} seconds\n"
        f"Use slider or video controls." # Updated instruction
    )
    slider_max = max(0, total_frames - 1)

    return {
        video_path_state: video_path,
        fps_state: fps, # Store the valid FPS
        frame_slider: gr.Slider(minimum=0, maximum=slider_max, value=0, step=1, interactive=True),
        frame_display: first_frame,
        info_text: info + "\n" + status,
        jpg_download_output: gr.File(value=None, visible=False),
        png_download_output: gr.File(value=None, visible=False)
    }

# 2. Function triggered when the slider value changes (on release)
def update_frame_on_slider(video_path, frame_number):
    """Gets and displays the frame, updates status, and hides download links."""
    if video_path is None:
        return None, "No video loaded.", gr.File(value=None, visible=False), gr.File(value=None, visible=False)
    frame_number = int(frame_number)
    frame, status = get_frame_from_video(video_path, frame_number)
    return frame, status, gr.File(value=None, visible=False), gr.File(value=None, visible=False)


# 3. Function to prepare and save the frame temporarily
# (Keep the save_frame_temporarily function exactly as it was)
def save_frame_temporarily(video_path, frame_number, image_format):
    """Saves the frame temporarily and returns the file path."""
    if video_path is None:
        return None, "No video loaded to download from."

    frame_number = int(frame_number)
    frame, status = get_frame_from_video(video_path, frame_number)

    if frame is None:
        return None, status # Return error status from get_frame

    # Ensure frame is uint8
    if frame.dtype != np.uint8:
        if frame.max() <= 1.0:
            frame = (frame * 255).astype(np.uint8)
        else:
            frame = frame.astype(np.uint8) # Potential clipping

    # Choose suffix and encode
    suffix = f".{image_format}"
    frame_bgr = cv2.cvtColor(frame, cv2.COLOR_RGB2BGR) # Convert for OpenCV saving

    if image_format == "jpeg":
        ret, buffer = cv2.imencode('.jpg', frame_bgr)
    elif image_format == "png":
        ret, buffer = cv2.imencode('.png', frame_bgr)
    else:
        return None, f"Unsupported format: {image_format}"

    if not ret:
        return None, f"Error encoding frame {frame_number} to {image_format}."

    try:
        temp_file = tempfile.NamedTemporaryFile(suffix=suffix, delete=False)
        temp_file.write(buffer)
        temp_file.close()
        file_path = temp_file.name
        status = f"Frame {frame_number} ready. Click link below to download {image_format.upper()}."
        return file_path, status
    except Exception as e:
        if 'temp_file' in locals() and temp_file:
             try: os.unlink(temp_file.name)
             except OSError: pass
        return None, f"Error creating temporary file: {e}"

# 4. NEW Function: Triggered when video is paused
def sync_slider_on_pause(video_path, fps, pause_time: gr.Number):
    """Updates the slider value based on the video pause time."""
    print(f"Pause event: video_path={video_path}, fps={fps}, pause_time={pause_time}") # Debug print
    if video_path is None or not fps or fps <= 0 or pause_time is None:
        print("Skipping slider sync due to invalid inputs.")
        # Return an update that does nothing if inputs are invalid
        return gr.Slider() # No change update

    # Calculate frame number - use math.ceil to lean towards the frame *after* the pause time
    # Or use int() for the frame *at* or *before* the pause time. Let's use int() for simplicity.
    calculated_frame = int(pause_time * fps)

    # Get the current max of the slider to prevent going out of bounds
    # This requires the slider component itself to be passed, which is complex.
    # Alternative: Re-read total frames here (less efficient but simpler state management)
    cap = cv2.VideoCapture(video_path)
    if cap.isOpened():
        total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
        cap.release()
        if total_frames > 0:
            max_frame = total_frames - 1
            # Clamp the calculated frame to the valid range
            target_frame = max(0, min(calculated_frame, max_frame))
            print(f"Calculated frame: {calculated_frame}, Target frame: {target_frame}")
            return gr.Slider(value=target_frame)
        else:
             print("Skipping slider sync because total_frames <= 0.")
             return gr.Slider() # No change
    else:
         print("Skipping slider sync because video couldn't be opened.")
         return gr.Slider() # No change


# --- Gradio Interface ---
with gr.Blocks() as demo:
    gr.Markdown("# Video Frame Explorer and Downloader")
    gr.Markdown("Upload a video file (like MP4), use the slider OR pause the video to select a frame. Click a 'Prepare' button, then click the link that appears to download.")

    video_path_state = gr.State(None)
    fps_state = gr.State(0.0) # NEW state for FPS

    with gr.Row():
        with gr.Column(scale=1):
            # Assign elem_id for potential JS interaction if needed later
            video_input = gr.Video(label="Upload Video", sources=["upload"], format="mp4", elem_id="video_player")
            info_text = gr.Textbox(label="Video Info / Status", interactive=False, lines=5)
            jpg_download_output = gr.File(label="JPG Download Link", visible=False, interactive=False)
            png_download_output = gr.File(label="PNG Download Link", visible=False, interactive=False)

        with gr.Column(scale=2):
            frame_display = gr.Image(label="Selected Frame", type="numpy", interactive=False)
            # Assign elem_id for potential JS interaction
            frame_slider = gr.Slider(
                minimum=0, maximum=0, value=0, step=1,
                label="Frame Number", interactive=False, elem_id="frame_slider"
            )
            with gr.Row():
                prepare_jpg_button = gr.Button("Prepare JPG Download")
                prepare_png_button = gr.Button("Prepare PNG Download")


    # --- Component Interactions ---

    # 1. Video Upload/Clear
    video_input.upload(
        fn=load_video,
        inputs=video_input,
        # Include fps_state in outputs
        outputs=[video_path_state, fps_state, frame_slider, frame_display, info_text, jpg_download_output, png_download_output]
    )
    video_input.clear(
        fn=load_video,
        inputs=video_input,
         # Include fps_state in outputs
        outputs=[video_path_state, fps_state, frame_slider, frame_display, info_text, jpg_download_output, png_download_output]
    )

    # 2. Slider Release (hides download links)
    frame_slider.release(
        fn=update_frame_on_slider,
        inputs=[video_path_state, frame_slider],
        outputs=[frame_display, info_text, jpg_download_output, png_download_output]
    )

    # 3. Prepare JPG Download Button Click
    prepare_jpg_button.click(
        fn=save_frame_temporarily,
        inputs=[video_path_state, frame_slider, gr.Textbox("jpeg", visible=False)],
        outputs=[jpg_download_output, info_text]
    ).then(
       lambda: {jpg_download_output: gr.File(visible=True), png_download_output: gr.File(visible=False)},
       outputs=[jpg_download_output, png_download_output]
    )

    # 4. Prepare PNG Download Button Click
    prepare_png_button.click(
        fn=save_frame_temporarily,
        inputs=[video_path_state, frame_slider, gr.Textbox("png", visible=False)],
        outputs=[png_download_output, info_text]
    ).then(
        lambda: {png_download_output: gr.File(visible=True), jpg_download_output: gr.File(visible=False)},
        outputs=[png_download_output, jpg_download_output]
    )

    # 5. NEW: Video Pause Event
    # The 'pause_time' argument to sync_slider_on_pause comes from the event data
    video_input.pause(
        fn=sync_slider_on_pause,
        inputs=[video_path_state, fps_state], # Pass needed state
        outputs=[frame_slider] # Update the slider value
    )
    # Optional: Trigger frame update immediately after slider syncs from pause
    # video_input.pause(...).then(
    #    fn=update_frame_on_slider,
    #    inputs=[video_path_state, frame_slider], # Use the NEW slider value
    #    outputs=[frame_display, info_text, jpg_download_output, png_download_output]
    # )


# --- Launch the App ---
if __name__ == "__main__":
    demo.launch(debug=True) # Add debug=True for easier troubleshooting