From a2bb28a574b28bbe666ed7f6d2821b16b435bd70 Mon Sep 17 00:00:00 2001 From: Sayan Samanta Date: Wed, 23 Jul 2025 18:56:04 -0700 Subject: [PATCH 1/2] update video encoding, lower cap on frame rate --- server/cmd/config/config.go | 6 ++-- server/cmd/config/config_test.go | 4 +-- server/lib/oapi/oapi.go | 54 +++++++++++++++---------------- server/lib/recorder/ffmeg_test.go | 9 ++++-- server/lib/recorder/ffmpeg.go | 18 ++++++++--- server/openapi.yaml | 2 +- 6 files changed, 53 insertions(+), 40 deletions(-) diff --git a/server/cmd/config/config.go b/server/cmd/config/config.go index 60b9fa1e..52e8ffc6 100644 --- a/server/cmd/config/config.go +++ b/server/cmd/config/config.go @@ -41,11 +41,11 @@ func validate(config *Config) error { if config.DisplayNum < 0 { return fmt.Errorf("DISPLAY_NUM must be greater than 0") } - if config.FrameRate < 0 || config.FrameRate > 120 { - return fmt.Errorf("FRAME_RATE must be greater than 0 and less than 120") + if config.FrameRate < 0 || config.FrameRate > 20 { + return fmt.Errorf("FRAME_RATE must be greater than 0 and less than or equal to 20") } if config.MaxSizeInMB < 0 || config.MaxSizeInMB > 1000 { - return fmt.Errorf("MAX_SIZE_MB must be greater than 0 and less than 1000") + return fmt.Errorf("MAX_SIZE_MB must be greater than 0 and less than or equal to 1000") } if config.PathToFFmpeg == "" { return fmt.Errorf("FFMPEG_PATH is required") diff --git a/server/cmd/config/config_test.go b/server/cmd/config/config_test.go index 984d5e8a..5b2ce1d2 100644 --- a/server/cmd/config/config_test.go +++ b/server/cmd/config/config_test.go @@ -29,7 +29,7 @@ func TestLoad(t *testing.T) { name: "custom valid env", env: map[string]string{ "PORT": "12345", - "FRAME_RATE": "24", + "FRAME_RATE": "20", "DISPLAY_NUM": "2", "MAX_SIZE_MB": "250", "OUTPUT_DIR": "/tmp", @@ -37,7 +37,7 @@ func TestLoad(t *testing.T) { }, wantCfg: &Config{ Port: 12345, - FrameRate: 24, + FrameRate: 20, DisplayNum: 2, MaxSizeInMB: 250, OutputDir: "/tmp", diff --git a/server/lib/oapi/oapi.go b/server/lib/oapi/oapi.go index f73d85c8..cbd77d51 100644 --- a/server/lib/oapi/oapi.go +++ b/server/lib/oapi/oapi.go @@ -1897,33 +1897,33 @@ func (sh *strictHandler) StopRecording(w http.ResponseWriter, r *http.Request) { // Base64 encoded, gzipped, json marshaled Swagger object var swaggerSpec = []string{ - "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", - "/32oWkJgGAAA", + "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", + "xeGFqmAYAAA=", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/server/lib/recorder/ffmeg_test.go b/server/lib/recorder/ffmeg_test.go index 9ef38125..a87516aa 100644 --- a/server/lib/recorder/ffmeg_test.go +++ b/server/lib/recorder/ffmeg_test.go @@ -38,7 +38,10 @@ func TestFFmpegRecorder_StartAndStop(t *testing.T) { time.Sleep(50 * time.Millisecond) - require.NoError(t, rec.Stop(t.Context())) + err := rec.Stop(t.Context()) + require.Error(t, err) + assert.Contains(t, err.Error(), "exit status 101") + <-rec.exited require.False(t, rec.IsRecording(t.Context())) } @@ -55,7 +58,9 @@ func TestFFmpegRecorder_ForceStop(t *testing.T) { time.Sleep(50 * time.Millisecond) - require.NoError(t, rec.ForceStop(t.Context())) + err := rec.ForceStop(t.Context()) + require.Error(t, err) + <-rec.exited require.False(t, rec.IsRecording(t.Context())) assert.Contains(t, rec.cmd.ProcessState.String(), "killed") diff --git a/server/lib/recorder/ffmpeg.go b/server/lib/recorder/ffmpeg.go index 80e5dd93..101a8b69 100644 --- a/server/lib/recorder/ffmpeg.go +++ b/server/lib/recorder/ffmpeg.go @@ -194,10 +194,14 @@ func (fr *FFmpegRecorder) Start(ctx context.Context) error { // Stop gracefully stops the recording using a multi-phase shutdown process. func (fr *FFmpegRecorder) Stop(ctx context.Context) error { defer fr.stz.Enable(ctx) - err := fr.shutdownInPhases(ctx, []shutdownPhase{ - {"wake_and_interrupt", []syscall.Signal{syscall.SIGCONT, syscall.SIGINT}, 5 * time.Second, "graceful stop"}, - {"retry_interrupt", []syscall.Signal{syscall.SIGINT}, 3 * time.Second, "retry graceful stop"}, - {"terminate", []syscall.Signal{syscall.SIGTERM}, 250 * time.Millisecond, "forceful termination"}, + // This isn't scientific - give ffmpeg a long time to complete since encoding pipelines can + // be complex and we care more about the recording than performance. In cases where ffmpeg + // "falls behind" (e.g. it's resource constrained) it's better for our use case to wait for + // the recording to complete than it is to quickly terminate. We intentionally detach the + // shutdown process from any inbound context + err := fr.shutdownInPhases(context.Background(), []shutdownPhase{ + {"wake_and_interrupt", []syscall.Signal{syscall.SIGINT}, time.Minute, "graceful stop"}, + {"terminate", []syscall.Signal{syscall.SIGTERM}, 2 * time.Second, "forceful termination"}, {"kill", []syscall.Signal{syscall.SIGKILL}, 100 * time.Millisecond, "immediate kill"}, }) @@ -286,6 +290,8 @@ func ffmpegArgs(params FFmpegRecordingParams, outputPath string) ([]string, erro args = append(args, []string{ // Video encoding "-c:v", "libx264", + "-profile:v", "high", // Explicit web-compatible profile + "-pix_fmt", "yuv420p", // Web-standard pixel format // Timestamp handling for reliable playback "-use_wallclock_as_timestamps", "1", // Use system time instead of input stream time @@ -384,7 +390,9 @@ func (fr *FFmpegRecorder) shutdownInPhases(ctx context.Context, phases []shutdow // Wait for exit or timeout if err := waitForChan(ctx, phase.timeout-time.Since(phaseStartTime), done); err == nil { log.Info("ffmpeg shutdown successful", "phase", phase.name) - return nil + fr.mu.Lock() + defer fr.mu.Unlock() + return fr.ffmpegErr } } diff --git a/server/openapi.yaml b/server/openapi.yaml index 95d6f213..da8727da 100644 --- a/server/openapi.yaml +++ b/server/openapi.yaml @@ -149,7 +149,7 @@ components: type: integer description: Recording framerate in fps (overrides server default) minimum: 1 - maximum: 60 + maximum: 20 maxDurationInSeconds: type: integer description: Maximum recording duration in seconds (overrides server default) From 641990080fe6e796b3b3eec08383735c77548b4a Mon Sep 17 00:00:00 2001 From: Sayan Samanta Date: Wed, 23 Jul 2025 18:59:14 -0700 Subject: [PATCH 2/2] drop res, wait for neko, upgrade ffmpeg --- images/chromium-headful/Dockerfile | 17 +++++++++++++---- images/chromium-headful/neko.yaml | 2 +- images/chromium-headful/wrapper.sh | 8 ++++++++ 3 files changed, 22 insertions(+), 5 deletions(-) diff --git a/images/chromium-headful/Dockerfile b/images/chromium-headful/Dockerfile index 2cb8ad83..922ddf9d 100644 --- a/images/chromium-headful/Dockerfile +++ b/images/chromium-headful/Dockerfile @@ -50,8 +50,6 @@ RUN apt-get update && \ sudo \ mutter \ x11vnc \ - # Recording tools - ffmpeg \ # Python/pyenv reqs build-essential \ libssl-dev \ @@ -92,6 +90,17 @@ RUN apt-get update && \ unzip && \ apt-get clean +# install ffmpeg manually since the version available in apt is from the 4.x branch due to #drama. +# as of writing these static builds will be the latest 7.0.x release. +RUN set -eux; \ + URL="https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz"; \ + echo "Downloading FFmpeg static build from $URL"; \ + curl -fsSL "$URL" -o /tmp/ffmpeg.tar.xz; \ + tar -xJf /tmp/ffmpeg.tar.xz -C /tmp; \ + install -m755 /tmp/ffmpeg-*/ffmpeg /usr/local/bin/ffmpeg; \ + install -m755 /tmp/ffmpeg-*/ffprobe /usr/local/bin/ffprobe; \ + rm -rf /tmp/ffmpeg* + # runtime ENV USERNAME=root RUN set -eux; \ @@ -138,8 +147,8 @@ RUN git clone --branch v1.5.0 https://github.com/novnc/noVNC.git /opt/noVNC && \ # setup desktop env & app ENV DISPLAY_NUM=1 -ENV HEIGHT=1080 -ENV WIDTH=1920 +ENV HEIGHT=768 +ENV WIDTH=1024 ENV WITHDOCKER=true COPY xorg.conf /etc/neko/xorg.conf diff --git a/images/chromium-headful/neko.yaml b/images/chromium-headful/neko.yaml index 7c37cb74..0a3aa452 100644 --- a/images/chromium-headful/neko.yaml +++ b/images/chromium-headful/neko.yaml @@ -2,7 +2,7 @@ # https://neko.m1k1o.net/docs/v3/configuration#file desktop: - screen: "1920x1080@60" + screen: "1024x768@60" member: provider: "noauth" diff --git a/images/chromium-headful/wrapper.sh b/images/chromium-headful/wrapper.sh index dad0414f..7da22c79 100755 --- a/images/chromium-headful/wrapper.sh +++ b/images/chromium-headful/wrapper.sh @@ -151,6 +151,13 @@ if [[ "${ENABLE_WEBRTC:-}" == "true" ]]; then # use webrtc echo "✨ Starting neko (webrtc server)." /usr/bin/neko serve --server.static /var/www --server.bind 0.0.0.0:8080 >&2 & + + # Wait for neko to be ready. + echo "Waiting for neko port 0.0.0.0:8080..." + while ! nc -z 127.0.0.1 8080 2>/dev/null; do + sleep 0.5 + done + echo "Port 8080 is open" else # use novnc ./novnc_startup.sh @@ -213,6 +220,7 @@ if [[ "${WITH_KERNEL_IMAGES_API:-}" == "true" ]]; then sleep 5 # Attempt to click the warning's close button + echo "Clicking the warning's close button at x=$OFFSET_X y=115" if curl -s -o /dev/null -X POST \ http://localhost:10001/computer/click_mouse \ -H "Content-Type: application/json" \