Skip to content

Commit ef22821

Browse files
authored
Merge pull request #935 from StioStudio/Video-Recorder-Feature
Video recorder feature
2 parents 82702c5 + 3a5e9c3 commit ef22821

File tree

5 files changed

+291
-0
lines changed

5 files changed

+291
-0
lines changed

features/features.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
11
[
2+
{
3+
"version": 2,
4+
"id": "video-recorder",
5+
"versionAdded": "v4.0.0"
6+
},
27
{
38
"version": 2,
49
"id": "studio-creation-date",

features/video-recorder/data.json

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
{
2+
"title": "Record Stage",
3+
"description": "Allows you to record the stage for projects while in the editor or on the project page.",
4+
"credits": [
5+
{
6+
"username": "blob2763",
7+
"url": "https://blob2763.is-a.dev/"
8+
},
9+
{
10+
"username": "stio_studio",
11+
"url": "https://stio.studio/"
12+
}
13+
],
14+
"type": ["Editor"],
15+
"tags": ["New", "Featured"],
16+
"scripts": [
17+
{
18+
"file": "video-recorder.js",
19+
"runOn": "/projects/*"
20+
}
21+
],
22+
"styles": [
23+
{
24+
"file": "style.css",
25+
"runOn": "/projects/*"
26+
}
27+
],
28+
"resources": [
29+
{
30+
"name": "popup-html",
31+
"path": "/popup.html"
32+
}
33+
]
34+
}

features/video-recorder/popup.html

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
<div class="ReactModalPortal STE-ReactModalPortal">
2+
<div class="ReactModal__Overlay ReactModal__Overlay--after-open modal_modal-overlay_1Lcbx">
3+
<div
4+
class="ReactModal__Content ReactModal__Content--after-open modal_modal-content_1h3ll prompt_modal-content_1BfWj"
5+
tabindex="-1" role="dialog" aria-label="Rename Variable">
6+
<div class="box_box_2jjDp" dir="ltr" style="flex-direction: column; flex-grow: 1">
7+
<div class="modal_header_1h7ps">
8+
<div class="modal_header-item_2zQTd modal_header-item-title_tLOU5">
9+
Video Recording
10+
</div>
11+
<div class="modal_header-item_2zQTd modal_header-item-close_2XDeL">
12+
<div aria-label="Close" class="close-button_close-button_lOp2G close-button_large_2oadS" role="button"
13+
tabindex="0">
14+
<img class="close-button_close-icon_HBCuO"
15+
src="data:image/svg+xml;base64,PHN2ZyBpZD0iTGF5ZXJfMSIgZGF0YS1uYW1lPSJMYXllciAxIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA3LjQ4IDcuNDgiPjxkZWZzPjxzdHlsZT4uY2xzLTF7ZmlsbDpub25lO3N0cm9rZTojZmZmO3N0cm9rZS1saW5lY2FwOnJvdW5kO3N0cm9rZS1saW5lam9pbjpyb3VuZDtzdHJva2Utd2lkdGg6MnB4O308L3N0eWxlPjwvZGVmcz48dGl0bGU+aWNvbi0tYWRkPC90aXRsZT48bGluZSBjbGFzcz0iY2xzLTEiIHgxPSIzLjc0IiB5MT0iNi40OCIgeDI9IjMuNzQiIHkyPSIxIi8+PGxpbmUgY2xhc3M9ImNscy0xIiB4MT0iMSIgeTE9IjMuNzQiIHgyPSI2LjQ4IiB5Mj0iMy43NCIvPjwvc3ZnPg==" />
16+
</div>
17+
</div>
18+
</div>
19+
<div class="prompt_body_18Z-I box_box_2jjDp">
20+
<!-- <div class="prompt_label_tWjYZ box_box_2jjDp"></div>
21+
<div class="box_box_2jjDp"><input class="prompt_variable-name-text-input_1iu8-"
22+
name="Rename all &quot;box size&quot; variables to:" value="box size"></div> -->
23+
<div class="prompt_button-row_3Wc5Z box_box_2jjDp STE-left-text">
24+
<button class="stopButton STE-hide-button">
25+
<span>Stop Recording</span>
26+
</button>
27+
<button class="prompt_ok-button_3QFdD startButton scratchtoolsTag">
28+
<span>Start Recording</span>
29+
</button>
30+
<br /><br />
31+
Microphone: <input type="checkbox" class="microphoneCheckbox">
32+
<br />
33+
Desktop sound: <input type="checkbox" class="desktopSoundCheckbox" checked>
34+
<br />
35+
<select class="video-format-select">
36+
<option value="mp4">mp4</option>
37+
<option value="webm">webm</option>
38+
</select>
39+
<br /><br />
40+
Preview: <br />
41+
<video class="STE-recorded-video"></video>
42+
<button class="prompt_ok-button_3QFdD downloadButton scratchtoolsTag">
43+
<span>Download Video</span>
44+
</button>
45+
</div>
46+
</div>
47+
</div>
48+
</div>
49+
</div>
50+
</div>

features/video-recorder/style.css

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
.STE-ReactModalPortal .STE-recorded-video {
2+
width: 100%;
3+
height: 100%;
4+
border: 10px solid #ccc;
5+
border-radius: 10px;
6+
}
7+
8+
.STE-ReactModalPortal .STE-hide-button {
9+
display: none;
10+
}
11+
12+
.STE-ReactModalPortal .STE-left-text {
13+
text-align: left;
14+
}
15+
16+
.STE-ReactModalPortal .stopButton,
17+
.STE-ReactModalPortal .startButton,
18+
.STE-ReactModalPortal .downloadButton,
19+
.STE-ReactModalPortal .video-format-select {
20+
width: 100%;
21+
}
22+
Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
export default async function ({ feature, console }) {
2+
await new Promise(async (resolve, reject) => {
3+
(async () => {
4+
const rem = await ScratchTools.waitForElement(".preview .inner .flex-row.action-buttons")
5+
resolve(rem);
6+
})();
7+
(async () => {
8+
const rem = await ScratchTools.waitForElement(".menu-bar_account-info-group_MeJZP")
9+
resolve(rem);
10+
})();
11+
})
12+
13+
let openPopup = document.createElement("button");
14+
15+
ScratchTools.waitForElements(".preview .inner .flex-row.action-buttons", async function (row) {
16+
if (row.querySelector(".ste-video-recorder-open")) return;
17+
openPopup = document.createElement("button");
18+
openPopup.className = "button action-button ste-video-recorder-open";
19+
openPopup.textContent = "Record Video";
20+
row.insertAdjacentElement("afterbegin", openPopup);
21+
openPopup.addEventListener('click', () => {
22+
document.body.append(popup)
23+
})
24+
})
25+
26+
ScratchTools.waitForElements(".menu-bar_account-info-group_MeJZP", async function (row) {
27+
if (row.querySelector(".ste-video-recorder-open")) return;
28+
openPopup = document.createElement("div");
29+
openPopup.className = "menu-bar_menu-bar-item_oLDa- menu-bar_hoverable_c6WFB";
30+
openPopup.style.padding = "0 0.75rem"
31+
let rem = document.createElement("div");
32+
rem.textContent = "Record Video";
33+
openPopup.append(rem);
34+
row.insertAdjacentElement("afterbegin", openPopup);
35+
openPopup.addEventListener('click', () => {
36+
document.body.append(popup)
37+
})
38+
})
39+
40+
let popup = document.createElement("div");
41+
popup.insertAdjacentHTML("afterbegin", await (await fetch(feature.self.getResource("popup-html"))).text())
42+
popup = popup.querySelector("div.ReactModalPortal")
43+
44+
let stopButton = popup.querySelector(".stopButton");
45+
let startButton = popup.querySelector(".startButton");
46+
let closeButton = popup.querySelector(".close-button_close-button_lOp2G");
47+
let downloadButton = popup.querySelector(".downloadButton");
48+
let lastDownloadFunction = () => { }
49+
let mimeType = popup.querySelector("select");
50+
let microphoneCheckbox = popup.querySelector(".microphoneCheckbox");
51+
let desktopSoundCheckbox = popup.querySelector(".desktopSoundCheckbox");
52+
53+
closeButton.addEventListener('click', () => {
54+
document.querySelector(".STE-ReactModalPortal").remove()
55+
})
56+
addEventListener("keydown", (e) => {
57+
if (e.key === "Escape") {
58+
document.querySelector(".STE-ReactModalPortal").remove()
59+
}
60+
})
61+
62+
const canvas = feature.traps.vm.renderer.canvas;
63+
const preview = popup.querySelector("video")
64+
65+
await new Promise(async (resolve, reject) => {
66+
(async () => {
67+
const rem = await ScratchTools.waitForElement("input.inplace-input")
68+
resolve(rem);
69+
})();
70+
(async () => {
71+
const rem = await ScratchTools.waitForElement("input.project-title-input_title-field_en5Gd")
72+
resolve(rem);
73+
})();
74+
(async () => {
75+
const rem = await ScratchTools.waitForElement(".project-title")
76+
resolve(rem);
77+
})();
78+
})
79+
80+
let projectTitle = document.querySelector("input.inplace-input") || document.querySelector("input.project-title-input_title-field_en5Gd") || document.querySelector(".project-title");
81+
82+
ScratchTools.waitForElements("input.inplace-input", async function (_projectTitle) {
83+
projectTitle = _projectTitle
84+
})
85+
86+
ScratchTools.waitForElements("input.project-title-input_title-field_en5Gd", async function (_projectTitle) {
87+
projectTitle = _projectTitle
88+
})
89+
90+
ScratchTools.waitForElements(".project-title", async function (_projectTitle) {
91+
projectTitle = _projectTitle
92+
})
93+
94+
95+
let mediaRecorder;
96+
let recordedChunks = [];
97+
98+
startButton.addEventListener('click', async () => {
99+
startButton.classList.add("STE-hide-button");
100+
stopButton.classList.remove("STE-hide-button");
101+
102+
// Capture the canvas element as a stream
103+
const canvasStream = canvas.captureStream(30); // 30 FPS
104+
105+
// Get the audio context from the Scratch VM
106+
const audioContext = feature.traps.vm.runtime.audioEngine.audioContext;
107+
const audioDestination = audioContext.createMediaStreamDestination();
108+
109+
if (microphoneCheckbox.checked) {
110+
// Capture the microphone audio
111+
let micStream;
112+
try {
113+
micStream = await navigator.mediaDevices.getUserMedia({ audio: true });
114+
} catch (err) {
115+
console.error("Error capturing microphone audio:", err);
116+
}
117+
118+
if (micStream) {
119+
const micSource = audioContext.createMediaStreamSource(micStream);
120+
micSource.connect(audioDestination);
121+
}
122+
}
123+
124+
// Connect the audio engine's output
125+
if (desktopSoundCheckbox.checked) {
126+
feature.traps.vm.runtime.audioEngine.inputNode.connect(audioDestination);
127+
}
128+
129+
// Combine the canvas video track and audio tracks
130+
const combinedStream = new MediaStream();
131+
canvasStream.getVideoTracks().forEach(track => combinedStream.addTrack(track));
132+
if (microphoneCheckbox.checked || desktopSoundCheckbox.checked) {
133+
audioDestination.stream.getAudioTracks().forEach(track => combinedStream.addTrack(track));
134+
}
135+
136+
mediaRecorder = new MediaRecorder(combinedStream);
137+
138+
mediaRecorder.ondataavailable = function (event) {
139+
if (event.data.size > 0) {
140+
recordedChunks.push(event.data);
141+
}
142+
};
143+
144+
mediaRecorder.onstop = function () {
145+
const blob = new Blob(recordedChunks, {
146+
type: `video/${mimeType.value}`
147+
});
148+
preview.src = URL.createObjectURL(blob);
149+
preview.controls = true;
150+
// console.log(projectTitle)
151+
preview.download = `${projectTitle.value}.${mimeType.value}`;
152+
downloadButton.removeEventListener("click", lastDownloadFunction);
153+
lastDownloadFunction = async () => {
154+
const url = URL.createObjectURL(blob);
155+
const a = document.createElement('a');
156+
a.href = url;
157+
a.download = `${projectTitle.value}.${mimeType.value}`;
158+
document.body.appendChild(a);
159+
a.click();
160+
document.body.removeChild(a);
161+
URL.revokeObjectURL(url);
162+
}
163+
downloadButton.addEventListener("click", lastDownloadFunction);
164+
recordedChunks = [];
165+
};
166+
167+
mediaRecorder.start();
168+
startButton.disabled = true;
169+
stopButton.disabled = false;
170+
});
171+
172+
stopButton.addEventListener('click', () => {
173+
mediaRecorder.stop();
174+
startButton.disabled = false;
175+
stopButton.disabled = true;
176+
177+
stopButton.classList.add("STE-hide-button");
178+
startButton.classList.remove("STE-hide-button");
179+
});
180+
}

0 commit comments

Comments
 (0)