Skip to content
5 changes: 5 additions & 0 deletions features/features.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
[
{
"version": 2,
"id": "video-recorder",
"versionAdded": "v4.0.0"
},
{
"version": 2,
"id": "studio-creation-date",
Expand Down
34 changes: 34 additions & 0 deletions features/video-recorder/data.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
{
"title": "Record Stage",
"description": "Allows you to record the stage for projects while in the editor or on the project page.",
"credits": [
{
"username": "blob2763",
"url": "https://blob2763.is-a.dev/"
},
{
"username": "stio_studio",
"url": "https://stio.studio/"
}
],
"type": ["Editor"],
"tags": ["New", "Featured"],
"scripts": [
{
"file": "video-recorder.js",
"runOn": "/projects/*"
}
],
"styles": [
{
"file": "style.css",
"runOn": "/projects/*"
}
],
"resources": [
{
"name": "popup-html",
"path": "/popup.html"
}
]
}
50 changes: 50 additions & 0 deletions features/video-recorder/popup.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
<div class="ReactModalPortal STE-ReactModalPortal">
<div class="ReactModal__Overlay ReactModal__Overlay--after-open modal_modal-overlay_1Lcbx">
<div
class="ReactModal__Content ReactModal__Content--after-open modal_modal-content_1h3ll prompt_modal-content_1BfWj"
tabindex="-1" role="dialog" aria-label="Rename Variable">
<div class="box_box_2jjDp" dir="ltr" style="flex-direction: column; flex-grow: 1">
<div class="modal_header_1h7ps">
<div class="modal_header-item_2zQTd modal_header-item-title_tLOU5">
Video Recording
</div>
<div class="modal_header-item_2zQTd modal_header-item-close_2XDeL">
<div aria-label="Close" class="close-button_close-button_lOp2G close-button_large_2oadS" role="button"
tabindex="0">
<img class="close-button_close-icon_HBCuO"
src="data:image/svg+xml;base64,PHN2ZyBpZD0iTGF5ZXJfMSIgZGF0YS1uYW1lPSJMYXllciAxIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA3LjQ4IDcuNDgiPjxkZWZzPjxzdHlsZT4uY2xzLTF7ZmlsbDpub25lO3N0cm9rZTojZmZmO3N0cm9rZS1saW5lY2FwOnJvdW5kO3N0cm9rZS1saW5lam9pbjpyb3VuZDtzdHJva2Utd2lkdGg6MnB4O308L3N0eWxlPjwvZGVmcz48dGl0bGU+aWNvbi0tYWRkPC90aXRsZT48bGluZSBjbGFzcz0iY2xzLTEiIHgxPSIzLjc0IiB5MT0iNi40OCIgeDI9IjMuNzQiIHkyPSIxIi8+PGxpbmUgY2xhc3M9ImNscy0xIiB4MT0iMSIgeTE9IjMuNzQiIHgyPSI2LjQ4IiB5Mj0iMy43NCIvPjwvc3ZnPg==" />
</div>
</div>
</div>
<div class="prompt_body_18Z-I box_box_2jjDp">
<!-- <div class="prompt_label_tWjYZ box_box_2jjDp"></div>
<div class="box_box_2jjDp"><input class="prompt_variable-name-text-input_1iu8-"
name="Rename all &quot;box size&quot; variables to:" value="box size"></div> -->
<div class="prompt_button-row_3Wc5Z box_box_2jjDp STE-left-text">
<button class="stopButton STE-hide-button">
<span>Stop Recording</span>
</button>
<button class="prompt_ok-button_3QFdD startButton scratchtoolsTag">
<span>Start Recording</span>
</button>
<br /><br />
Microphone: <input type="checkbox" class="microphoneCheckbox">
<br />
Desktop sound: <input type="checkbox" class="desktopSoundCheckbox" checked>
<br />
<select class="video-format-select">
<option value="mp4">mp4</option>
<option value="webm">webm</option>
</select>
<br /><br />
Preview: <br />
<video class="STE-recorded-video"></video>
<button class="prompt_ok-button_3QFdD downloadButton scratchtoolsTag">
<span>Download Video</span>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
22 changes: 22 additions & 0 deletions features/video-recorder/style.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
.STE-ReactModalPortal .STE-recorded-video {
width: 100%;
height: 100%;
border: 10px solid #ccc;
border-radius: 10px;
}

.STE-ReactModalPortal .STE-hide-button {
display: none;
}

.STE-ReactModalPortal .STE-left-text {
text-align: left;
}

.STE-ReactModalPortal .stopButton,
.STE-ReactModalPortal .startButton,
.STE-ReactModalPortal .downloadButton,
.STE-ReactModalPortal .video-format-select {
width: 100%;
}

180 changes: 180 additions & 0 deletions features/video-recorder/video-recorder.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
export default async function ({ feature, console }) {
await new Promise(async (resolve, reject) => {
(async () => {
const rem = await ScratchTools.waitForElement(".preview .inner .flex-row.action-buttons")
resolve(rem);
})();
(async () => {
const rem = await ScratchTools.waitForElement(".menu-bar_account-info-group_MeJZP")
resolve(rem);
})();
})

let openPopup = document.createElement("button");

ScratchTools.waitForElements(".preview .inner .flex-row.action-buttons", async function (row) {
if (row.querySelector(".ste-video-recorder-open")) return;
openPopup = document.createElement("button");
openPopup.className = "button action-button ste-video-recorder-open";
openPopup.textContent = "Record Video";
row.insertAdjacentElement("afterbegin", openPopup);
openPopup.addEventListener('click', () => {
document.body.append(popup)
})
})

ScratchTools.waitForElements(".menu-bar_account-info-group_MeJZP", async function (row) {
if (row.querySelector(".ste-video-recorder-open")) return;
openPopup = document.createElement("div");
openPopup.className = "menu-bar_menu-bar-item_oLDa- menu-bar_hoverable_c6WFB";
openPopup.style.padding = "0 0.75rem"
let rem = document.createElement("div");
rem.textContent = "Record Video";
openPopup.append(rem);
row.insertAdjacentElement("afterbegin", openPopup);
openPopup.addEventListener('click', () => {
document.body.append(popup)
})
})

let popup = document.createElement("div");
popup.insertAdjacentHTML("afterbegin", await (await fetch(feature.self.getResource("popup-html"))).text())
popup = popup.querySelector("div.ReactModalPortal")

let stopButton = popup.querySelector(".stopButton");
let startButton = popup.querySelector(".startButton");
let closeButton = popup.querySelector(".close-button_close-button_lOp2G");
let downloadButton = popup.querySelector(".downloadButton");
let lastDownloadFunction = () => { }
let mimeType = popup.querySelector("select");
let microphoneCheckbox = popup.querySelector(".microphoneCheckbox");
let desktopSoundCheckbox = popup.querySelector(".desktopSoundCheckbox");

closeButton.addEventListener('click', () => {
document.querySelector(".STE-ReactModalPortal").remove()
})
addEventListener("keydown", (e) => {
if (e.key === "Escape") {
document.querySelector(".STE-ReactModalPortal").remove()
}
})

const canvas = feature.traps.vm.renderer.canvas;
const preview = popup.querySelector("video")

await new Promise(async (resolve, reject) => {
(async () => {
const rem = await ScratchTools.waitForElement("input.inplace-input")
resolve(rem);
})();
(async () => {
const rem = await ScratchTools.waitForElement("input.project-title-input_title-field_en5Gd")
resolve(rem);
})();
(async () => {
const rem = await ScratchTools.waitForElement(".project-title")
resolve(rem);
})();
})

let projectTitle = document.querySelector("input.inplace-input") || document.querySelector("input.project-title-input_title-field_en5Gd") || document.querySelector(".project-title");

ScratchTools.waitForElements("input.inplace-input", async function (_projectTitle) {
projectTitle = _projectTitle
})

ScratchTools.waitForElements("input.project-title-input_title-field_en5Gd", async function (_projectTitle) {
projectTitle = _projectTitle
})

ScratchTools.waitForElements(".project-title", async function (_projectTitle) {
projectTitle = _projectTitle
})


let mediaRecorder;
let recordedChunks = [];

startButton.addEventListener('click', async () => {
startButton.classList.add("STE-hide-button");
stopButton.classList.remove("STE-hide-button");

// Capture the canvas element as a stream
const canvasStream = canvas.captureStream(30); // 30 FPS

// Get the audio context from the Scratch VM
const audioContext = feature.traps.vm.runtime.audioEngine.audioContext;
const audioDestination = audioContext.createMediaStreamDestination();

if (microphoneCheckbox.checked) {
// Capture the microphone audio
let micStream;
try {
micStream = await navigator.mediaDevices.getUserMedia({ audio: true });
} catch (err) {
console.error("Error capturing microphone audio:", err);
}

if (micStream) {
const micSource = audioContext.createMediaStreamSource(micStream);
micSource.connect(audioDestination);
}
}

// Connect the audio engine's output
if (desktopSoundCheckbox.checked) {
feature.traps.vm.runtime.audioEngine.inputNode.connect(audioDestination);
}

// Combine the canvas video track and audio tracks
const combinedStream = new MediaStream();
canvasStream.getVideoTracks().forEach(track => combinedStream.addTrack(track));
if (microphoneCheckbox.checked || desktopSoundCheckbox.checked) {
audioDestination.stream.getAudioTracks().forEach(track => combinedStream.addTrack(track));
}

mediaRecorder = new MediaRecorder(combinedStream);

mediaRecorder.ondataavailable = function (event) {
if (event.data.size > 0) {
recordedChunks.push(event.data);
}
};

mediaRecorder.onstop = function () {
const blob = new Blob(recordedChunks, {
type: `video/${mimeType.value}`
});
preview.src = URL.createObjectURL(blob);
preview.controls = true;
// console.log(projectTitle)
preview.download = `${projectTitle.value}.${mimeType.value}`;
downloadButton.removeEventListener("click", lastDownloadFunction);
lastDownloadFunction = async () => {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${projectTitle.value}.${mimeType.value}`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
downloadButton.addEventListener("click", lastDownloadFunction);
recordedChunks = [];
};

mediaRecorder.start();
startButton.disabled = true;
stopButton.disabled = false;
});

stopButton.addEventListener('click', () => {
mediaRecorder.stop();
startButton.disabled = false;
stopButton.disabled = true;

stopButton.classList.add("STE-hide-button");
startButton.classList.remove("STE-hide-button");
});
}