Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Unverified Commit 73013bdf authored by Simon Chan's avatar Simon Chan
Browse files

feat(scrcpy): record screen to WebM

fixes #465
parent eaf3a7a3
Loading
Loading
Loading
Loading
+1 −0
Original line number Diff line number Diff line
@@ -36,6 +36,7 @@
        "next": "13.1.1",
        "react": "^18.2.0",
        "react-dom": "^18.2.0",
        "webm-muxer": "^1.1.0",
        "xterm": "^5.1.0",
        "xterm-addon-fit": "^0.7.0",
        "xterm-addon-search": "^0.11.0",
+34 −8
Original line number Diff line number Diff line
@@ -14,7 +14,8 @@ import { GlobalState } from "../../state";
import { Icons } from "../../utils";
import { ExternalLink } from "../external-link";
import { CommandBarSpacerItem } from "./command-bar-spacer-item";
import { Recorder, STATE } from "./state";
import { RECORD_STATE } from "./recorder";
import { STATE } from "./state";

const ITEMS = computed(() => {
    const result: ICommandBarItemProps[] = [];
@@ -36,13 +37,38 @@ const ITEMS = computed(() => {
        });
    }

    result.push({
    result.push(
        RECORD_STATE.recording
            ? {
                  key: "Record",
                  iconProps: {
                      iconName: Icons.Record,
                      style: { color: "red" },
                  },
                  // prettier-ignore
                  text: `${
                      RECORD_STATE.hours ? `${RECORD_STATE.hours}:` : ""
                  }${
                      RECORD_STATE.minutes.toString().padStart(2, "0")
                  }:${
                      RECORD_STATE.seconds.toString().padStart(2, "0")
                  }`,
                  onClick: action(() => {
                      RECORD_STATE.recorder.stop();
                      RECORD_STATE.recording = false;
                  }),
              }
            : {
                  key: "Record",
                  disabled: !STATE.running,
                  iconProps: { iconName: Icons.Record },
                  text: "Record",
        onClick: () => Recorder.start(),
    });
                  onClick: action(() => {
                      RECORD_STATE.recorder.start();
                      RECORD_STATE.recording = true;
                  }),
              }
    );

    result.push({
        key: "fullscreen",
+95 −50
Original line number Diff line number Diff line
@@ -4,7 +4,9 @@ import {
    splitH264Stream,
} from "@yume-chan/scrcpy";
import { InspectStream } from "@yume-chan/stream-extra";
import { action, makeAutoObservable, reaction } from "mobx";
import WebMMuxer from "webm-muxer";
import { saveFile } from "../../utils";

// https://ffmpeg.org/doxygen/0.11/avc_8c-source.html#l00106
function h264ConfigurationToAvcDecoderConfigurationRecord(
@@ -75,22 +77,14 @@ export class MuxerStream extends InspectStream<ScrcpyVideoStreamPacket> {
        }

        const sample = h264StreamToAvcSample(frame.data);
        this.muxer!.addVideoChunk(
            {
                byteLength: sample.byteLength,
        this.muxer!.addVideoChunkRaw(
            sample,
            frame.keyframe ? "key" : "delta",
            timestamp,
                type: frame.keyframe ? "key" : "delta",
                // Not used
                duration: null,
                copyTo: (destination) => {
                    // destination is a Uint8Array
                    (destination as Uint8Array).set(sample);
                },
            },
            {
                decoderConfig: this.configurationWritten
            this.configurationWritten
                ? undefined
                : {
                      decoderConfig: {
                          // Not used
                          codec: "",
                          description: this.avcConfiguration,
@@ -102,6 +96,7 @@ export class MuxerStream extends InspectStream<ScrcpyVideoStreamPacket> {

    constructor() {
        super((packet) => {
            try {
                if (packet.type === "configuration") {
                    this.width = packet.data.croppedWidth;
                    this.height = packet.data.croppedHeight;
@@ -126,6 +121,9 @@ export class MuxerStream extends InspectStream<ScrcpyVideoStreamPacket> {
                }

                this.appendFrame(packet);
            } catch (e) {
                console.error(e);
            }
        });
    }

@@ -146,10 +144,6 @@ export class MuxerStream extends InspectStream<ScrcpyVideoStreamPacket> {
                this.appendFrame(frame);
            }
        }

        setTimeout(() => {
            this.stop();
        }, 10000);
    }

    stop() {
@@ -158,12 +152,26 @@ export class MuxerStream extends InspectStream<ScrcpyVideoStreamPacket> {
        }

        const buffer = this.muxer.finalize()!;
        const blob = new Blob([buffer], { type: "video/webm" });
        const url = URL.createObjectURL(blob);
        const a = document.createElement("a");
        a.href = url;
        a.download = "scrcpy.webm";
        a.click();
        const now = new Date();
        const stream = saveFile(
            // prettier-ignore
            `Recording ${
                now.getFullYear()
            }-${
                (now.getMonth() + 1).toString().padStart(2, '0')
            }-${
                now.getDate().toString().padStart(2, '0')
            } ${
                now.getHours().toString().padStart(2, '0')
            }-${
                now.getMinutes().toString().padStart(2, '0')
            }-${
                now.getSeconds().toString().padStart(2, '0')
            }.webm`
        );
        const writer = stream.getWriter();
        writer.write(new Uint8Array(buffer));
        writer.close();

        this.muxer = undefined;
        this.configurationWritten = false;
@@ -171,3 +179,40 @@ export class MuxerStream extends InspectStream<ScrcpyVideoStreamPacket> {
        this.firstTimestamp = -1;
    }
}

export const RECORD_STATE = makeAutoObservable({
    recorder: new MuxerStream(),
    recording: false,
    intervalId: -1,
    hours: 0,
    minutes: 0,
    seconds: 0,
});

reaction(
    () => RECORD_STATE.recording,
    (recording) => {
        if (recording) {
            RECORD_STATE.intervalId = window.setInterval(
                action(() => {
                    RECORD_STATE.seconds += 1;
                    if (RECORD_STATE.seconds >= 60) {
                        RECORD_STATE.seconds = 0;
                        RECORD_STATE.minutes += 1;
                    }
                    if (RECORD_STATE.minutes >= 60) {
                        RECORD_STATE.minutes = 0;
                        RECORD_STATE.hours += 1;
                    }
                }),
                1000
            );
        } else {
            window.clearInterval(RECORD_STATE.intervalId);
            RECORD_STATE.intervalId = -1;
            RECORD_STATE.hours = 0;
            RECORD_STATE.minutes = 0;
            RECORD_STATE.seconds = 0;
        }
    }
);
+8 −7
Original line number Diff line number Diff line
@@ -37,7 +37,7 @@ import { GlobalState } from "../../state";
import { Icons, ProgressStream } from "../../utils";
import { DeviceViewRef } from "../device-view";
import { fetchServer } from "./fetch-server";
import { MuxerStream } from "./recorder";
import { MuxerStream, RECORD_STATE } from "./recorder";
import { SettingDefinition, Settings } from "./settings";

export interface H264Decoder extends Disposable {
@@ -59,8 +59,6 @@ interface DecoderDefinition {
    Constructor: H264DecoderConstructor;
}

export const Recorder = new MuxerStream();

export class ScrcpyPageState {
    running = false;

@@ -529,6 +527,7 @@ export class ScrcpyPageState {
                })
            );

            RECORD_STATE.recorder = new MuxerStream();
            client.videoStream
                .pipeThrough(
                    new InspectStream(
@@ -547,10 +546,7 @@ export class ScrcpyPageState {
                        })
                    )
                )
                .pipeThrough(Recorder, {
                    preventAbort: true,
                    preventClose: true,
                })
                .pipeThrough(RECORD_STATE.recorder)
                .pipeTo(decoder.writable)
                .catch(() => {});

@@ -603,6 +599,11 @@ export class ScrcpyPageState {
        this.decoder?.dispose();
        this.decoder = undefined;

        if (RECORD_STATE.recording) {
            RECORD_STATE.recorder.stop();
            RECORD_STATE.recording = false;
        }

        this.fps = 0;
        clearTimeout(this.fpsCounterIntervalId);

+14 −9
Original line number Diff line number Diff line
@@ -72,6 +72,7 @@ importers:
      react-dom: ^18.2.0
      source-map-loader: ^4.0.1
      typescript: ^4.9.4
      webm-muxer: ^1.1.0
      xterm: ^5.1.0
      xterm-addon-fit: ^0.7.0
      xterm-addon-search: ^0.11.0
@@ -103,6 +104,7 @@ importers:
      next: 13.1.1_biqbaboplfbrettd7655fr4n2y
      react: 18.2.0
      react-dom: 18.2.0_react@18.2.0
      webm-muxer: 1.1.0
      xterm: 5.1.0
      xterm-addon-fit: 0.7.0_xterm@5.1.0
      xterm-addon-search: 0.11.0_xterm@5.1.0
@@ -2284,7 +2286,7 @@ packages:
      '@docusaurus/react-loadable': 5.5.2_react@18.2.0
      '@docusaurus/types': 2.2.0_biqbaboplfbrettd7655fr4n2y
      '@types/history': 4.7.11
      '@types/react': 17.0.27
      '@types/react': 18.0.26
      '@types/react-router-config': 5.0.6
      '@types/react-router-dom': 5.3.3
      react: 18.2.0
@@ -4182,14 +4184,6 @@ packages:
      '@types/react': 18.0.26
    dev: false

  /@types/react/17.0.27:
    resolution: {integrity: sha512-zgiJwtsggVGtr53MndV7jfiUESTqrbxOcBvwfe6KS/9bzaVPCTDieTWnFNecVNx6EAaapg5xsLLWFfHHR437AA==}
    dependencies:
      '@types/prop-types': 15.7.5
      '@types/scheduler': 0.16.2
      csstype: 3.1.1
    dev: false

  /@types/react/18.0.26:
    resolution: {integrity: sha512-hCR3PJQsAIXyxhTNSiDFY//LhnMZWpNNr5etoCqx/iUfGc5gXWtQR2Phl908jVR6uPXacojQWTg4qRpkxTuGug==}
    dependencies:
@@ -4247,6 +4241,10 @@ packages:
    resolution: {integrity: sha512-cSjhgrr8g4KbPnnijAr/KJDNKa/bBa+ixYkywFRvrhvi9n1WEl7yYbtRyzE6jqNQiSxxJxoAW3STaOQwJHndaw==}
    dev: false

  /@types/wicg-file-system-access/2020.9.5:
    resolution: {integrity: sha512-UYK244awtmcUYQfs7FR8710MJcefL2WvkyHMjA8yJzxd1mo0Gfn88sRZ1Bls7hiUhA2w7ne1gpJ9T5g3G0wOyA==}
    dev: false

  /@types/ws/8.5.4:
    resolution: {integrity: sha512-zdQDHKUgcX/zBc4GrwsE/7dVdAD8JR4EuiAXiiUhhfyIJXXb2+PrGshFyeXWQPMmmZ2XxgaqclgpIC7eTXc1mg==}
    dependencies:
@@ -12545,6 +12543,13 @@ packages:
  /webidl-conversions/3.0.1:
    resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==}

  /webm-muxer/1.1.0:
    resolution: {integrity: sha512-r1xYFOKHkctRjG+Fj7D6B/2AW2aU6uL1rjvBcrj3CvgdL8qP6gB+l2Z81x+VG71vStbJJAyORfyq2blYKLr9sg==}
    dependencies:
      '@types/dom-webcodecs': 0.1.5
      '@types/wicg-file-system-access': 2020.9.5
    dev: false

  /webpack-bundle-analyzer/4.7.0:
    resolution: {integrity: sha512-j9b8ynpJS4K+zfO5GGwsAcQX4ZHpWV+yRiHDiL+bE0XHJ8NiPYLTNVQdlFYWxtpg9lfAQNlwJg16J9AJtFSXRg==}
    engines: {node: '>= 10.13.0'}
Loading