diff --git a/doc/CHANGES.txt b/doc/CHANGES.txt index 38a61aaac5..4968c1bf75 100644 --- a/doc/CHANGES.txt +++ b/doc/CHANGES.txt @@ -35,6 +35,9 @@ Changelog - cleaned up bestiary window layout - performance optimization: sound events too far away to be heard are not loaded +*alpha features* +- built-in support added for capturing video of web client viewport + 1.47 diff --git a/src/js/stendhal/SlashActionRepo.ts b/src/js/stendhal/SlashActionRepo.ts index 7fb7bc8a2f..97b9ffd77b 100644 --- a/src/js/stendhal/SlashActionRepo.ts +++ b/src/js/stendhal/SlashActionRepo.ts @@ -19,6 +19,7 @@ import { DebugAction } from "./action/DebugAction"; import { OpenWebsiteAction } from "./action/OpenWebsiteAction"; import { ProgressStatusAction } from "./action/ProgressStatusAction"; import { ReTellAction } from "./action/ReTellAction"; +import { ScreenCaptureAction } from "./action/ScreenCaptureAction"; import { SettingsAction } from "./action/SettingsAction"; import { SlashActionImpl } from "./action/SlashAction"; import { TellAction } from "./action/TellAction"; @@ -29,6 +30,7 @@ import { UIComponentEnum } from "./ui/UIComponentEnum"; import { ChatLogComponent } from "./ui/component/ChatLogComponent"; import { Chat } from "./util/Chat"; +import { Debug } from "./util/Debug"; /** @@ -132,6 +134,7 @@ export class SlashActionRepo { "TOOLS": [ "progressstatus", "screenshot", + //"screencap", "atlas", "beginnersguide" ], @@ -220,6 +223,11 @@ export class SlashActionRepo { {type: "group", sparams: "status"} ] }; + + if (Debug.isActive("screencap")) { + grouping["TOOLS"].push("screencap"); + } + return { info: [ "For a detailed reference, visit #https://stendhalgame.org/wiki/Stendhal_Manual", @@ -1122,6 +1130,8 @@ export class SlashActionRepo { } }; + "screencap" = new ScreenCaptureAction(); + "sentence": SlashActionImpl = { execute: (type: string, params: string[], remainder: string): boolean => { if (params == null) { diff --git a/src/js/stendhal/action/DebugAction.ts b/src/js/stendhal/action/DebugAction.ts index 3d93fc500b..148a303d82 100644 --- a/src/js/stendhal/action/DebugAction.ts +++ b/src/js/stendhal/action/DebugAction.ts @@ -51,6 +51,10 @@ export class DebugAction extends SlashAction { : "disabled")); } else if (params[0] === "touch") { Chat.log("client", "Touch debugging " + (Debug.toggle("touch") ? "enabled" : "disabled")); + } else if (params[0] === "screencap") { + Debug.setActive("screencap", !Debug.isActive("screencap")); + Chat.log("client", "Screen capture debugging " + (Debug.isActive("screencap") ? "enabled" + : "disabled")); } return true; } diff --git a/src/js/stendhal/action/ScreenCaptureAction.ts b/src/js/stendhal/action/ScreenCaptureAction.ts new file mode 100644 index 0000000000..7c8fc9d3c2 --- /dev/null +++ b/src/js/stendhal/action/ScreenCaptureAction.ts @@ -0,0 +1,41 @@ +/*************************************************************************** + * Copyright © 2024 - Faiumoni e. V. * + *************************************************************************** + *************************************************************************** + * * + * This program is free software; you can redistribute it and/or modify * + * it under the terms of the GNU Affero General Public License as * + * published by the Free Software Foundation; either version 3 of the * + * License, or (at your option) any later version. * + * * + ***************************************************************************/ + +import { SlashAction } from "./SlashAction"; + +import { ScreenCapture } from "../util/ScreenCapture"; + +declare var stendhal: any; + + +export class ScreenCaptureAction extends SlashAction { + + readonly minParams = 0; + readonly maxParams = 0; + + override desc = "Start or stop capturing video of the client viewport"; + + private recorder?: ScreenCapture; + + + execute(type: string, params: string[], remainder: string): boolean { + if (this.recorder && ScreenCapture.isActive()) { + // currently recording + this.recorder.stop(); + this.recorder = undefined; + return true; + } + this.recorder = new ScreenCapture(); + this.recorder.start(stendhal.ui.viewport.getElement() as HTMLCanvasElement); + return true; + } +} diff --git a/src/js/stendhal/ui/dialog/ApplicationMenuDialog.ts b/src/js/stendhal/ui/dialog/ApplicationMenuDialog.ts index 01b04379c6..4cbaa12d5d 100644 --- a/src/js/stendhal/ui/dialog/ApplicationMenuDialog.ts +++ b/src/js/stendhal/ui/dialog/ApplicationMenuDialog.ts @@ -14,6 +14,16 @@ declare var stendhal: any; import { DialogContentComponent } from "../toolkit/DialogContentComponent"; import { singletons } from "../../SingletonRepo"; +import { Debug } from "../../util/Debug"; +import { ScreenCapture } from "../../util/ScreenCapture"; + + +interface MenuAction { + title: string, + action: string, + alt?: string, + condition?: Function +} export class ApplicationMenuDialog extends DialogContentComponent { @@ -37,7 +47,7 @@ export class ApplicationMenuDialog extends DialogContentComponent { title: "Logout", action: "logout" } - ] + ] as MenuAction[] }, { title: "Tools", @@ -46,11 +56,19 @@ export class ApplicationMenuDialog extends DialogContentComponent { title: "Take Screenshot", action: "screenshot", }, + /* + { + title: "Capture Video", + alt: "Stop Capture", + condition: ScreenCapture.isActive, + action: "screencap" + }, + */ { title: "Settings", action: "settings", } - ] + ] as MenuAction[] }, { title: "Commands", @@ -71,7 +89,7 @@ export class ApplicationMenuDialog extends DialogContentComponent { title: "Travel Log", action: "progressstatus", } - ] + ] as MenuAction[] }, { title: "Help", @@ -100,18 +118,32 @@ export class ApplicationMenuDialog extends DialogContentComponent { title: "About", action: "about", } - ] + ] as MenuAction[] }, ] constructor() { super("applicationmenudialog-template"); + if (Debug.isActive("screencap")) { + this.actions[1].children.push({ + title: "Capture Video", + alt: "Stop Capture", + condition: ScreenCapture.isActive, + action: "screencap" + }); + } + var content = ""; for (var i = 0; i < this.actions.length; i++) { content += "

" + stendhal.ui.html.esc(this.actions[i].title) + "

" for (var j = 0; j < this.actions[i].children.length; j++) { - content += "
"; + const action = this.actions[i].children[j]; + let title = action.title; + if (action.alt && action.condition && action.condition()) { + title = action.alt; + } + content += "
"; } content += "
"; } diff --git a/src/js/stendhal/util/DownloadUtil.ts b/src/js/stendhal/util/DownloadUtil.ts index b6d5a47718..776ecbb415 100644 --- a/src/js/stendhal/util/DownloadUtil.ts +++ b/src/js/stendhal/util/DownloadUtil.ts @@ -26,7 +26,7 @@ export class DownloadUtil { * @return {string} * Timestamp formatted string (yyyy-mm-dd_HH.MM.SS). */ - private static timestamp(): string { + static timestamp(): string { const d = new Date(); const ts = { yyyy: "" + d.getFullYear(), diff --git a/src/js/stendhal/util/ScreenCapture.ts b/src/js/stendhal/util/ScreenCapture.ts new file mode 100644 index 0000000000..ed100a8527 --- /dev/null +++ b/src/js/stendhal/util/ScreenCapture.ts @@ -0,0 +1,251 @@ +/*************************************************************************** + * Copyright © 2024 - Faiumoni e. V. * + *************************************************************************** + *************************************************************************** + * * + * This program is free software; you can redistribute it and/or modify * + * it under the terms of the GNU Affero General Public License as * + * published by the Free Software Foundation; either version 3 of the * + * License, or (at your option) any later version. * + * * + ***************************************************************************/ + +import { Chat } from "./Chat"; +import { Debug } from "./Debug"; +import { DownloadUtil } from "./DownloadUtil"; + + +/** + * Interface defining video attributes such as MIME type, framerate, & encoders. + */ +interface VideoDefinition { + mime: string, // MIME type + container: string, // media container + framerate: number, // video framerate (frames per second) + codec?: string, // video encoder + bitrate?: number, // video bitrate (bits per second) + acodec?: string, // audio encoder + abitrate?: number // audio bitrate (bits per second) +} + + +/** + * Manages capturing video of the client. + * + * TODO: + * - handle audio capture + * - allow attributes such as framerate, bitrate, etc. to be customized + */ +export class ScreenCapture { + + /** Property denoting state of activity. */ + private static active = false; + /** Video recorder. */ + private capture!: MediaRecorder; + /** Recorded data. */ + private readonly chunks: any[]; + /** Video capture definition. */ + private readonly def?: VideoDefinition; + + + constructor() { + this.chunks = []; + this.def = this.getDefinition(); + } + + /** + * Begins recording. + * + * @param {HTMLCanvasElement} canvas + * Canvas element to record. + * @param {AudioContext=} audio + * Sound manager from which to record audio. + */ + start(canvas: HTMLCanvasElement, audio?: AudioContext) { + if (!Debug.isActive("screencap")) { + Chat.log("client", "Screen capture debugging is disabled"); + return; + } + + if (!this.def) { + Chat.log("error", "No suitable video codec available"); + return; + } + if (!this.def.acodec) { + Chat.log("warning", "Audio capture not supported"); + } + Chat.log("client", "Starting video capture ..."); + Chat.log("client", "  MIME type: " + this.def.mime); + Chat.log("client", "  framerate: " + this.def.framerate.toFixed(2) + " frames/sec"); + if (this.def.codec) { + Chat.log("client", "  video encoder: " + this.def.codec + " (" + this.def.bitrate + + " bits/sec)"); + if (this.def.acodec) { + Chat.log("client", "  audio encoder: " + this.def.acodec + " (" + + this.def.abitrate + " bits/sec)"); + } + } + + // FIXME: framerate not working, may need to use library for encoding + this.capture = new MediaRecorder(canvas.captureStream(this.def.framerate), { + mimeType: this.getCodec(this.def), + videoBitsPerSecond: this.def.bitrate, + audioBitsPerSecond: this.def.abitrate + }); + // listener to store captured data + this.capture.ondataavailable = (e: any) => { + this.chunks.push(e.data); + }; + // listener to create download after capture ends + this.capture.onstop = () => { + this.createDownload(); + }; + ScreenCapture.active = true; + this.capture.start(); + } + + /** + * Stops recording & creates data download. + */ + stop() { + ScreenCapture.active = false; + if (!this.capture) { + return; + } + this.capture.stop(); + } + + /** + * Creates a video definition based on available media containers & codecs. + * + * @returns {VideoDefinition|undefined} + * Supported video definiton or `undefined`. + */ + private getDefinition(): VideoDefinition|undefined { + const containers = ["mp4", "webm", "ogg"]; + const codecs = ["avc1", "h264", "av1", "vp9", "vp8"]; + const acodecs = ["aac", "vorbis", "mpeg"]; + + for (const container of containers) { + for (const codec of codecs) { + for (const acodec of acodecs) { + const def = this.buildDefinition(container, codec, acodec); + if (this.checkDefinition(def)) { + return def; + } + } + } + } + // fallback to video only + for (const container of containers) { + for (const codec of codecs) { + const def = this.buildDefinition(container, codec); + if (this.checkDefinition(def)) { + return def; + } + } + } + // fallback to default encoder + for (const container of containers) { + const def = this.buildDefinition(container); + if (this.checkDefinition(def)) { + return def; + } + } + // no suitable codecs available + return undefined; + } + + /** + * Builds a video definition based on parameters. + * + * @param {string} container + * Container type (mp4, webm, or ogg). + * @param {string=} codec + * Video codec (avc1, h264, vp9, etc.). + * @param {string=} acodec + * Audio codec (aac, vorbis, mpeg, etc.). + */ + private buildDefinition(container: string, codec?: string, acodec?: string): VideoDefinition { + const def: any = { + mime: "video/" + container, + container: container, + framerate: 30000 / 1001, // NTSC standard (29.97) + bitrate: 1500000 // 1.5 Mbit/sec + }; + if (codec) { + def.codec = codec; + if (acodec) { + def.acodec = acodec; + def.abitrate = 96000; // 96 Kbit/sec + } + } + return def; + } + + /** + * Checks if a video definition is supported by Web API & browser. + * + * @param {VideoDefinition} def + * Video definition with MIME, container, codec, etc. information. + * @returns {boolean} + * `true` if the browser supports encoding with definition's codecs. + */ + private checkDefinition(def: VideoDefinition): boolean { + return MediaRecorder.isTypeSupported(this.getCodec(def)); + } + + /** + * Converts video definition to codec string formatted for use with Web API. + * + * @param {VideoDefinition} def + * Video definition with MIME, container, codec, etc. information. + * @returns {string} + * String formatted as "[;codecs=[,