diff --git a/features/features.json b/features/features.json index 9bc58c5c..6e545b93 100644 --- a/features/features.json +++ b/features/features.json @@ -1,4 +1,9 @@ [ + { + "version": 2, + "id": "video-recorder", + "versionAdded": "v4.0.0" + }, { "version": 2, "id": "studio-creation-date", diff --git a/features/video-recorder/data.json b/features/video-recorder/data.json new file mode 100644 index 00000000..42d327f1 --- /dev/null +++ b/features/video-recorder/data.json @@ -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" + } + ] +} diff --git a/features/video-recorder/popup.html b/features/video-recorder/popup.html new file mode 100644 index 00000000..ef9c057c --- /dev/null +++ b/features/video-recorder/popup.html @@ -0,0 +1,50 @@ +
+ +
\ No newline at end of file diff --git a/features/video-recorder/style.css b/features/video-recorder/style.css new file mode 100644 index 00000000..70fa6eac --- /dev/null +++ b/features/video-recorder/style.css @@ -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%; +} + diff --git a/features/video-recorder/video-recorder.js b/features/video-recorder/video-recorder.js new file mode 100644 index 00000000..ccba54cd --- /dev/null +++ b/features/video-recorder/video-recorder.js @@ -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"); + }); +}