diff --git a/.changeset/old-coins-heal.md b/.changeset/old-coins-heal.md new file mode 100644 index 0000000000..ea1220f473 --- /dev/null +++ b/.changeset/old-coins-heal.md @@ -0,0 +1,6 @@ +--- +"@gradio/core": minor +"gradio": minor +--- + +feat:Screen recording diff --git a/gradio/routes.py b/gradio/routes.py index 856d80be54..0fa5d14450 100644 --- a/gradio/routes.py +++ b/gradio/routes.py @@ -97,6 +97,7 @@ create_lifespan_handler, move_uploaded_files_to_cache, ) +from gradio.screen_recording_utils import process_video_with_ffmpeg from gradio.server_messages import ( CloseStreamMessage, EstimationMessage, @@ -117,6 +118,8 @@ if TYPE_CHECKING: from gradio.blocks import Block +import shutil +import tempfile mimetypes.init() @@ -152,6 +155,10 @@ "application/json", } +DEFAULT_TEMP_DIR = os.environ.get("GRADIO_TEMP_DIR") or str( + Path(tempfile.gettempdir()) / "gradio" +) + class ORJSONResponse(JSONResponse): media_type = "application/json" @@ -1769,8 +1776,105 @@ async def analytics_dashboard(key: str): else: raise HTTPException(status_code=403, detail="Invalid key.") - app.include_router(router) + @router.post("/process_recording", dependencies=[Depends(login_check)]) + async def process_recording( + request: fastapi.Request, + ): + try: + content_type_header = request.headers.get("Content-Type") + content_type: bytes + content_type, _ = parse_options_header(content_type_header or "") + if content_type != b"multipart/form-data": + raise HTTPException(status_code=400, detail="Invalid content type.") + + app = request.app + max_file_size = ( + app.get_blocks().max_file_size + if hasattr(app, "get_blocks") + else None + ) + max_file_size = max_file_size if max_file_size is not None else math.inf + + multipart_parser = GradioMultiPartParser( + request.headers, + request.stream(), + max_files=1, + max_fields=10, + max_file_size=max_file_size, + ) + form = await multipart_parser.parse() + except MultiPartException as exc: + code = 413 if "maximum allowed size" in exc.message else 400 + return PlainTextResponse(exc.message, status_code=code) + video_files = form.getlist("video") + if not video_files or not isinstance(video_files[0], GradioUploadFile): + raise HTTPException(status_code=400, detail="No video file provided") + + video_file = video_files[0] + + params = {} + if ( + form.get("remove_segment_start") is not None + and form.get("remove_segment_end") is not None + ): + params["remove_segment_start"] = form.get("remove_segment_start") + params["remove_segment_end"] = form.get("remove_segment_end") + + zoom_effects_json = form.get("zoom_effects") + if zoom_effects_json: + try: + params["zoom_effects"] = json.loads(str(zoom_effects_json)) + except json.JSONDecodeError: + params["zoom_effects"] = [] + + with tempfile.NamedTemporaryFile( + delete=False, suffix=".mp4", dir=DEFAULT_TEMP_DIR + ) as input_file: + video_file.file.seek(0) + shutil.copyfileobj(video_file.file, input_file) + input_path = input_file.name + + if wasm_utils.IS_WASM or shutil.which("ffmpeg") is None: + return FileResponse( + input_path, + media_type="video/mp4", + filename="gradio-screen-recording.mp4", + background=BackgroundTask(lambda: cleanup_files([input_path])), + ) + + output_path = tempfile.mkstemp( + suffix="_processed.mp4", dir=DEFAULT_TEMP_DIR + )[1] + + try: + processed_path, temp_files = await process_video_with_ffmpeg( + input_path, output_path, params + ) + + return FileResponse( + processed_path, + media_type="video/mp4", + filename="gradio-screen-recording.mp4", + background=BackgroundTask(lambda: cleanup_files(temp_files)), + ) + except Exception: + return FileResponse( + input_path, + media_type="video/mp4", + filename="gradio-screen-recording.mp4", + background=BackgroundTask(lambda: cleanup_files([input_path])), + ) + + def cleanup_files(files): + for file in files: + try: + if file and os.path.exists(file): + os.unlink(file) + except Exception as e: + print(f"Error cleaning up file {file}: {str(e)}") + + app.include_router(router) return app diff --git a/gradio/screen_recording_utils.py b/gradio/screen_recording_utils.py new file mode 100644 index 0000000000..4155c28cfb --- /dev/null +++ b/gradio/screen_recording_utils.py @@ -0,0 +1,328 @@ +import asyncio +import os +import shutil +import tempfile +import traceback +from pathlib import Path + +DEFAULT_TEMP_DIR = os.environ.get("GRADIO_TEMP_DIR") or str( + Path(tempfile.gettempdir()) / "gradio" +) + + +async def process_video_with_ffmpeg(input_path, output_path, params): + from ffmpy import FFmpeg + + current_input = input_path + temp_files = [input_path] + + try: + if params.get("remove_segment_start") and params.get("remove_segment_end"): + start = float(params["remove_segment_start"]) + end = float(params["remove_segment_end"]) + + if start < end: + segment_output = tempfile.mkstemp( + suffix="_trimmed.mp4", dir=DEFAULT_TEMP_DIR + )[1] + before_segment = tempfile.mkstemp( + suffix="_before.mp4", dir=DEFAULT_TEMP_DIR + )[1] + after_segment = tempfile.mkstemp( + suffix="_after.mp4", dir=DEFAULT_TEMP_DIR + )[1] + + temp_files.extend([segment_output, before_segment, after_segment]) + + if start > 0: + ff = FFmpeg( + inputs={current_input: None}, + outputs={ + before_segment: f"-t {start} -c:v libx264 -preset fast -crf 22 -c:a aac -r 30 -y" + }, + ) + process = await asyncio.create_subprocess_exec( + *ff.cmd.split(), + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout, stderr = await process.communicate() + + ff = FFmpeg( + inputs={current_input: None}, + outputs={ + after_segment: f"-ss {end} -c:v libx264 -preset fast -crf 22 -c:a aac -r 30 -y" + }, + ) + process = await asyncio.create_subprocess_exec( + *ff.cmd.split(), + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout, stderr = await process.communicate() + + concat_file = tempfile.mkstemp( + suffix="_concat.txt", dir=DEFAULT_TEMP_DIR + )[1] + temp_files.append(concat_file) + + with open(concat_file, "w") as f: + if ( + start > 0 + and os.path.exists(before_segment) + and os.path.getsize(before_segment) > 0 + ): + f.write(f"file '{before_segment}'\n") + if ( + os.path.exists(after_segment) + and os.path.getsize(after_segment) > 0 + ): + f.write(f"file '{after_segment}'\n") + + if os.path.exists(concat_file) and os.path.getsize(concat_file) > 0: + ff = FFmpeg( + inputs={concat_file: "-f concat -safe 0"}, + outputs={segment_output: "-c copy -y"}, + ) + process = await asyncio.create_subprocess_exec( + *ff.cmd.split(), + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout, stderr = await process.communicate() + + current_input = segment_output + + for file in [before_segment, after_segment, concat_file]: + try: + if os.path.exists(file): + os.unlink(file) + except OSError: + pass + + if "zoom_effects" in params and params["zoom_effects"]: + zoom_effects = params["zoom_effects"] + for i, effect in enumerate(zoom_effects): + if ( + effect.get("boundingBox") + and effect["boundingBox"].get("topLeft") + and effect["boundingBox"].get("bottomRight") + ): + top_left = effect["boundingBox"]["topLeft"] + bottom_right = effect["boundingBox"]["bottomRight"] + start_frame = effect.get("start_frame") + duration = effect.get("duration", 2.0) + + zoom_output = tempfile.mkstemp( + suffix=f"_zoom_{i}.mp4", dir=DEFAULT_TEMP_DIR + )[1] + temp_files.append(zoom_output) + + zoom_output, zoom_temp_files = await zoom_in( + current_input, top_left, bottom_right, duration, start_frame + ) + + temp_files.extend(zoom_temp_files) + if zoom_output and zoom_output != current_input: + if current_input not in [input_path]: + temp_files.append(current_input) + current_input = zoom_output + + ff = FFmpeg( + inputs={current_input: None}, + outputs={ + output_path: "-c:v libx264 -preset fast -crf 22 -c:a aac -r 30 -vsync cfr -y" + }, + ) + process = await asyncio.create_subprocess_exec( + *ff.cmd.split(), + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout, stderr = await process.communicate() + + if process.returncode != 0: + shutil.copy(current_input, output_path) + + current_input = output_path + final_trimmed_output = tempfile.mkstemp( + suffix="_final_trimmed.mp4", dir=DEFAULT_TEMP_DIR + )[1] + temp_files.append(final_trimmed_output) + + ff = FFmpeg( + inputs={current_input: None}, + outputs={ + final_trimmed_output: "-ss 0.5 -c:v libx264 -preset fast -crf 22 -c:a aac -r 30 -y" + }, + ) + process = await asyncio.create_subprocess_exec( + *ff.cmd.split(), + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout, stderr = await process.communicate() + + if ( + process.returncode == 0 + and os.path.exists(final_trimmed_output) + and os.path.getsize(final_trimmed_output) > 0 + ): + shutil.copy(final_trimmed_output, output_path) + temp_files.append(final_trimmed_output) + + return output_path, temp_files + + except Exception: + traceback.print_exc() + return input_path, temp_files + + +async def zoom_in( + input_path, + top_left=None, + bottom_right=None, + zoom_duration=2.0, + zoom_start_frame=None, +): + from ffmpy import FFmpeg + + temp_files = [] + + try: + if not input_path or not os.path.exists(input_path): + return input_path, temp_files + + if zoom_start_frame is None: + zoom_start_frame = 60 + else: + try: + zoom_start_frame = float(zoom_start_frame) + except (ValueError, TypeError): + zoom_start_frame = 60 + + if top_left is None: + top_left = [0.25, 0.25] + + if bottom_right is None: + bottom_right = [0.75, 0.75] + + try: + x1, y1 = float(top_left[0]), float(top_left[1]) + x2, y2 = float(bottom_right[0]), float(bottom_right[1]) + except (TypeError, ValueError, IndexError): + x1, y1 = 0.25, 0.25 + x2, y2 = 0.75, 0.75 + + x1 = max(0.0, min(0.9, x1)) + y1 = max(0.0, min(0.9, y1)) + x2 = max(0.1, min(1.0, x2)) + y2 = max(0.1, min(1.0, y2)) + + if x2 <= x1: + x1, x2 = 0.25, 0.75 + if y2 <= y1: + y1, y2 = 0.25, 0.75 + + box_width = x2 - x1 + box_height = y2 - y1 + + box_center_x = (x1 + x2) / 2 + box_center_y = (y1 + y2) / 2 + + def calculate_proportional_offset(center, size): + if center < 0.5: + distance_from_center = 0.5 - center + return center - (size * (distance_from_center / 0.5)) + elif center > 0.5: + distance_from_center = center - 0.5 + return center + (size * (distance_from_center / 0.5)) + return center + + zoom_center_x = calculate_proportional_offset(box_center_x, box_width) + zoom_center_y = calculate_proportional_offset(box_center_y, box_height) + + target_zoom = 3.0 + max_zoom_by_size = min(1.0 / box_width, 1.0 / box_height) + + safety_margin = 0.9 + max_zoom_by_size = max_zoom_by_size * safety_margin + + dynamic_max_zoom = min(max_zoom_by_size, target_zoom) + dynamic_max_zoom = max(dynamic_max_zoom, 1.3) + + duration_cmd = f'ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "{input_path}"' + + process = await asyncio.create_subprocess_shell( + duration_cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout, stderr = await process.communicate() + + try: + output = stdout.decode().strip() + video_duration = float(output) + except (ValueError, TypeError): + video_duration = 10.0 + + fps = 30.0 + zoom_duration = min(float(zoom_duration), video_duration) + zoom_output = tempfile.mkstemp(suffix="_zoomed.mp4", dir=DEFAULT_TEMP_DIR)[1] + temp_files.append(zoom_output) + zoom_in_frames = int(fps / 2) + zoom_out_frames = int(fps / 2) + hold_frames = int(zoom_duration * fps) + + width, height = 1920, 1080 + + complex_filter = ( + f"[0:v]zoompan=" + f"z='if(between(on,{zoom_start_frame},{zoom_start_frame + zoom_in_frames + hold_frames + zoom_out_frames})," + f"if(lt(on-{zoom_start_frame},{zoom_in_frames})," + f"1+(({dynamic_max_zoom}-1)*(on-{zoom_start_frame})/{zoom_in_frames})," + f"if(lt(on-{zoom_start_frame},{zoom_in_frames + hold_frames})," + f"{dynamic_max_zoom}," + f"{dynamic_max_zoom}-(({dynamic_max_zoom}-1)*((on-{zoom_start_frame}-{zoom_in_frames}-{hold_frames}))/{zoom_out_frames})" + f")),1)':" + f"x='iw*{zoom_center_x}-iw/zoom*{zoom_center_x}':" + f"y='ih*{zoom_center_y}-ih/zoom*{zoom_center_y}':" + f"d=1:" + f"fps={fps}:" + f"s={width}x{height}[outv]" + ) + + ff = FFmpeg( + inputs={input_path: None}, + outputs={ + zoom_output: ( + f'-filter_complex "{complex_filter}" ' + f'-map "[outv]" ' + f"-map 0:a? " + f"-c:v libx264 " + f"-pix_fmt yuv420p " + f"-movflags +faststart " + f"-preset fast " + f"-r 30 " + f"-c:a aac " + f"-y" + ) + }, + ) + + cmd_parts = ff.cmd.split() + process = await asyncio.create_subprocess_exec( + *cmd_parts, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout, stderr = await process.communicate() + + if process.returncode != 0: + return input_path, temp_files + + return zoom_output, temp_files + + except Exception: + traceback.print_exc() + return input_path, temp_files diff --git a/js/core/src/Blocks.svelte b/js/core/src/Blocks.svelte index 1dd420dd6b..6bc458990d 100644 --- a/js/core/src/Blocks.svelte +++ b/js/core/src/Blocks.svelte @@ -2,6 +2,7 @@ import { tick, onMount } from "svelte"; import { _ } from "svelte-i18n"; import { Client } from "@gradio/client"; + import { writable } from "svelte/store"; import type { LoadingStatus, LoadingStatusCollection } from "./stores"; @@ -19,12 +20,14 @@ import logo from "./images/logo.svg"; import api_logo from "./api_docs/img/api-logo.svg"; import settings_logo from "./api_docs/img/settings-logo.svg"; + import record_stop from "./api_docs/img/record-stop.svg"; import { create_components, AsyncFunction } from "./init"; import type { LogMessage, RenderMessage, StatusMessage } from "@gradio/client"; + import * as screen_recorder from "./screen_recorder"; export let root: string; export let components: ComponentMeta[]; @@ -97,6 +100,8 @@ let settings_visible = search_params.get("view") === "settings"; let api_recorder_visible = search_params.get("view") === "api-recorder" && show_api; + let allow_zoom = true; + let allow_video_trim = true; function set_api_docs_visible(visible: boolean): void { api_recorder_visible = false; @@ -126,11 +131,28 @@ export let render_complete = false; async function handle_update(data: any, fn_index: number): Promise { const dep = dependencies.find((dep) => dep.id === fn_index); + const input_type = components.find( + (comp) => comp.id === dep?.inputs[0] + )?.type; + if (allow_zoom && dep && input_type !== "dataset") { + if (dep && dep.inputs && dep.inputs.length > 0 && $is_screen_recording) { + screen_recorder.zoom(true, dep.inputs, 1.0); + } + + if ( + dep && + dep.outputs && + dep.outputs.length > 0 && + $is_screen_recording + ) { + screen_recorder.zoom(false, dep.outputs, 2.0); + } + } + if (!dep) { return; } const outputs = dep.outputs; - const meta_updates = data?.map((value: any, i: number) => { return { id: outputs[i], @@ -372,6 +394,9 @@ payload: Payload, streaming = false ): Promise { + if (allow_video_trim) { + screen_recorder.markRemoveSegmentStart(); + } if (api_recorder_visible) { api_calls = [...api_calls, JSON.parse(JSON.stringify(payload))]; } @@ -601,6 +626,9 @@ }); } } + if (allow_video_trim) { + screen_recorder.markRemoveSegmentEnd(); + } } } /* eslint-enable complexity */ @@ -773,6 +801,8 @@ return "detail" in event; } + let is_screen_recording = writable(false); + onMount(() => { document.addEventListener("visibilitychange", function () { if (document.visibilityState === "hidden") { @@ -784,7 +814,25 @@ /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test( navigator.userAgent ); + + screen_recorder.initialize( + root, + (title, message, type) => { + add_new_message(title, message, type); + }, + (isRecording) => { + $is_screen_recording = isRecording; + } + ); }); + + function screen_recording(): void { + if ($is_screen_recording) { + screen_recorder.stopRecording(); + } else { + screen_recorder.startRecording(); + } + } @@ -840,6 +888,17 @@ {$_("common.built_with_gradio")} {$_("common.logo")} +
·
+
·
+ diff --git a/js/core/src/api_docs/img/record-stop.svg b/js/core/src/api_docs/img/record-stop.svg new file mode 100644 index 0000000000..2b83f60b1d --- /dev/null +++ b/js/core/src/api_docs/img/record-stop.svg @@ -0,0 +1 @@ + record [#982] Created with Sketch. \ No newline at end of file diff --git a/js/core/src/api_docs/img/record.svg b/js/core/src/api_docs/img/record.svg new file mode 100644 index 0000000000..a11a664ac4 --- /dev/null +++ b/js/core/src/api_docs/img/record.svg @@ -0,0 +1 @@ + record [#982] Created with Sketch. \ No newline at end of file diff --git a/js/core/src/lang/en.json b/js/core/src/lang/en.json index 793aceadc9..b406510f9b 100644 --- a/js/core/src/lang/en.json +++ b/js/core/src/lang/en.json @@ -69,6 +69,10 @@ "language": "Language", "display_theme": "Display Theme", "pwa": "Progressive Web App", + "record": "Record", + "stop_recording": "Stop Recording", + "screen_studio": "Screen Studio", + "share_gradio_tab": "[Sharing] Gradio Tab", "run": "Run" }, "dataframe": { diff --git a/js/core/src/screen_recorder.ts b/js/core/src/screen_recorder.ts new file mode 100644 index 0000000000..b7e2c13005 --- /dev/null +++ b/js/core/src/screen_recorder.ts @@ -0,0 +1,361 @@ +import type { ToastMessage } from "@gradio/statustracker"; + +let isRecording = false; +let mediaRecorder: MediaRecorder | null = null; +let recordedChunks: Blob[] = []; +let recordingStartTime = 0; +let animationFrameId: number | null = null; +let removeSegment: { start?: number; end?: number } = {}; +let root: string; + +let add_message_callback: ( + title: string, + message: string, + type: ToastMessage["type"] +) => void; +let onRecordingStateChange: ((isRecording: boolean) => void) | null = null; +let zoomEffects: { + boundingBox: { topLeft: [number, number]; bottomRight: [number, number] }; + start_frame: number; + duration?: number; +}[] = []; + +export function initialize( + rootPath: string, + add_new_message: ( + title: string, + message: string, + type: ToastMessage["type"] + ) => void, + recordingStateCallback?: (isRecording: boolean) => void +): void { + root = rootPath; + add_message_callback = add_new_message; + if (recordingStateCallback) { + onRecordingStateChange = recordingStateCallback; + } +} + +export async function startRecording(): Promise { + if (isRecording) { + return; + } + + try { + const originalTitle = document.title; + document.title = "[Sharing] Gradio Tab"; + const stream = await navigator.mediaDevices.getDisplayMedia({ + video: { + width: { ideal: 1920 }, + height: { ideal: 1080 }, + frameRate: { ideal: 30 } + }, + audio: true, + selfBrowserSurface: "include" + } as MediaStreamConstraints); + document.title = originalTitle; + + const options = { + videoBitsPerSecond: 5000000 + }; + + mediaRecorder = new MediaRecorder(stream, options); + + recordedChunks = []; + removeSegment = {}; + + mediaRecorder.ondataavailable = handleDataAvailable; + mediaRecorder.onstop = handleStop; + + mediaRecorder.start(1000); + isRecording = true; + if (onRecordingStateChange) { + onRecordingStateChange(true); + } + recordingStartTime = Date.now(); + } catch (error: any) { + add_message_callback( + "Recording Error", + "Failed to start recording: " + error.message, + "error" + ); + } +} + +export function stopRecording(): void { + if (!isRecording || !mediaRecorder) { + return; + } + + mediaRecorder.stop(); + isRecording = false; + if (onRecordingStateChange) { + onRecordingStateChange(false); + } +} + +export function isCurrentlyRecording(): boolean { + return isRecording; +} + +export function markRemoveSegmentStart(): void { + if (!isRecording) { + return; + } + + const currentTime = (Date.now() - recordingStartTime) / 1000; + removeSegment.start = currentTime; +} + +export function markRemoveSegmentEnd(): void { + if (!isRecording || removeSegment.start === undefined) { + return; + } + + const currentTime = (Date.now() - recordingStartTime) / 1000; + removeSegment.end = currentTime; +} + +export function clearRemoveSegment(): void { + removeSegment = {}; +} + +export function addZoomEffect( + is_input: boolean, + params: { + boundingBox: { + topLeft: [number, number]; + bottomRight: [number, number]; + }; + duration?: number; + } +): void { + if (!isRecording) { + return; + } + + const FPS = 30; + const currentTime = (Date.now() - recordingStartTime) / 1000; + const currentFrame = is_input + ? Math.floor((currentTime - 2) * FPS) + : Math.floor(currentTime * FPS); + + if ( + params.boundingBox && + params.boundingBox.topLeft && + params.boundingBox.bottomRight && + params.boundingBox.topLeft.length === 2 && + params.boundingBox.bottomRight.length === 2 + ) { + const newEffectDuration = params.duration || 2.0; + const newEffectEndFrame = + currentFrame + Math.floor(newEffectDuration * FPS); + + const hasOverlap = zoomEffects.some((existingEffect) => { + const existingEffectEndFrame = + existingEffect.start_frame + + Math.floor((existingEffect.duration || 2.0) * FPS); + return ( + (currentFrame >= existingEffect.start_frame && + currentFrame <= existingEffectEndFrame) || + (newEffectEndFrame >= existingEffect.start_frame && + newEffectEndFrame <= existingEffectEndFrame) || + (currentFrame <= existingEffect.start_frame && + newEffectEndFrame >= existingEffectEndFrame) + ); + }); + + if (!hasOverlap) { + zoomEffects.push({ + boundingBox: params.boundingBox, + start_frame: currentFrame, + duration: newEffectDuration + }); + } + } +} + +export function zoom( + is_input: boolean, + elements: number[], + duration = 2.0 +): void { + if (!isRecording) { + return; + } + + try { + setTimeout(() => { + if (!elements || elements.length === 0) { + return; + } + + let minLeft = Infinity; + let minTop = Infinity; + let maxRight = 0; + let maxBottom = 0; + let foundElements = false; + + for (const elementId of elements) { + const selector = `#component-${elementId}`; + const element = document.querySelector(selector); + + if (element) { + foundElements = true; + const rect = element.getBoundingClientRect(); + + minLeft = Math.min(minLeft, rect.left); + minTop = Math.min(minTop, rect.top); + maxRight = Math.max(maxRight, rect.right); + maxBottom = Math.max(maxBottom, rect.bottom); + } + } + + if (!foundElements) { + return; + } + + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + + const boxWidth = Math.min(maxRight, viewportWidth) - Math.max(0, minLeft); + const boxHeight = + Math.min(maxBottom, viewportHeight) - Math.max(0, minTop); + + const widthPercentage = boxWidth / viewportWidth; + const heightPercentage = boxHeight / viewportHeight; + + if (widthPercentage >= 0.8 || heightPercentage >= 0.8) { + return; + } + + const isSafari = /^((?!chrome|android).)*safari/i.test( + navigator.userAgent + ); + + let topLeft: [number, number] = [ + Math.max(0, minLeft) / viewportWidth, + Math.max(0, minTop) / viewportHeight + ]; + + let bottomRight: [number, number] = [ + Math.min(maxRight, viewportWidth) / viewportWidth, + Math.min(maxBottom, viewportHeight) / viewportHeight + ]; + + if (isSafari) { + topLeft[0] = Math.max(0, topLeft[0] * 0.9); + bottomRight[0] = Math.min(1, bottomRight[0] * 0.9); + const width = bottomRight[0] - topLeft[0]; + const center = (topLeft[0] + bottomRight[0]) / 2; + const newCenter = center * 0.9; + topLeft[0] = Math.max(0, newCenter - width / 2); + bottomRight[0] = Math.min(1, newCenter + width / 2); + } + + topLeft[0] = Math.max(0, topLeft[0]); + topLeft[1] = Math.max(0, topLeft[1]); + bottomRight[0] = Math.min(1, bottomRight[0]); + bottomRight[1] = Math.min(1, bottomRight[1]); + + addZoomEffect(is_input, { + boundingBox: { + topLeft, + bottomRight + }, + duration: duration + }); + }, 300); + } catch (error) { + // pass + } +} + +function handleDataAvailable(event: BlobEvent): void { + if (event.data.size > 0) { + recordedChunks.push(event.data); + } +} + +function handleStop(): void { + isRecording = false; + if (onRecordingStateChange) { + onRecordingStateChange(false); + } + + const blob = new Blob(recordedChunks, { + type: "video/mp4" + }); + + handleRecordingComplete(blob); + + const screenStream = mediaRecorder?.stream?.getTracks() || []; + screenStream.forEach((track) => track.stop()); + + if (animationFrameId !== null) { + cancelAnimationFrame(animationFrameId); + animationFrameId = null; + } +} + +async function handleRecordingComplete(recordedBlob: Blob): Promise { + try { + add_message_callback( + "Processing video", + "This may take a few seconds...", + "info" + ); + + const formData = new FormData(); + formData.append("video", recordedBlob, "recording.mp4"); + + if (removeSegment.start !== undefined && removeSegment.end !== undefined) { + formData.append("remove_segment_start", removeSegment.start.toString()); + formData.append("remove_segment_end", removeSegment.end.toString()); + } + + if (zoomEffects.length > 0) { + formData.append("zoom_effects", JSON.stringify(zoomEffects)); + } + + const response = await fetch(root + "/gradio_api/process_recording", { + method: "POST", + body: formData + }); + + if (!response.ok) { + throw new Error( + `Server returned ${response.status}: ${response.statusText}` + ); + } + + const processedBlob = await response.blob(); + const defaultFilename = `gradio-screen-recording-${new Date().toISOString().replace(/:/g, "-").replace(/\..+/, "")}.mp4`; + saveWithDownloadAttribute(processedBlob, defaultFilename); + zoomEffects = []; + } catch (error) { + add_message_callback( + "Processing Error", + "Failed to process recording. Saving original version.", + "warning" + ); + + const defaultFilename = `gradio-screen-recording-${new Date().toISOString().replace(/:/g, "-").replace(/\..+/, "")}.mp4`; + saveWithDownloadAttribute(recordedBlob, defaultFilename); + } +} + +function saveWithDownloadAttribute(blob: Blob, suggestedName: string): void { + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.style.display = "none"; + a.href = url; + a.download = suggestedName; + + document.body.appendChild(a); + a.click(); + setTimeout(() => { + document.body.removeChild(a); + URL.revokeObjectURL(url); + }, 100); +}