Spaces:
Running
Running
Add demo files.
Browse files- README.md +28 -3
- app.py +42 -0
- main.js +74 -0
- record_button.js +40 -0
- recorder.js +112 -0
README.md
CHANGED
@@ -1,8 +1,8 @@
|
|
1 |
---
|
2 |
title: Gradio Screen Recorder
|
3 |
-
emoji:
|
4 |
colorFrom: blue
|
5 |
-
colorTo:
|
6 |
sdk: gradio
|
7 |
sdk_version: 4.7.1
|
8 |
app_file: app.py
|
@@ -10,4 +10,29 @@ pinned: false
|
|
10 |
license: apache-2.0
|
11 |
---
|
12 |
|
13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
---
|
2 |
title: Gradio Screen Recorder
|
3 |
+
emoji: π»π₯
|
4 |
colorFrom: blue
|
5 |
+
colorTo: blue
|
6 |
sdk: gradio
|
7 |
sdk_version: 4.7.1
|
8 |
app_file: app.py
|
|
|
10 |
license: apache-2.0
|
11 |
---
|
12 |
|
13 |
+
# Gradio Screen Recorder
|
14 |
+
|
15 |
+
This is a simple example of how a screen recorder button can be configured in Gradio.
|
16 |
+
|
17 |
+
Depending on your current settings, you may need to grant permission to your browser to record your screen or to use your microphone if recording a voiceover.
|
18 |
+
|
19 |
+
When there is time I may make it into a standalone component using the new support for custom components.
|
20 |
+
|
21 |
+
## Dependencies
|
22 |
+
This demo has a dependency on `ffmpeg` to convert `webm` files to `mp4` format. On linux (debian) install with `apt-get install ffmpeg`.
|
23 |
+
|
24 |
+
## Limitations
|
25 |
+
This demo uses the Media Recording API of supported browsers. Some details on Media Recording API limitations can be found [here](
|
26 |
+
https://blog.addpipe.com/mediarecorder-api/#:~:text=Firefox%20or%20Chrome.-,Final%20Conclusions,-Although%20a%20simple)
|
27 |
+
|
28 |
+
### TODO Features
|
29 |
+
- Any way to work within colab cells given js restrictions?
|
30 |
+
- Option to convert and save as gif rather than webm or mp4
|
31 |
+
- Hotkeys for start / stop
|
32 |
+
- Prerecording countdown [as in Streamlit](https://docs.streamlit.io/library/advanced-features/app-menu#:~:text=to%2Dpdf%20function.-,Record%20a%20screencast,-You%20can%20easily)
|
33 |
+
- Streaming support via [MediaStream Web API?](https://dev.to/antopiras89/using-the-mediastream-web-api-to-record-screen-camera-and-audio-1c4n)?
|
34 |
+
|
35 |
+
### TODO Cleanup
|
36 |
+
- Address any limits on recording size (base64 string an issue?)
|
37 |
+
- Additional error handling around recorder setup
|
38 |
+
- Namespace window variables to prevent possible collisions
|
app.py
ADDED
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import base64
|
2 |
+
import pathlib
|
3 |
+
import tempfile
|
4 |
+
import gradio as gr
|
5 |
+
|
6 |
+
recorder_js = pathlib.Path('recorder.js').read_text()
|
7 |
+
main_js = pathlib.Path('main.js').read_text()
|
8 |
+
record_button_js = pathlib.Path('record_button.js').read_text().replace('let recorder_js = null;', recorder_js).replace(
|
9 |
+
'let main_js = null;', main_js)
|
10 |
+
|
11 |
+
|
12 |
+
def save_base64_video(base64_string):
|
13 |
+
base64_video = base64_string
|
14 |
+
video_data = base64.b64decode(base64_video)
|
15 |
+
with tempfile.NamedTemporaryFile(suffix=".mp4", delete=False) as temp_file:
|
16 |
+
temp_filename = temp_file.name
|
17 |
+
temp_file.write(video_data)
|
18 |
+
print(f"Temporary MP4 file saved as: {temp_filename}")
|
19 |
+
return temp_filename
|
20 |
+
|
21 |
+
|
22 |
+
with gr.Blocks(title="Screen Recorder Demo") as demo:
|
23 |
+
start_button = gr.Button("Record Screen π΄")
|
24 |
+
video_component = gr.Video(interactive=True, show_share_button=True)
|
25 |
+
|
26 |
+
|
27 |
+
def toggle_button_label(returned_string):
|
28 |
+
if returned_string.startswith("Record"):
|
29 |
+
return gr.Button(value="Stop Recording βͺ"), None
|
30 |
+
else:
|
31 |
+
try:
|
32 |
+
temp_filename = save_base64_video(returned_string)
|
33 |
+
except Exception as e:
|
34 |
+
return gr.Button(value="Record Screen π΄"), gr.Warning(f'Failed to convert video to mp4:\n{e}')
|
35 |
+
return gr.Button(value="Record Screen π΄"), gr.Video(value=temp_filename, interactive=True,
|
36 |
+
show_share_button=True)
|
37 |
+
|
38 |
+
|
39 |
+
start_button.click(toggle_button_label, start_button, [start_button, video_component], js=record_button_js)
|
40 |
+
|
41 |
+
if __name__ == "__main__":
|
42 |
+
demo.launch()
|
main.js
ADDED
@@ -0,0 +1,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
// main.js
|
2 |
+
if (!ScreenCastRecorder.isSupportedBrowser()) {
|
3 |
+
console.error("Screen Recording not supported in this browser");
|
4 |
+
}
|
5 |
+
let recorder;
|
6 |
+
let outputBlob;
|
7 |
+
const stopRecording = () => __awaiter(void 0, void 0, void 0, function* () {
|
8 |
+
let currentState = "RECORDING";
|
9 |
+
// We should do nothing if the user try to stop recording when it is not started
|
10 |
+
if (currentState === "OFF" || recorder == null) {
|
11 |
+
return;
|
12 |
+
}
|
13 |
+
// if (currentState === "COUNTDOWN") {
|
14 |
+
// this.setState({
|
15 |
+
// currentState: "OFF",
|
16 |
+
// })
|
17 |
+
// }
|
18 |
+
if (currentState === "RECORDING") {
|
19 |
+
if (recorder.getState() === "inactive") {
|
20 |
+
// this.setState({
|
21 |
+
// currentState: "OFF",
|
22 |
+
// })
|
23 |
+
console.log("Inactive");
|
24 |
+
}
|
25 |
+
else {
|
26 |
+
outputBlob = yield recorder.stop();
|
27 |
+
console.log("Done recording");
|
28 |
+
// this.setState({
|
29 |
+
// outputBlob,
|
30 |
+
// currentState: "PREVIEW_FILE",
|
31 |
+
// })
|
32 |
+
window.currentState = "PREVIEW_FILE";
|
33 |
+
const videoSource = URL.createObjectURL(outputBlob);
|
34 |
+
window.videoSource = videoSource;
|
35 |
+
const fileName = "recording";
|
36 |
+
const link = document.createElement("a");
|
37 |
+
link.setAttribute("href", videoSource);
|
38 |
+
link.setAttribute("download", `${fileName}.webm`);
|
39 |
+
link.click();
|
40 |
+
}
|
41 |
+
}
|
42 |
+
});
|
43 |
+
const startRecording = () => __awaiter(void 0, void 0, void 0, function* () {
|
44 |
+
const recordAudio = true;
|
45 |
+
recorder = new ScreenCastRecorder({
|
46 |
+
recordAudio,
|
47 |
+
onErrorOrStop: () => stopRecording(),
|
48 |
+
});
|
49 |
+
try {
|
50 |
+
yield recorder.initialize();
|
51 |
+
}
|
52 |
+
catch (e) {
|
53 |
+
console.warn(`ScreenCastRecorder.initialize error: ${e}`);
|
54 |
+
// this.setState({ currentState: "UNSUPPORTED" })
|
55 |
+
window.currentState = "UNSUPPORTED";
|
56 |
+
return;
|
57 |
+
}
|
58 |
+
// this.setState({ currentState: "COUNTDOWN" })
|
59 |
+
const hasStarted = recorder.start();
|
60 |
+
if (hasStarted) {
|
61 |
+
// this.setState({
|
62 |
+
// currentState: "RECORDING",
|
63 |
+
// })
|
64 |
+
console.log("Started recording");
|
65 |
+
window.currentState = "RECORDING";
|
66 |
+
}
|
67 |
+
else {
|
68 |
+
stopRecording().catch(err => console.warn(`withScreencast.stopRecording threw an error: ${err}`));
|
69 |
+
}
|
70 |
+
});
|
71 |
+
|
72 |
+
// Set global functions to window.
|
73 |
+
window.startRecording = startRecording;
|
74 |
+
window.stopRecording = stopRecording;
|
record_button.js
ADDED
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
// Setup if needed and start recording.
|
2 |
+
async () => {
|
3 |
+
// Set up recording functions if not already initialized
|
4 |
+
if (!window.startRecording) {
|
5 |
+
let recorder_js = null;
|
6 |
+
let main_js = null;
|
7 |
+
}
|
8 |
+
|
9 |
+
// Function to fetch and convert video blob to base64 using async/await without explicit Promise
|
10 |
+
async function getVideoBlobAsBase64(objectURL) {
|
11 |
+
const response = await fetch(objectURL);
|
12 |
+
if (!response.ok) {
|
13 |
+
throw new Error('Failed to fetch video blob.');
|
14 |
+
}
|
15 |
+
|
16 |
+
const blob = await response.blob();
|
17 |
+
|
18 |
+
const reader = new FileReader();
|
19 |
+
reader.readAsDataURL(blob);
|
20 |
+
|
21 |
+
return new Promise((resolve, reject) => {
|
22 |
+
reader.onloadend = () => {
|
23 |
+
if (reader.result) {
|
24 |
+
resolve(reader.result.split(',')[1]); // Return the base64 string (without data URI prefix)
|
25 |
+
} else {
|
26 |
+
reject('Failed to convert blob to base64.');
|
27 |
+
}
|
28 |
+
};
|
29 |
+
});
|
30 |
+
}
|
31 |
+
|
32 |
+
if (window.currentState === "RECORDING") {
|
33 |
+
await window.stopRecording();
|
34 |
+
const base64String = await getVideoBlobAsBase64(window.videoSource);
|
35 |
+
return base64String;
|
36 |
+
} else {
|
37 |
+
window.startRecording();
|
38 |
+
return "Record";
|
39 |
+
}
|
40 |
+
}
|
recorder.js
ADDED
@@ -0,0 +1,112 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
// recorder.js
|
2 |
+
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
3 |
+
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
4 |
+
return new (P || (P = Promise))(function (resolve, reject) {
|
5 |
+
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
6 |
+
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
7 |
+
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
8 |
+
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
9 |
+
});
|
10 |
+
};
|
11 |
+
const BLOB_TYPE = "video/webm";
|
12 |
+
class ScreenCastRecorder {
|
13 |
+
/** True if the current browser likely supports screencasts. */
|
14 |
+
static isSupportedBrowser() {
|
15 |
+
return (navigator.mediaDevices != null &&
|
16 |
+
navigator.mediaDevices.getUserMedia != null &&
|
17 |
+
navigator.mediaDevices.getDisplayMedia != null &&
|
18 |
+
MediaRecorder.isTypeSupported(BLOB_TYPE));
|
19 |
+
}
|
20 |
+
constructor({ recordAudio, onErrorOrStop }) {
|
21 |
+
this.recordAudio = recordAudio;
|
22 |
+
this.onErrorOrStopCallback = onErrorOrStop;
|
23 |
+
this.inputStream = null;
|
24 |
+
this.recordedChunks = [];
|
25 |
+
this.mediaRecorder = null;
|
26 |
+
}
|
27 |
+
/**
|
28 |
+
* This asynchronous method will initialize the screen recording object asking
|
29 |
+
* for permissions to the user which are needed to start recording.
|
30 |
+
*/
|
31 |
+
initialize() {
|
32 |
+
return __awaiter(this, void 0, void 0, function* () {
|
33 |
+
const desktopStream = yield navigator.mediaDevices.getDisplayMedia({
|
34 |
+
video: true,
|
35 |
+
});
|
36 |
+
let tracks = desktopStream.getTracks();
|
37 |
+
if (this.recordAudio) {
|
38 |
+
const voiceStream = yield navigator.mediaDevices.getUserMedia({
|
39 |
+
video: false,
|
40 |
+
audio: true,
|
41 |
+
});
|
42 |
+
tracks = tracks.concat(voiceStream.getAudioTracks());
|
43 |
+
}
|
44 |
+
this.recordedChunks = [];
|
45 |
+
this.inputStream = new MediaStream(tracks);
|
46 |
+
this.mediaRecorder = new MediaRecorder(this.inputStream, {
|
47 |
+
mimeType: BLOB_TYPE,
|
48 |
+
});
|
49 |
+
this.mediaRecorder.ondataavailable = e => this.recordedChunks.push(e.data);
|
50 |
+
});
|
51 |
+
}
|
52 |
+
getState() {
|
53 |
+
if (this.mediaRecorder) {
|
54 |
+
return this.mediaRecorder.state;
|
55 |
+
}
|
56 |
+
return "inactive";
|
57 |
+
}
|
58 |
+
/**
|
59 |
+
* This method will start the screen recording if the user has granted permissions
|
60 |
+
* and the mediaRecorder has been initialized
|
61 |
+
*
|
62 |
+
* @returns {boolean}
|
63 |
+
*/
|
64 |
+
start() {
|
65 |
+
if (!this.mediaRecorder) {
|
66 |
+
console.warn(`ScreenCastRecorder.start: mediaRecorder is null`);
|
67 |
+
return false;
|
68 |
+
}
|
69 |
+
const logRecorderError = (e) => {
|
70 |
+
console.warn(`mediaRecorder.start threw an error: ${e}`);
|
71 |
+
};
|
72 |
+
this.mediaRecorder.onerror = (e) => {
|
73 |
+
logRecorderError(e);
|
74 |
+
this.onErrorOrStopCallback();
|
75 |
+
};
|
76 |
+
this.mediaRecorder.onstop = () => this.onErrorOrStopCallback();
|
77 |
+
try {
|
78 |
+
this.mediaRecorder.start();
|
79 |
+
}
|
80 |
+
catch (e) {
|
81 |
+
logRecorderError(e);
|
82 |
+
return false;
|
83 |
+
}
|
84 |
+
return true;
|
85 |
+
}
|
86 |
+
/**
|
87 |
+
* This method will stop recording and then return the generated Blob
|
88 |
+
*
|
89 |
+
* @returns {(Promise|undefined)}
|
90 |
+
* A Promise which will return the generated Blob
|
91 |
+
* Undefined if the MediaRecorder could not initialize
|
92 |
+
*/
|
93 |
+
stop() {
|
94 |
+
if (!this.mediaRecorder) {
|
95 |
+
return undefined;
|
96 |
+
}
|
97 |
+
let resolver;
|
98 |
+
const promise = new Promise(r => {
|
99 |
+
resolver = r;
|
100 |
+
});
|
101 |
+
this.mediaRecorder.onstop = () => resolver();
|
102 |
+
this.mediaRecorder.stop();
|
103 |
+
if (this.inputStream) {
|
104 |
+
this.inputStream.getTracks().forEach(s => s.stop());
|
105 |
+
this.inputStream = null;
|
106 |
+
}
|
107 |
+
return promise.then(() => this.buildOutputBlob());
|
108 |
+
}
|
109 |
+
buildOutputBlob() {
|
110 |
+
return new Blob(this.recordedChunks, { type: BLOB_TYPE });
|
111 |
+
}
|
112 |
+
}
|