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 += "
"
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=[,