diff --git a/extensions/Lily/Video.js b/extensions/Lily/Video.js new file mode 100644 index 0000000000..307e89f2ec --- /dev/null +++ b/extensions/Lily/Video.js @@ -0,0 +1,491 @@ +// Name: Video +// ID: lmsVideo +// Description: Play videos from URLs. +// By: LilyMakesThings + +// Attribution is not required, but greatly appreciated. + +(function (Scratch) { + "use strict"; + + const vm = Scratch.vm; + const runtime = vm.runtime; + const renderer = vm.renderer; + const Cast = Scratch.Cast; + + const BitmapSkin = runtime.renderer.exports.BitmapSkin; + class VideoSkin extends BitmapSkin { + constructor(id, renderer, videoName, videoSrc) { + super(id, renderer); + + /** @type {string} */ + this.videoName = videoName; + + /** @type {string} */ + this.videoSrc = videoSrc; + + this.videoError = false; + + this.readyPromise = new Promise((resolve) => { + this.readyCallback = resolve; + }); + + this.videoElement = document.createElement("video"); + // Need to set non-zero dimensions, otherwise scratch-render thinks this is an empty image + this.videoElement.width = 1; + this.videoElement.height = 1; + this.videoElement.crossOrigin = "anonymous"; + this.videoElement.onloadeddata = () => { + // First frame loaded + this.readyCallback(); + this.markVideoDirty(); + }; + this.videoElement.onerror = () => { + this.videoError = true; + this.readyCallback(); + this.markVideoDirty(); + }; + this.videoElement.src = videoSrc; + this.videoElement.currentTime = 0; + + this.videoDirty = true; + + this.reuploadVideo(); + } + + reuploadVideo() { + this.videoDirty = false; + if (this.videoError) { + // Draw an image that looks similar to Scratch's normal costume loading errors + const canvas = document.createElement("canvas"); + canvas.width = this.videoElement.videoWidth || 128; + canvas.height = this.videoElement.videoHeight || 128; + const ctx = canvas.getContext("2d"); + + if (ctx) { + ctx.fillStyle = "#cccccc"; + ctx.fillRect(0, 0, canvas.width, canvas.height); + + const fontSize = Math.min(canvas.width, canvas.height); + ctx.fillStyle = "#000000"; + ctx.font = `${fontSize}px serif`; + ctx.textBaseline = "middle"; + ctx.textAlign = "center"; + ctx.fillText("?", canvas.width / 2, canvas.height / 2); + } else { + // guess we can't draw the error then + } + + this.setBitmap(canvas); + } else { + this.setBitmap(this.videoElement); + } + } + + markVideoDirty() { + this.videoDirty = true; + this.emitWasAltered(); + } + + get size() { + if (this.videoDirty) { + this.reuploadVideo(); + } + return super.size; + } + + getTexture(scale) { + if (this.videoDirty) { + this.reuploadVideo(); + } + return super.getTexture(scale); + } + + dispose() { + super.dispose(); + this.videoElement.pause(); + } + } + + class Video { + constructor() { + /** @type {Record} */ + this.videos = Object.create(null); + + runtime.on("PROJECT_STOP_ALL", () => this.resetEverything()); + runtime.on("PROJECT_START", () => this.resetEverything()); + + runtime.on("BEFORE_EXECUTE", () => { + for (const skin of renderer._allSkins) { + if (skin instanceof VideoSkin && !skin.videoElement.paused) { + skin.markVideoDirty(); + } + } + }); + } + + getInfo() { + return { + id: "lmsVideo", + color1: "#557882", + name: "Video", + blocks: [ + { + opcode: "loadVideoURL", + blockType: Scratch.BlockType.COMMAND, + text: "load video from URL [URL] as [NAME]", + arguments: { + URL: { + type: Scratch.ArgumentType.STRING, + defaultValue: "https://extensions.turbowarp.org/dango.mp4", + }, + NAME: { + type: Scratch.ArgumentType.STRING, + defaultValue: "my video", + }, + }, + }, + { + opcode: "deleteVideoURL", + blockType: Scratch.BlockType.COMMAND, + text: "delete video [NAME]", + arguments: { + NAME: { + type: Scratch.ArgumentType.STRING, + defaultValue: "my video", + }, + }, + }, + { + opcode: "getLoadedVideos", + blockType: Scratch.BlockType.REPORTER, + text: "loaded videos", + }, + "---", + { + opcode: "showVideo", + blockType: Scratch.BlockType.COMMAND, + text: "show video [NAME] on [TARGET]", + arguments: { + TARGET: { + type: Scratch.ArgumentType.STRING, + menu: "targets", + }, + NAME: { + type: Scratch.ArgumentType.STRING, + defaultValue: "my video", + }, + }, + }, + { + opcode: "stopShowingVideo", + blockType: Scratch.BlockType.COMMAND, + text: "stop showing video on [TARGET]", + arguments: { + TARGET: { + type: Scratch.ArgumentType.STRING, + menu: "targets", + }, + NAME: { + type: Scratch.ArgumentType.STRING, + defaultValue: "my video", + }, + }, + }, + { + opcode: "getCurrentVideo", + blockType: Scratch.BlockType.REPORTER, + text: "current video on [TARGET]", + arguments: { + TARGET: { + type: Scratch.ArgumentType.STRING, + menu: "targets", + }, + }, + }, + "---", + { + opcode: "startVideo", + blockType: Scratch.BlockType.COMMAND, + text: "start video [NAME] at [DURATION] seconds", + arguments: { + NAME: { + type: Scratch.ArgumentType.STRING, + defaultValue: "my video", + }, + DURATION: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 0, + }, + }, + }, + { + opcode: "getAttribute", + blockType: Scratch.BlockType.REPORTER, + text: "[ATTRIBUTE] of video [NAME]", + arguments: { + ATTRIBUTE: { + type: Scratch.ArgumentType.STRING, + menu: "attribute", + }, + NAME: { + type: Scratch.ArgumentType.STRING, + defaultValue: "my video", + }, + }, + }, + "---", + { + opcode: "pause", + blockType: Scratch.BlockType.COMMAND, + text: "pause video [NAME]", + arguments: { + NAME: { + type: Scratch.ArgumentType.STRING, + defaultValue: "my video", + }, + }, + }, + { + opcode: "resume", + blockType: Scratch.BlockType.COMMAND, + text: "resume video [NAME]", + arguments: { + NAME: { + type: Scratch.ArgumentType.STRING, + defaultValue: "my video", + }, + }, + }, + { + opcode: "getState", + blockType: Scratch.BlockType.BOOLEAN, + text: "video [NAME] is [STATE]?", + arguments: { + NAME: { + type: Scratch.ArgumentType.STRING, + defaultValue: "my video", + }, + STATE: { + type: Scratch.ArgumentType.STRING, + menu: "state", + }, + }, + }, + "---", + { + opcode: "setVolume", + blockType: Scratch.BlockType.COMMAND, + text: "set volume of video [NAME] to [VALUE]", + arguments: { + NAME: { + type: Scratch.ArgumentType.STRING, + defaultValue: "my video", + }, + VALUE: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 100, + }, + }, + }, + ], + menus: { + targets: { + acceptReporters: true, + items: "_getTargets", + }, + state: { + acceptReporters: true, + items: ["playing", "paused"], + }, + attribute: { + acceptReporters: false, + items: ["current time", "duration", "volume", "width", "height"], + }, + }, + }; + } + + resetEverything() { + for (const { videoElement } of Object.values(this.videos)) { + videoElement.pause(); + videoElement.currentTime = 0; + } + + for (const target of runtime.targets) { + const drawable = renderer._allDrawables[target.drawableID]; + if (drawable.skin instanceof VideoSkin) { + target.setCostume(target.currentCostume); + } + } + } + + async loadVideoURL(args) { + // Always delete the old video with the same name, if it exists. + this.deleteVideoURL(args); + + const videoName = Cast.toString(args.NAME); + const url = Cast.toString(args.URL); + + if ( + url.startsWith("https://www.youtube.com/") || + url.startsWith("https://youtube.com/") + ) { + alert( + [ + "The video extension does not support YouTube links.", + "You can use the Iframe extension instead.", + ].join("\n\n") + ); + return; + } + + if (!(await Scratch.canFetch(url))) return; + + const skinId = renderer._nextSkinId++; + const skin = new VideoSkin(skinId, renderer, videoName, url); + renderer._allSkins[skinId] = skin; + this.videos[videoName] = skin; + + return skin.readyPromise; + } + + deleteVideoURL(args) { + const videoName = Cast.toString(args.NAME); + const videoSkin = this.videos[videoName]; + if (!videoSkin) return; + + for (const target of runtime.targets) { + const drawable = renderer._allDrawables[target.drawableID]; + if (drawable && drawable.skin === videoSkin) { + target.setCostume(target.currentCostume); + } + } + + renderer.destroySkin(videoSkin.id); + Reflect.deleteProperty(this.videos, videoName); + } + + getLoadedVideos() { + return JSON.stringify(Object.keys(this.videos)); + } + + showVideo(args, util) { + const targetName = Cast.toString(args.TARGET); + const videoName = Cast.toString(args.NAME); + const target = this._getTargetFromMenu(targetName, util); + const videoSkin = this.videos[videoName]; + if (!target || !videoSkin) return; + + vm.renderer.updateDrawableSkinId(target.drawableID, videoSkin._id); + } + + stopShowingVideo(args, util) { + const targetName = Cast.toString(args.TARGET); + const target = this._getTargetFromMenu(targetName, util); + if (!target) return; + + target.setCostume(target.currentCostume); + } + + getCurrentVideo(args, util) { + const targetName = Cast.toString(args.TARGET); + const target = this._getTargetFromMenu(targetName, util); + if (!target) return; + + const drawable = renderer._allDrawables[target.drawableID]; + const skin = drawable && drawable.skin; + return skin instanceof VideoSkin ? skin.videoName : ""; + } + + startVideo(args) { + const videoName = Cast.toString(args.NAME); + const duration = Cast.toNumber(args.DURATION); + const videoSkin = this.videos[videoName]; + if (!videoSkin) return; + + videoSkin.videoElement.play(); + videoSkin.videoElement.currentTime = duration; + videoSkin.markVideoDirty(); + } + + getAttribute(args) { + const videoName = Cast.toString(args.NAME); + const videoSkin = this.videos[videoName]; + if (!videoSkin) return 0; + + switch (args.ATTRIBUTE) { + case "current time": + return videoSkin.videoElement.currentTime; + case "duration": + return videoSkin.videoElement.duration; + case "volume": + return videoSkin.videoElement.volume * 100; + case "width": + return videoSkin.size[0]; + case "height": + return videoSkin.size[1]; + default: + return 0; + } + } + + pause(args) { + const videoName = Cast.toString(args.NAME); + const videoSkin = this.videos[videoName]; + if (!videoSkin) return; + + videoSkin.videoElement.pause(); + videoSkin.markVideoDirty(); + } + + resume(args) { + const videoName = Cast.toString(args.NAME); + const videoSkin = this.videos[videoName]; + if (!videoSkin) return; + + videoSkin.videoElement.play(); + videoSkin.markVideoDirty(); + } + + getState(args) { + const videoName = Cast.toString(args.NAME); + const videoSkin = this.videos[videoName]; + if (!videoSkin) return args.STATE === "paused"; + + return args.STATE == "playing" + ? !videoSkin.videoElement.paused + : videoSkin.videoElement.paused; + } + + setVolume(args) { + const videoName = Cast.toString(args.NAME); + const value = Cast.toNumber(args.VALUE); + const videoSkin = this.videos[videoName]; + if (!videoSkin) return; + + videoSkin.videoElement.volume = value / 100; + } + + /** @returns {VM.Target|undefined} */ + _getTargetFromMenu(targetName, util) { + if (targetName === "_myself_") return util.target; + if (targetName === "_stage_") return runtime.getTargetForStage(); + return Scratch.vm.runtime.getSpriteTargetByName(targetName); + } + + _getTargets() { + let spriteNames = [ + { text: "myself", value: "_myself_" }, + { text: "Stage", value: "_stage_" }, + ]; + const targets = Scratch.vm.runtime.targets + .filter((target) => target.isOriginal && !target.isStage) + .map((target) => target.getName()); + spriteNames = spriteNames.concat(targets); + return spriteNames; + } + } + + Scratch.extensions.register(new Video()); +})(Scratch); diff --git a/extensions/extensions.json b/extensions/extensions.json index 2467cede1b..2781ff7955 100644 --- a/extensions/extensions.json +++ b/extensions/extensions.json @@ -15,6 +15,7 @@ "Skyhigh173/bigint", "utilities", "sound", + "Lily/Video", "iframe", "Xeltalliv/clippingblending", "clipboard", diff --git a/images/Lily/Video.svg b/images/Lily/Video.svg new file mode 100644 index 0000000000..bd9a2f932b --- /dev/null +++ b/images/Lily/Video.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/images/README.md b/images/README.md index a67e0ceca9..069ffb31ca 100644 --- a/images/README.md +++ b/images/README.md @@ -287,3 +287,6 @@ All images in this folder are licensed under the [GNU General Public License ver ## iframe.svg - Created by [HamsterCreativity](https://github.com/HamsterCreativity) in https://github.com/TurboWarp/extensions/issues/90#issuecomment-1694716263 + +## Lily/Video.svg + - Created by [@LilyMakesThings](https://github.com/LilyMakesThings) in https://github.com/TurboWarp/extensions/pull/656 diff --git a/website/dango.mp4 b/website/dango.mp4 new file mode 100644 index 0000000000..f5dbfd4738 Binary files /dev/null and b/website/dango.mp4 differ