diff --git a/images/chromium-headful/Dockerfile b/images/chromium-headful/Dockerfile index ab773e93..96901308 100644 --- a/images/chromium-headful/Dockerfile +++ b/images/chromium-headful/Dockerfile @@ -366,8 +366,8 @@ COPY --from=xorg-deps /usr/local/lib/xorg/modules/drivers/dummy_drv.so /usr/lib/ COPY --from=xorg-deps /usr/local/lib/xorg/modules/input/neko_drv.so /usr/lib/xorg/modules/input/neko_drv.so COPY images/chromium-headful/image-chromium/ / -COPY images/chromium-headful/start-pulseaudio.sh /images/chromium-headful/start-pulseaudio.sh -RUN chmod +x /images/chromium-headful/start-pulseaudio.sh +COPY shared/start-pulseaudio.sh /usr/local/bin/start-pulseaudio.sh +RUN chmod +x /usr/local/bin/start-pulseaudio.sh COPY images/chromium-headful/supervisord.conf /etc/supervisor/supervisord.conf COPY images/chromium-headful/supervisor/services/ /etc/supervisor/conf.d/services/ COPY shared/envoy/supervisor-envoy.conf /etc/supervisor/conf.d/services/envoy.conf diff --git a/images/chromium-headful/client/src/components/video.vue b/images/chromium-headful/client/src/components/video.vue index e040891e..2e308417 100644 --- a/images/chromium-headful/client/src/components/video.vue +++ b/images/chromium-headful/client/src/components/video.vue @@ -541,6 +541,8 @@ /* Initialize Guacamole Keyboard */ this.keyboard.onkeydown = (key: number) => { + this.unmuteOnInteraction() + if (!this.hosting || this.locked) { return true } @@ -670,6 +672,18 @@ this.$accessor.video.setMuted(false) } + // The autoplay policy only lets us unmute inside a real user gesture, and + // this client hides the unmute overlay. Piggyback on the existing input + // handlers so the first interaction with the live view unmutes. Guarded on + // the current state so it is a cheap no-op once unmuted, yet re-applies if a + // reconnect re-mutes the element. (mousemove is intentionally not used: it + // is not a user-activation event, so it cannot unlock audio.) + unmuteOnInteraction() { + if (this.muted) { + this.unmute() + } + } + toggleControl() { if (!this.playable) { return @@ -789,6 +803,8 @@ } onMouseDown(e: MouseEvent) { + this.unmuteOnInteraction() + if (!this.hosting) { this.$emit('control-attempt', e) } diff --git a/images/chromium-headful/neko.yaml b/images/chromium-headful/neko.yaml index 5f906e98..baf664f3 100644 --- a/images/chromium-headful/neko.yaml +++ b/images/chromium-headful/neko.yaml @@ -42,6 +42,14 @@ session: # needed for legacy API enabled: false +capture: + audio: + # Capture from the monitor of the recorder's playback sink so live view + # streams the same browser audio that gets recorded. Neko defaults to + # "audio_output.monitor", which does not exist here (our sink is + # KernelOutput), so without this override live view has no audio. + device: "KernelOutput.monitor" + plugins: enabled: false diff --git a/images/chromium-headful/start-pulseaudio.sh b/images/chromium-headful/start-pulseaudio.sh deleted file mode 100644 index ba5b995b..00000000 --- a/images/chromium-headful/start-pulseaudio.sh +++ /dev/null @@ -1,13 +0,0 @@ -#!/bin/bash - -set -o pipefail -o errexit -o nounset - -if [[ "$RUN_AS_ROOT" == "true" ]]; then - echo "Not starting PulseAudio daemon when running as root" -else - exec runuser -u kernel -- pulseaudio \ - --start \ - --exit-idle-time=-1 \ - --load="module-null-sink sink_name=DummyOutput" \ - --load="module-null-source source_name=DummyInput" -fi diff --git a/images/chromium-headful/supervisor/services/kernel-images-api.conf b/images/chromium-headful/supervisor/services/kernel-images-api.conf index 064aa538..b2142e63 100644 --- a/images/chromium-headful/supervisor/services/kernel-images-api.conf +++ b/images/chromium-headful/supervisor/services/kernel-images-api.conf @@ -1,5 +1,6 @@ [program:kernel-images-api] -command=/bin/bash -lc 'mkdir -p "${KERNEL_IMAGES_API_OUTPUT_DIR:-/recordings}" && PORT="${KERNEL_IMAGES_API_PORT:-10001}" FRAME_RATE="${KERNEL_IMAGES_API_FRAME_RATE:-10}" DISPLAY_NUM="${KERNEL_IMAGES_API_DISPLAY_NUM:-${DISPLAY_NUM:-1}}" MAX_SIZE_MB="${KERNEL_IMAGES_API_MAX_SIZE_MB:-500}" OUTPUT_DIR="${KERNEL_IMAGES_API_OUTPUT_DIR:-/recordings}" LOG_CDP_MESSAGES="${LOG_CDP_MESSAGES:-false}" S2_BASIN="${S2_BASIN:-}" S2_ACCESS_TOKEN="${S2_ACCESS_TOKEN:-}" S2_STREAM="${S2_STREAM:-}" exec /usr/local/bin/kernel-images-api' +; AUDIO_SOURCE and PULSE_SERVER defaults must match shared/start-pulseaudio.sh. +command=/bin/bash -lc 'mkdir -p "${KERNEL_IMAGES_API_OUTPUT_DIR:-/recordings}" && PORT="${KERNEL_IMAGES_API_PORT:-10001}" FRAME_RATE="${KERNEL_IMAGES_API_FRAME_RATE:-10}" DISPLAY_NUM="${KERNEL_IMAGES_API_DISPLAY_NUM:-${DISPLAY_NUM:-1}}" MAX_SIZE_MB="${KERNEL_IMAGES_API_MAX_SIZE_MB:-500}" OUTPUT_DIR="${KERNEL_IMAGES_API_OUTPUT_DIR:-/recordings}" AUDIO_SOURCE="${KERNEL_IMAGES_API_AUDIO_SOURCE:-${AUDIO_SOURCE:-KernelOutput.monitor}}" PULSE_SERVER="${PULSE_SERVER:-unix:/tmp/pulse/native}" LOG_CDP_MESSAGES="${LOG_CDP_MESSAGES:-false}" S2_BASIN="${S2_BASIN:-}" S2_ACCESS_TOKEN="${S2_ACCESS_TOKEN:-}" S2_STREAM="${S2_STREAM:-}" exec /usr/local/bin/kernel-images-api' autostart=false autorestart=true startsecs=0 diff --git a/images/chromium-headful/supervisor/services/neko.conf b/images/chromium-headful/supervisor/services/neko.conf index 9662df02..9cb2677d 100644 --- a/images/chromium-headful/supervisor/services/neko.conf +++ b/images/chromium-headful/supervisor/services/neko.conf @@ -3,5 +3,8 @@ command=/usr/bin/neko serve --server.static /var/www --server.bind 0.0.0.0:8080 autostart=false autorestart=true startsecs=0 +# neko's gstreamer pulsesrc needs the shared PulseAudio socket to capture +# KernelOutput.monitor for live-view audio +environment=PULSE_SERVER="unix:/tmp/pulse/native" stdout_logfile=/var/log/supervisord/neko redirect_stderr=true diff --git a/images/chromium-headful/supervisor/services/pulseaudio.conf b/images/chromium-headful/supervisor/services/pulseaudio.conf index b5df4abf..22de4810 100644 --- a/images/chromium-headful/supervisor/services/pulseaudio.conf +++ b/images/chromium-headful/supervisor/services/pulseaudio.conf @@ -1,7 +1,7 @@ [program:pulseaudio] -command=/bin/bash -lc '/images/chromium-headful/start-pulseaudio.sh' +command=/bin/bash -lc '/usr/local/bin/start-pulseaudio.sh' autostart=false -autorestart=false +autorestart=true startsecs=0 exitcodes=0 stdout_logfile=/var/log/supervisord/pulseaudio diff --git a/images/chromium-headless/image/Dockerfile b/images/chromium-headless/image/Dockerfile index 2a26e0f0..a27861b6 100644 --- a/images/chromium-headless/image/Dockerfile +++ b/images/chromium-headless/image/Dockerfile @@ -149,6 +149,7 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked,id=$CACHEIDPREFIX-ap gpg-agent \ dbus \ dbus-x11 \ + pulseaudio \ xvfb \ x11-utils \ x11-xserver-utils \ @@ -235,6 +236,8 @@ RUN useradd -m -s /bin/bash kernel # supervisor start scripts COPY images/chromium-headless/image/start-xvfb.sh /images/chromium-headless/image/start-xvfb.sh RUN chmod +x /images/chromium-headless/image/start-xvfb.sh +COPY shared/start-pulseaudio.sh /usr/local/bin/start-pulseaudio.sh +RUN chmod +x /usr/local/bin/start-pulseaudio.sh # Container entrypoint wrapper (Go binary, replaces wrapper.sh) COPY --from=server-builder /out/wrapper /wrapper diff --git a/images/chromium-headless/image/supervisor/services/kernel-images-api.conf b/images/chromium-headless/image/supervisor/services/kernel-images-api.conf index 064aa538..b2142e63 100644 --- a/images/chromium-headless/image/supervisor/services/kernel-images-api.conf +++ b/images/chromium-headless/image/supervisor/services/kernel-images-api.conf @@ -1,5 +1,6 @@ [program:kernel-images-api] -command=/bin/bash -lc 'mkdir -p "${KERNEL_IMAGES_API_OUTPUT_DIR:-/recordings}" && PORT="${KERNEL_IMAGES_API_PORT:-10001}" FRAME_RATE="${KERNEL_IMAGES_API_FRAME_RATE:-10}" DISPLAY_NUM="${KERNEL_IMAGES_API_DISPLAY_NUM:-${DISPLAY_NUM:-1}}" MAX_SIZE_MB="${KERNEL_IMAGES_API_MAX_SIZE_MB:-500}" OUTPUT_DIR="${KERNEL_IMAGES_API_OUTPUT_DIR:-/recordings}" LOG_CDP_MESSAGES="${LOG_CDP_MESSAGES:-false}" S2_BASIN="${S2_BASIN:-}" S2_ACCESS_TOKEN="${S2_ACCESS_TOKEN:-}" S2_STREAM="${S2_STREAM:-}" exec /usr/local/bin/kernel-images-api' +; AUDIO_SOURCE and PULSE_SERVER defaults must match shared/start-pulseaudio.sh. +command=/bin/bash -lc 'mkdir -p "${KERNEL_IMAGES_API_OUTPUT_DIR:-/recordings}" && PORT="${KERNEL_IMAGES_API_PORT:-10001}" FRAME_RATE="${KERNEL_IMAGES_API_FRAME_RATE:-10}" DISPLAY_NUM="${KERNEL_IMAGES_API_DISPLAY_NUM:-${DISPLAY_NUM:-1}}" MAX_SIZE_MB="${KERNEL_IMAGES_API_MAX_SIZE_MB:-500}" OUTPUT_DIR="${KERNEL_IMAGES_API_OUTPUT_DIR:-/recordings}" AUDIO_SOURCE="${KERNEL_IMAGES_API_AUDIO_SOURCE:-${AUDIO_SOURCE:-KernelOutput.monitor}}" PULSE_SERVER="${PULSE_SERVER:-unix:/tmp/pulse/native}" LOG_CDP_MESSAGES="${LOG_CDP_MESSAGES:-false}" S2_BASIN="${S2_BASIN:-}" S2_ACCESS_TOKEN="${S2_ACCESS_TOKEN:-}" S2_STREAM="${S2_STREAM:-}" exec /usr/local/bin/kernel-images-api' autostart=false autorestart=true startsecs=0 diff --git a/images/chromium-headless/image/supervisor/services/pulseaudio.conf b/images/chromium-headless/image/supervisor/services/pulseaudio.conf new file mode 100644 index 00000000..22de4810 --- /dev/null +++ b/images/chromium-headless/image/supervisor/services/pulseaudio.conf @@ -0,0 +1,8 @@ +[program:pulseaudio] +command=/bin/bash -lc '/usr/local/bin/start-pulseaudio.sh' +autostart=false +autorestart=true +startsecs=0 +exitcodes=0 +stdout_logfile=/var/log/supervisord/pulseaudio +redirect_stderr=true diff --git a/server/cmd/api/api/api.go b/server/cmd/api/api/api.go index 08857a86..de27629e 100644 --- a/server/cmd/api/api/api.go +++ b/server/cmd/api/api/api.go @@ -150,6 +150,7 @@ func (s *ApiService) StartRecording(ctx context.Context, req oapi.StartRecording params.FrameRate = req.Body.Framerate params.MaxSizeInMB = req.Body.MaxFileSizeInMB params.MaxDurationInSeconds = req.Body.MaxDurationInSeconds + params.RecordAudio = req.Body.RecordAudio } // Determine recorder ID (use default if none provided) @@ -161,6 +162,10 @@ func (s *ApiService) StartRecording(ctx context.Context, req oapi.StartRecording // Create, register, and start a new recorder rec, err := s.factory(recorderID, params) if err != nil { + if errors.Is(err, recorder.ErrInvalidParams) { + log.Warn("invalid recording parameters", "err", err, "recorder_id", recorderID) + return oapi.StartRecording400JSONResponse{BadRequestErrorJSONResponse: oapi.BadRequestErrorJSONResponse{Message: err.Error()}}, nil + } log.Error("failed to create recorder", "err", err, "recorder_id", recorderID) return oapi.StartRecording500JSONResponse{InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{Message: "failed to create recording"}}, nil } diff --git a/server/cmd/api/main.go b/server/cmd/api/main.go index d375312f..b3500a52 100644 --- a/server/cmd/api/main.go +++ b/server/cmd/api/main.go @@ -72,6 +72,8 @@ func main() { FrameRate: &config.FrameRate, MaxSizeInMB: &config.MaxSizeInMB, OutputDir: &config.OutputDir, + AudioSource: &config.AudioSource, + PulseServer: &config.PulseServer, } if err := defaultParams.Validate(); err != nil { slogger.Error("invalid default recording parameters", "err", err) diff --git a/server/cmd/chromium-launcher/main.go b/server/cmd/chromium-launcher/main.go index 558b0499..762167e4 100644 --- a/server/cmd/chromium-launcher/main.go +++ b/server/cmd/chromium-launcher/main.go @@ -16,6 +16,18 @@ import ( "github.com/kernel/kernel-images/server/lib/x11" ) +// shared/start-pulseaudio.sh is the authority for the audio topology. These are +// the fixed contract values it creates, not overridable defaults: chromium must +// connect to the same socket and play into the same sink the daemon sets up. +// Keep them in sync with start-pulseaudio.sh. +const ( + // pulseServer is the PulseAudio socket the recorder and chromium share. + pulseServer = "unix:/tmp/pulse/native" + // pulseSink is the null sink chromium plays into; the recorder captures + // its .monitor source. + pulseSink = "KernelOutput" +) + func main() { headless := flag.Bool("headless", false, "Run Chromium with headless flags") chromiumPath := flag.String("chromium", "chromium", "Chromium binary path (default: chromium)") @@ -89,11 +101,15 @@ func main() { runAsRoot := strings.EqualFold(strings.TrimSpace(os.Getenv("RUN_AS_ROOT")), "true") - // Prepare environment + // Prepare environment. PULSE_SERVER/PULSE_SINK route chromium's audio into the + // recorder's sink; the root path below relies on this inherited env, while the + // non-root path re-asserts them in its runuser env allowlist. env := os.Environ() env = append(env, "DISPLAY=:1", "DBUS_SESSION_BUS_ADDRESS=unix:path=/run/dbus/system_bus_socket", + "PULSE_SERVER="+pulseServer, + "PULSE_SINK="+pulseSink, ) if runAsRoot { @@ -118,10 +134,18 @@ func main() { } // Build: runuser -u kernel -- env DISPLAY=... DBUS_... XDG_... HOME=... chromium + // PULSE_SERVER tells libpulse which daemon socket to connect to; without it + // chromium-as-kernel-user can't reach the recorder's PulseAudio instance and + // has no audio output at all. PULSE_SINK then selects which sink within that + // daemon playback lands on: Chromium's AudioManagerPulse honors it to redirect + // playback into KernelOutput (see media/audio/pulse/audio_manager_pulse.cc + // GetDefaultOutputDeviceID), which is the sink the recorder captures. inner := []string{ "env", "DISPLAY=:1", "DBUS_SESSION_BUS_ADDRESS=unix:path=/run/dbus/system_bus_socket", + "PULSE_SERVER=" + pulseServer, + "PULSE_SINK=" + pulseSink, "XDG_CONFIG_HOME=/home/kernel/.config", "XDG_CACHE_HOME=/home/kernel/.cache", "HOME=/home/kernel", diff --git a/server/cmd/config/config.go b/server/cmd/config/config.go index c2dddced..aae38b9e 100644 --- a/server/cmd/config/config.go +++ b/server/cmd/config/config.go @@ -18,6 +18,12 @@ type Config struct { DisplayNum int `envconfig:"DISPLAY_NUM" default:"1"` MaxSizeInMB int `envconfig:"MAX_SIZE_MB" default:"500"` OutputDir string `envconfig:"OUTPUT_DIR" default:"."` + // AudioSource and PulseServer default to empty, i.e. video-only. Setting both + // enables audio capture; their values must match the topology defined in + // shared/start-pulseaudio.sh (the authority for the sink/source/socket). The + // image's supervisor conf sets both. + AudioSource string `envconfig:"AUDIO_SOURCE" default:""` + PulseServer string `envconfig:"PULSE_SERVER" default:""` // Absolute or relative path to the ffmpeg binary. If empty the code falls back to "ffmpeg" on $PATH. PathToFFmpeg string `envconfig:"FFMPEG_PATH" default:"ffmpeg"` @@ -55,6 +61,8 @@ func (c *Config) LogValue() slog.Value { slog.Int("display_num", c.DisplayNum), slog.Int("max_size_mb", c.MaxSizeInMB), slog.String("output_dir", c.OutputDir), + slog.String("audio_source", c.AudioSource), + slog.String("pulse_server", c.PulseServer), slog.String("ffmpeg_path", c.PathToFFmpeg), slog.Int("devtools_proxy_port", c.DevToolsProxyPort), slog.Bool("log_cdp_messages", c.LogCDPMessages), diff --git a/server/cmd/config/config_test.go b/server/cmd/config/config_test.go index d2b50291..27dd8b3d 100644 --- a/server/cmd/config/config_test.go +++ b/server/cmd/config/config_test.go @@ -23,6 +23,8 @@ func TestLoad(t *testing.T) { DisplayNum: 1, MaxSizeInMB: 500, OutputDir: ".", + AudioSource: "", + PulseServer: "", PathToFFmpeg: "ffmpeg", DevToolsProxyPort: 9222, ScaleToZeroCooldown: time.Second, @@ -39,6 +41,8 @@ func TestLoad(t *testing.T) { "DISPLAY_NUM": "2", "MAX_SIZE_MB": "250", "OUTPUT_DIR": "/tmp", + "AUDIO_SOURCE": "CustomOutput.monitor", + "PULSE_SERVER": "unix:/tmp/pulse/native", "FFMPEG_PATH": "/usr/local/bin/ffmpeg", "DEVTOOLS_PROXY_PORT": "9876", "SCALE_TO_ZERO_COOLDOWN": "5s", @@ -51,6 +55,8 @@ func TestLoad(t *testing.T) { DisplayNum: 2, MaxSizeInMB: 250, OutputDir: "/tmp", + AudioSource: "CustomOutput.monitor", + PulseServer: "unix:/tmp/pulse/native", PathToFFmpeg: "/usr/local/bin/ffmpeg", DevToolsProxyPort: 9876, ScaleToZeroCooldown: 5 * time.Second, @@ -71,6 +77,8 @@ func TestLoad(t *testing.T) { DisplayNum: 1, MaxSizeInMB: 500, OutputDir: ".", + AudioSource: "", + PulseServer: "", PathToFFmpeg: "ffmpeg", DevToolsProxyPort: 7777, ScaleToZeroCooldown: time.Second, diff --git a/server/cmd/wrapper/chromium.go b/server/cmd/wrapper/chromium.go index 3b239a14..511fce60 100644 --- a/server/cmd/wrapper/chromium.go +++ b/server/cmd/wrapper/chromium.go @@ -47,7 +47,6 @@ func applyHeadlessDefaultFlags() { "--hide-crash-restore-bubble", "--hide-scrollbars", "--metrics-recording-only", - "--mute-audio", "--no-default-browser-check", "--no-first-run", "--no-sandbox", diff --git a/server/cmd/wrapper/main.go b/server/cmd/wrapper/main.go index aae8f0c0..b263fe83 100644 --- a/server/cmd/wrapper/main.go +++ b/server/cmd/wrapper/main.go @@ -27,6 +27,9 @@ const ( dbusSocket = "/run/dbus/system_bus_socket" defaultDisplay = ":1" defaultIntPort = "9223" + // pulseSocket must match the socket path created in shared/start-pulseaudio.sh + // (the authority for the audio topology); the wrapper only waits on it here. + pulseSocket = "/tmp/pulse/native" ) type profile int @@ -172,11 +175,13 @@ func main() { _ = os.WriteFile(filepath.Join(supervisordLogD, "chromium"), nil, 0o644) browserStart := time.Now() - startAll(xServer, "dbus", "chromedriver", "chromium") + startAll(xServer, "dbus", "chromedriver", "pulseaudio") waitForX(defaultDisplay, 20*time.Second) if prof == profileHeadful { startAll("mutter") } + waitForSocket(pulseSocket, 10*time.Second) + startAll("chromium") waitForSocket(dbusSocket, 10*time.Second) if prof == profileHeadful && webrtc { startAll("neko") @@ -223,12 +228,6 @@ func main() { identityDone.Sub(identityStart).Truncate(time.Millisecond), formatProbeDurations(probeDurations)) - // Cosmetic + non-critical services come up off the hot path. Headless has - // no audio stack. - if prof == profileHeadful { - go startAll("pulseaudio") - } - // Re-enable scale-to-zero now that the hot path is up — unless the caller // asked to keep it disabled via ENABLE_STZ=false/0. if stzManaged { diff --git a/server/e2e/e2e_recording_audio_test.go b/server/e2e/e2e_recording_audio_test.go new file mode 100644 index 00000000..74651437 --- /dev/null +++ b/server/e2e/e2e_recording_audio_test.go @@ -0,0 +1,489 @@ +package e2e + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "os" + "os/exec" + "path/filepath" + "regexp" + "strconv" + "testing" + "time" + + instanceoapi "github.com/kernel/kernel-images/server/lib/oapi" + "github.com/stretchr/testify/require" +) + +func TestReplayRecordingIncludesAudioTrack(t *testing.T) { + if _, err := exec.LookPath("docker"); err != nil { + t.Skipf("docker not available: %v", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 4*time.Minute) + defer cancel() + + c := NewTestContainer(t, headfulImage) + require.NoError(t, c.Start(ctx, ContainerConfig{ + Env: map[string]string{ + "WIDTH": "1280", + "HEIGHT": "720", + }, + }), "failed to start container") + defer c.Stop(ctx) + + require.NoError(t, c.WaitReady(ctx), "api not ready") + require.NoError(t, c.WaitDevTools(ctx), "devtools not ready") + + // Verify the browser sees a real sound card over pure CDP/websocket. Chromium + // excludes PulseAudio monitor sources from enumerateDevices(), so the + // recorder's capture sink alone is invisible as an input. The standalone + // null-source (KernelInput) is what makes a non-monitor microphone show up, + // which antibot fingerprinting checks for. + assertBrowserSeesAudioDevices(t, ctx, c) + + // Serve the tone fixture from inside the container as a file:// page. This + // keeps the test self-contained instead of relying on host.docker.internal, + // which is not routable in every sandbox. + fixtureURL := writeContainerAudioFixture(t, ctx, c) + + playwrightCode := fmt.Sprintf(` + await page.goto(%q, { waitUntil: 'load' }); + await page.click('#start'); + await page.waitForFunction(() => window.audioStarted === true); + await page.waitForTimeout(8000); + return await page.title(); + `, fixtureURL) + + recordReplayAudio(t, ctx, c, playwrightCode, os.Getenv("RECORDING_AUDIO_OUTPUT_PATH"), 0.1) +} + +func TestReplayRecordingZombocomArchiveAudio(t *testing.T) { + outputPath := os.Getenv("RECORDING_ZOMBO_OUTPUT_PATH") + if outputPath == "" { + t.Skip("set RECORDING_ZOMBO_OUTPUT_PATH to write a Zombocom archive recording") + } + if _, err := exec.LookPath("docker"); err != nil { + t.Skipf("docker not available: %v", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + + c := NewTestContainer(t, headfulImage) + require.NoError(t, c.Start(ctx, ContainerConfig{ + Env: map[string]string{ + "WIDTH": "1280", + "HEIGHT": "720", + }, + }), "failed to start container") + defer c.Stop(ctx) + + require.NoError(t, c.WaitReady(ctx), "api not ready") + + playwrightCode := ` + await page.goto('https://archive.org/embed/ZombocomAkaZombo.com', { waitUntil: 'domcontentloaded' }); + await page.waitForSelector('play-av', { timeout: 30000 }); + + const playbackState = () => page.evaluate(() => { + const mediaElements = []; + const collect = (root) => { + mediaElements.push(...root.querySelectorAll('audio,video')); + for (const el of root.querySelectorAll('*')) { + if (el.shadowRoot) { + collect(el.shadowRoot); + } + } + }; + collect(document); + return mediaElements.map((el) => ({ + currentTime: el.currentTime, + paused: el.paused, + readyState: el.readyState, + src: el.currentSrc || el.src, + })); + }); + const isPlaying = async () => { + const playback = await playbackState(); + return playback.some((media) => media.currentTime > 0.2 && !media.paused); + }; + + await page.waitForTimeout(2000); + await page.waitForFunction(async () => { + const player = document.querySelector('play-av'); + const video = player?.shadowRoot?.querySelector('video'); + return video && video.readyState >= 2; + }, null, { timeout: 30000 }); + const playButton = await page.locator('play-av').evaluate((player) => { + const button = player.shadowRoot?.querySelector('.jw-icon-playback'); + if (!button) { + throw new Error('archive play button not found'); + } + const rect = button.getBoundingClientRect(); + return { + x: rect.left + rect.width / 2, + y: rect.top + rect.height / 2, + }; + }); + await page.mouse.click(playButton.x, playButton.y); + await page.waitForTimeout(2000); + if (!(await isPlaying())) { + throw new Error('archive audio did not start after clicking play: ' + JSON.stringify(await playbackState())); + } + + await page.waitForTimeout(16000); + const playback = await playbackState(); + if (!playback.some((media) => media.currentTime > 8 && !media.paused)) { + throw new Error('archive audio did not start: ' + JSON.stringify(playback)); + } + return playback; + ` + + recordReplayAudio(t, ctx, c, playwrightCode, outputPath, 0.01) +} + +func recordReplayAudio(t *testing.T, ctx context.Context, c *TestContainer, playwrightCode string, outputPath string, minPeakLevel float64) { + t.Helper() + + client, err := c.APIClient() + require.NoError(t, err, "failed to create API client") + + // Safety backstop only: each caller stops the recording explicitly once its + // Playwright script finishes. Keep this above the longest script (the Zombocom + // capture can run ~50s) so the cap never truncates the intended recording. + maxDuration := 120 + maxFileSize := 100 + recordAudio := true + startResp, err := client.StartRecordingWithResponse(ctx, instanceoapi.StartRecordingJSONRequestBody{ + MaxDurationInSeconds: &maxDuration, + MaxFileSizeInMB: &maxFileSize, + RecordAudio: &recordAudio, + }) + require.NoError(t, err, "POST /recording/start failed") + require.Equal(t, http.StatusCreated, startResp.StatusCode(), "unexpected start status: %s body=%s", startResp.Status(), string(startResp.Body)) + + stopped := false + defer func() { + if !stopped { + force := true + _, _ = client.StopRecordingWithResponse(context.Background(), instanceoapi.StopRecordingJSONRequestBody{ForceStop: &force}) + } + }() + + runResp, err := client.ExecutePlaywrightCodeWithResponse(ctx, instanceoapi.ExecutePlaywrightCodeJSONRequestBody{ + Code: playwrightCode, + }) + require.NoError(t, err, "playwright request failed") + require.Equal(t, http.StatusOK, runResp.StatusCode(), "unexpected playwright status: %s body=%s", runResp.Status(), string(runResp.Body)) + require.NotNil(t, runResp.JSON200, "expected playwright JSON response") + if !runResp.JSON200.Success { + t.Fatalf("playwright execution failed: error=%s stderr=%s result=%#v", stringValue(runResp.JSON200.Error), stringValue(runResp.JSON200.Stderr), runResp.JSON200.Result) + } + + stopResp, err := client.StopRecordingWithResponse(ctx, instanceoapi.StopRecordingJSONRequestBody{}) + stopped = true + require.NoError(t, err, "POST /recording/stop failed") + require.Equal(t, http.StatusOK, stopResp.StatusCode(), "unexpected stop status: %s body=%s", stopResp.Status(), string(stopResp.Body)) + + downloadResp, err := client.DownloadRecordingWithResponse(ctx, nil) + require.NoError(t, err, "GET /recording/download failed") + require.Equal(t, http.StatusOK, downloadResp.StatusCode(), "unexpected download status: %s body=%s", downloadResp.Status(), string(downloadResp.Body)) + require.NotEmpty(t, downloadResp.Body, "downloaded recording is empty") + + if outputPath != "" { + require.NoError(t, os.MkdirAll(filepath.Dir(outputPath), 0o755), "failed to create recording output directory") + require.NoError(t, os.WriteFile(outputPath, downloadResp.Body, 0o644), "failed to write downloaded recording") + } + + require.True(t, mp4HasAudioTrack(downloadResp.Body), "downloaded recording does not contain an audio track") + require.Greater(t, mp4AudioPeakLevel(t, downloadResp.Body), minPeakLevel, "downloaded recording audio track is silent") + formatDuration, audioDuration := mp4Durations(t, downloadResp.Body) + require.GreaterOrEqual(t, audioDuration, formatDuration-2, "downloaded recording audio track ends before the recording does") +} + +type mediaDeviceInfo struct { + Kind string `json:"kind"` + Label string `json:"label"` + DeviceID string `json:"deviceId"` +} + +// assertBrowserSeesAudioDevices connects to the browser over a raw CDP websocket +// and asserts navigator.mediaDevices.enumerateDevices() reports both an audio +// output and a non-monitor audio input. Chromium drops monitor sources from the +// input list, so a passing audioinput assertion confirms the KernelInput +// null-source (not just the KernelOutput sink monitor) is present. +func assertBrowserSeesAudioDevices(t *testing.T, ctx context.Context, c *TestContainer) { + t.Helper() + + // navigator.mediaDevices is only exposed in a secure context. A file:// page + // qualifies, so drop a minimal HTML file into the container and load it. + const securePagePath = "/tmp/enumerate-devices.html" + exitCode, out, err := c.Exec(ctx, []string{"sh", "-lc", "printf '%s' 'enumerate-devices' > " + securePagePath}) + require.NoError(t, err, "failed to write secure-context page") + require.Zero(t, exitCode, "failed to write secure-context page: %s", out) + + devices, err := enumerateMediaDevicesViaCDP(ctx, c.CDPURL(), "file://"+securePagePath) + require.NoError(t, err, "failed to enumerate media devices via CDP") + t.Logf("enumerateDevices reported %d devices: %+v", len(devices), devices) + + audioInputs := make([]mediaDeviceInfo, 0) + audioOutputs := make([]mediaDeviceInfo, 0) + for _, d := range devices { + switch d.Kind { + case "audioinput": + audioInputs = append(audioInputs, d) + case "audiooutput": + audioOutputs = append(audioOutputs, d) + } + } + + // Chromium drops monitor sources from the input list, so any audioinput entry + // here is necessarily the standalone KernelInput null-source, not the + // KernelOutput sink monitor. + require.NotEmpty(t, audioInputs, "expected at least one non-monitor audioinput device (KernelInput); Chromium filters monitor sources, so a missing entry means the null-source did not load") + require.NotEmpty(t, audioOutputs, "expected at least one audiooutput device (KernelOutput)") +} + +// enumerateMediaDevicesViaCDP opens a CDP target over the websocket proxy, +// navigates to pageURL (which must be a secure-context origin), and evaluates +// navigator.mediaDevices.enumerateDevices() inside the page. +func enumerateMediaDevicesViaCDP(ctx context.Context, wsURL, pageURL string) ([]mediaDeviceInfo, error) { + client, err := newCDPClient(ctx, wsURL) + if err != nil { + return nil, err + } + defer client.Close() + + // Grant mic/camera access at the browser level so device labels are exposed. + if _, err := client.Call(ctx, "Browser.grantPermissions", map[string]any{ + "permissions": []string{"audioCapture", "videoCapture"}, + }, ""); err != nil { + return nil, fmt.Errorf("Browser.grantPermissions: %w", err) + } + + targetRaw, err := client.Call(ctx, "Target.createTarget", map[string]any{"url": "about:blank"}, "") + if err != nil { + return nil, fmt.Errorf("Target.createTarget: %w", err) + } + targetID, err := decodeJSONStringField(targetRaw, "targetId") + if err != nil { + return nil, err + } + defer func() { + _, _ = client.Call(ctx, "Target.closeTarget", map[string]any{"targetId": targetID}, "") + }() + + attachRaw, err := client.Call(ctx, "Target.attachToTarget", map[string]any{ + "targetId": targetID, + "flatten": true, + }, "") + if err != nil { + return nil, fmt.Errorf("Target.attachToTarget: %w", err) + } + sessionID, err := decodeJSONStringField(attachRaw, "sessionId") + if err != nil { + return nil, err + } + + if _, err := client.Call(ctx, "Page.enable", map[string]any{}, sessionID); err != nil { + return nil, fmt.Errorf("Page.enable: %w", err) + } + if _, err := client.Call(ctx, "Runtime.enable", map[string]any{}, sessionID); err != nil { + return nil, fmt.Errorf("Runtime.enable: %w", err) + } + + loadCtx, loadCancel := context.WithTimeout(ctx, 20*time.Second) + defer loadCancel() + loadDone := make(chan error, 1) + go func() { + loadDone <- client.WaitForEvent(loadCtx, "Page.loadEventFired", sessionID) + }() + if _, err := client.Call(ctx, "Page.navigate", map[string]any{ + "url": pageURL, + }, sessionID); err != nil { + return nil, fmt.Errorf("Page.navigate: %w", err) + } + if err := <-loadDone; err != nil { + return nil, fmt.Errorf("waiting for page load: %w", err) + } + + const expression = `(async () => { + if (!navigator.mediaDevices || !navigator.mediaDevices.enumerateDevices) { + return JSON.stringify({ error: 'mediaDevices unavailable', secureContext: window.isSecureContext }); + } + const devices = await navigator.mediaDevices.enumerateDevices(); + return JSON.stringify({ devices: devices.map((d) => ({ kind: d.kind, label: d.label, deviceId: d.deviceId })) }); +})()` + + evalRaw, err := client.Call(ctx, "Runtime.evaluate", map[string]any{ + "expression": expression, + "returnByValue": true, + "awaitPromise": true, + }, sessionID) + if err != nil { + return nil, fmt.Errorf("Runtime.evaluate: %w", err) + } + + var evalEnvelope struct { + Result struct { + Value string `json:"value"` + } `json:"result"` + ExceptionDetails json.RawMessage `json:"exceptionDetails"` + } + if err := json.Unmarshal(evalRaw, &evalEnvelope); err != nil { + return nil, fmt.Errorf("decode Runtime.evaluate result: %w", err) + } + if len(evalEnvelope.ExceptionDetails) > 0 { + return nil, fmt.Errorf("enumerateDevices raised an exception: %s", string(evalEnvelope.ExceptionDetails)) + } + + var payload struct { + Error string `json:"error"` + SecureContext bool `json:"secureContext"` + Devices []mediaDeviceInfo `json:"devices"` + } + if err := json.Unmarshal([]byte(evalEnvelope.Result.Value), &payload); err != nil { + return nil, fmt.Errorf("decode enumerateDevices payload %q: %w", evalEnvelope.Result.Value, err) + } + if payload.Error != "" { + return nil, fmt.Errorf("enumerateDevices failed: %s (secureContext=%t)", payload.Error, payload.SecureContext) + } + + return payload.Devices, nil +} + +const audioFixtureHTML = ` + +audio replay fixture + + + + +` + +// writeContainerAudioFixture writes the tone-playing HTML fixture into the +// container and returns a file:// URL for it. A file:// page is a secure +// context (AudioContext works) and avoids depending on host networking. +func writeContainerAudioFixture(t *testing.T, ctx context.Context, c *TestContainer) string { + t.Helper() + + const fixturePath = "/tmp/audio-fixture.html" + enc := base64.StdEncoding.EncodeToString([]byte(audioFixtureHTML)) + exitCode, out, err := c.Exec(ctx, []string{"sh", "-lc", fmt.Sprintf("echo %s | base64 -d > %s", enc, fixturePath)}) + require.NoError(t, err, "failed to write audio fixture") + require.Zero(t, exitCode, "failed to write audio fixture: %s", out) + return "file://" + fixturePath +} + +func mp4HasAudioTrack(data []byte) bool { + for i := 0; i+16 <= len(data); i++ { + if !bytes.Equal(data[i:i+4], []byte("hdlr")) { + continue + } + end := i + 32 + if end > len(data) { + end = len(data) + } + if bytes.Contains(data[i:end], []byte("soun")) { + return true + } + } + return false +} + +func stringValue(v *string) string { + if v == nil { + return "" + } + return *v +} + +func mp4AudioPeakLevel(t *testing.T, data []byte) float64 { + t.Helper() + + recordingPath := filepath.Join(t.TempDir(), "recording.mp4") + require.NoError(t, os.WriteFile(recordingPath, data, 0o644), "failed to write recording for audio analysis") + + out, err := exec.Command( + "docker", "run", "--rm", + "-v", recordingPath+":/tmp/recording.mp4:ro", + "--entrypoint", "ffmpeg", + headfulImage, + "-hide_banner", + "-i", "/tmp/recording.mp4", + "-map", "0:a:0", + "-af", "astats=metadata=1:reset=0", + "-f", "null", + "-", + ).CombinedOutput() + require.NoError(t, err, "failed to analyze recording audio: %s", string(out)) + + matches := regexp.MustCompile(`Max level: ([0-9.]+)`).FindStringSubmatch(string(out)) + require.Len(t, matches, 2, "failed to find audio peak level in ffmpeg output: %s", string(out)) + + peak, err := strconv.ParseFloat(matches[1], 64) + require.NoError(t, err, "failed to parse audio peak level") + return peak +} + +func mp4Durations(t *testing.T, data []byte) (float64, float64) { + t.Helper() + + recordingPath := filepath.Join(t.TempDir(), "recording.mp4") + require.NoError(t, os.WriteFile(recordingPath, data, 0o644), "failed to write recording for duration analysis") + + out, err := exec.Command( + "docker", "run", "--rm", + "-v", recordingPath+":/tmp/recording.mp4:ro", + "--entrypoint", "ffprobe", + headfulImage, + "-v", "error", + "-show_entries", "format=duration", + "-show_entries", "stream=codec_type,duration", + "-of", "json", + "/tmp/recording.mp4", + ).CombinedOutput() + require.NoError(t, err, "failed to probe recording durations: %s", string(out)) + + var probe struct { + Streams []struct { + CodecType string `json:"codec_type"` + Duration string `json:"duration"` + } `json:"streams"` + Format struct { + Duration string `json:"duration"` + } `json:"format"` + } + require.NoError(t, json.Unmarshal(out, &probe), "failed to parse ffprobe output") + + formatDuration, err := strconv.ParseFloat(probe.Format.Duration, 64) + require.NoError(t, err, "failed to parse format duration") + + for _, stream := range probe.Streams { + if stream.CodecType != "audio" { + continue + } + audioDuration, err := strconv.ParseFloat(stream.Duration, 64) + require.NoError(t, err, "failed to parse audio duration") + return formatDuration, audioDuration + } + t.Fatal("ffprobe did not report an audio stream") + return 0, 0 +} diff --git a/server/lib/oapi/oapi.go b/server/lib/oapi/oapi.go index 5d754063..1efa4827 100644 --- a/server/lib/oapi/oapi.go +++ b/server/lib/oapi/oapi.go @@ -3495,6 +3495,9 @@ type StartRecordingRequest struct { // MaxFileSizeInMB Maximum file size in MB (overrides server default) MaxFileSizeInMB *int `json:"maxFileSizeInMB,omitempty"` + + // RecordAudio Capture audio alongside video. Requires the server to have an audio source and PulseAudio socket configured (the image sets both by default). When false the recording is video-only. + RecordAudio *bool `json:"recordAudio,omitempty"` } // StopRecordingRequest defines model for StopRecordingRequest. @@ -18678,7 +18681,7 @@ func (sh *strictHandler) StreamTelemetryEvents(w http.ResponseWriter, r *http.Re var swaggerSpec = []string{ "H4sIAAAAAAAC/+z9+XIjN5YojL8Kgr+JsDRDUqpyued2Vdw/ZEnV1rgW/SSVPdMtfySYeUiilQTSAJIS", - "7agb9yHuE94n+QLnALmQSC5aapmvIiamy2JiOxsOzvpnJ1GzXEmQ1nRe/tnRYHIlDeB//MjTC/i9AGNP", + "7agb9yHuE94n+QLnALmQSC5aapmvIiamy2JiOzsOzvJnJ1GzXEmQ1nRe/tnRYHIlDeB//MjTC/i9AGNP", "tVba/SlR0oK07p88zzORcCuUPPinUdL9zSRTmHH3r3/RMO687Pz/Dqr5D+hXc0Czffz4sdtJwSRa5G6S", "zku3IPMrdj52O8dKjjORfKrVw3Ju6TNpQUuefaKlw3LsEvQcNPMfdjvvlH2tCpl+on28U5bheh33m/+c", "SMEm02M1ywsL+ihxnwdEuZ2kqXB/4tm5VjloKxwBjXlmYHmFIzZyUzE1ZomfjnGczzCrGNxBUlhgxk0u", @@ -18690,342 +18693,343 @@ var swaggerSpec = []string{ "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", - "/w0AAP//kgERyXu3AQA=", + "FzWW2yICVZQGQYEz+oolKq3PUtLiEnnVDrIE13K9bgOnayjOkdul5cnN6haPT87ZRSEdL/XxkyvNE2Aa", + "cg3GgUhOEDb/wef8EseReDPuW8Yt/uhGo3CXRH199tpxvGGFAeZWkHzmJkqUdD+jAtDcTkEzO+WSGclv", + "YJBwgyIBaQHnPZ5qNQN2AvMrpTLDzrWyKlEZuxUaGHF3/1pGRGiWvdZ8BlsoJDzNGD/uMkd9eqaMJeXT", + "UDtLS6ismMl3RPkri/wdtOqNuIGU0YeMeITdCjsVpN4yIaN00O2MC4mq6B2fwercNUyEDx18ocuUZjDL", + "7YIRZaJg4FLJxUwVpvzYREnY7WaL07jPImehr+Onod/O0jjt0X/X2DG6u0Jnq8M/XLxxR3ZnD2LEzzYW", + "WYxRlzisAebaPmm5Bki6TXzHWK1pWiwJ7VVJSMKeZXwEGSIKt49MZZEDSQZys5AJS3hhIC7vcq6D8Zll", + "78edl//YSplXEuHjbysKBqdsbAYpCbeCfzX9FWDWWG6tIMptMuWXKpvDBZgis22mFEvoU2bct4xb60ib", + "aeCoJzhzjCocCFVhEzWDiMLb3jhq2dc3O6nVTvLoGSB6BhphtpvNtNlQWoeV3W2mQEINsyl2jHYTKnwd", + "gLEkzjzFzkGmSrMxn4ls0XdKKy0S0IZJB+bMITLXai5S0D2TQyLGImGWmxsUZYYJaRWzU2GYAfuSgbuJ", + "5loYYHOuBZfWOHGnIXBIorKM5wbCQBCazUEbpxhGRXIDlu3Nn7MDNv9+v8u4TBmXCye6J0wqyxI1R4VI", + "AscB90Q5bfLW+gN1WZ5xIdn744t9JoyzDZR2pMkNGyqnxYekhANtTP3OOg75AWbz583//N5RQqGlsSJz", + "5DABsO7y2u3glPHrzq4mLJp2JEGM5do6TooJjhVDFm+dA2eqrS6E9FhDHX6LZp27uY65yApd2rCnFxfv", + "LwbHR+dXxz8dDT68u3z/5pejH9+cDvf77GjkLCw3yBSJs3R3Mi6vls/Bhn6a4Us6s2YaHIhRXhaGjzJw", + "P+BVu8+Gfqexr6U/1J4BYMMKGG7XQydPVGGrcalIkZJofN0ucFoB9HeG3XJh2ahIJ2D7bMhHXKZKQjp8", + "6T9hCZcJZO7C7HVhzifAJJ+LCYpBfssXzgzv4ZpNevPHdoKMjuTASJvsdDvlYlGScnwXvSx4LHNjxMTB", + "pGahsPc5/72ArjNvxwWpb1PkjiuYE6ymp2EMGmQCcZTewsgIC4OpMhHd95Miy7SEwu0UNHh4Ess7FYGA", + "SNfOn3M7jVyDuJ1uPz/7/xegS5MS7pKsSKPLrhgENVl5jytLmh8rKSGx7c4WuPM+uiQTjpGI5ZLCWDUD", + "zS5Pfu6y84wvbrWYTG2XnRd5DhZA77ubiJsbUkYiE28pv8LoUqG8zLW6W5AfShj2y9vtvDRmYSzMomT2", + "zYJYtSDSfODR8ITOluM0PxEm2ZWU0nIMpJWDYAORsHMu6FqEX4vZDFLBLWQLlmtIIHUcNKydexhcncbd", + "YYzVwGcPJrVdzN4V4HyzeNfSa0UWn5Rk72nxVrtdMnobJ3l8j6Gf2P1lG6fhDIzhExgkqohxJ9253dyO", + "/fzHzgrN+MIZBqhxI+uCQAdTKjT9Le6d0MBN7Ib+63SxPCdIp/jYkETEIMmUccYTfkVSQ0hhBdIw/VEZ", + "Z5UVOXH2IJlyOUGjBx1bopgxDWiXQkq2DRi02p2NjtoZJYxVGliqbiUzqr5aooosdfcAj2M+4UIa8shJ", + "uGVh3foW0JQbvix/Y6lwFqQOcGV5McvJ+KOzKmnhzg5K88wfODhG/e/IwZUJt2cXuXCG3cK/cjAzLaw7", + "wn7TcquDstPtLEOq/ifcEzpilna0mRPrdLxMbiUFrGNIJY3KAN/oWv0VI/rWQcR97A1opZkTa8Vkausu", + "VLhLICeiIn/p6UzYStXcKqeArJCJRaInmWFItaRijMalJQlqpjwH0y+duH79o/OzY07I8H/p+3sKzzKz", + "70jL3UoNy2AOWZc5mHYZ1xNDV0T08wzQ+1PNXW77aqodPe6VZyt/qU9Nc2ZCQte7Qbv+KINCZ5F1vNfY", + "3SX8U6q7sngLjUYyroFxvDjFPb+rutKf/8HKcpkKvunKdl1JsPJM+4SqMoqTXZ2hOPKY5ErnY3fZ1e+Y", + "IsLxWVbyOteTYuZmZokCndCtgs5q+uycXlKYktnC3bWkJ2XP7W2M23h8WL23Lrmbib8iTqnG80PDXV+7", + "91XyCMkLuXvrjS9JhbieRTETfwIIUHSD2Jxn7mbNs1u+MOyaHDHXnQdBMfrYsbqXN7W3jc8HqEpAtrx4", + "rLx0MDvFdzgNt809PsLGGm6oIKi3dqqXbwzdDvLWqghClRRsD/dNtWch2UjZaZD7ObdTs9ntgOusSozf", + "VmTGGzXZWpdnakKKulKmmZp0w+99Iceq+q9brmWXgU36+/3PoKDCwb6pp43qKVOTp1dODXx8WappJw2z", + "RoK32p5uji7LuTF4J9KqmExZIccis/jmgFKIXvn73s88xCcGVXjfXMOS8DdV5q45wNNXjGcZw+cCtqxI", + "jLMggWvmRHefXQJ5b0wOSfncOi6yjDmaIEvy04i81xjVtoyeVexsFnWEkO4WIq9BRSs78h95CRdudMh0", + "VfxaEIkzJYV1FxtpFYL/+OS8F5SKdySws+Arp3u55XoCtktRFmT2e8c+3oBylUwdd99OhY/7oJ2oJCm0", + "u4ZG7HycKuq3d1jGX+shPrUnCdpM3CxQPAXdOmuqEsIVfVebv+vu8YAvOcCTae100XUknw8M/L66ylsl", + "lVXSX52FTNzdFN/pKnBRHGYSLJUufeb2BWm5AavyHpJHfWQUCFtIT++VaIVL8FrUQ6o8h9E6NSdKFB70", + "VXT+QJt+otoSe8bi7dD7f6pzmnBQziwf7a9bMeiFLTj7CkdcuQHr4lE0ZDDnkh4ap8IQKb+idxb3wRgj", + "VkqcOF7A34h1uqVjpfwW7K3SNzUf3XqhUENWHbDNI1ckuEZ91U2BHX2PWs1BckekM7AcrQOPuYWjZmJ0", + "7ybQDLzvo+T8VasJ4pZaeFqvvcWi5MCQIP8A26abhgjeuvQqPTcI6jjh3AiZtpkq4UB99LAGL18sfM2r", + "sfJdwQvXPhtSCOKA52L4kv2M/8GOzs+CG23PyRk9B3Lk0h97E5Cg0dwKO2dDuLMgHSEMXzIh/0nvGH4/", + "5W99NsxUwrOBD7QcvmT0rsD8H5gupHQY45mSEyNSaGy36cpL8063U+3f/RQW6jjZWlsoaukGUmkntoiR", + "sokegjYjYnDSivjgwPPJAamKs5MGvgMvLPEWIn8Nx/xkbf4TON1g2g9hdbHCMBgnOqWRbMZzh91brlOM", + "segJTylu9060qcKWoSSkZNgv7tZs0DdWc72SlcdGhWUzvmAjYFwu2H9cvn+HJlLD6lk5DCZAUEj8cSaS", + "m42XpQJvTO7TYEnw3BbOypsLXhEhSrsqXnDz7UhUG3noDSl6pm/3pNZ7Ug30A8TsE96W2nHzyHcmAxkk", + "VkXiXI8vL1n4Ff0NwfWMZ3fyNUNDq8WkmMQCwN++YZZPGkGqS7M5hBV5Dhrjn0lQ/fjh6ur9uy476rKT", + "s19abJioMf+LMAKd5k7q+dSkloW7zGp8o45OfxebG24xyOWulyilUyG5bZ7KncVBMRd3kJm4g2uxZuLF", + "/SdeosO7jlupW2GbMLT2mlQjwZ9hsVHg3cBipLhOvwZxF87zTdhtJexuYPFpRF0DL48s6NwhVgD4MyzI", + "x15Znz97OibYkgA6dVvssh95cmNynrhbe1wK3UOaBrmHbuspBiUkhSH3NKXhLJBicg3GtEin7aUtTr5e", + "2p69O/9w1WVXp/95dXRx2i5zl81BeICAuUy0yrJLsDaDdKOoMfg1M/S5Fzjh3sTHtvokV0bUUinxIV3I", + "SffLFk+r0PgmqLYSVIT1gSeMTyOzWpD1yNLLiadBxAih1dldr6R0n4RGAd7V85j7agLGEf02Zgmut2hd", + "b/HY63l/zD3kJ621yRxVMeC9xoBxswpCFCFu8nCCIGq2OYmKwa2x1OJRllrO3yIKKVHnD+03tArhtaL5", + "jZiDM0M3BB2zTMyBzQXcVlFYS5HE7h4/LrIgu78z7FcYXVwdlz6cd3Cj9vvsJ/+dktniFca8BIE+Vhpn", + "ycAYRtmoD8puip3tm3xtla8OxQOH4vuFJ2+Wpa342D3EM/jeG/GdKwdoD/Fc59t/U5L6qoe/zy4b7vcy", + "CtF0mVGMM6u5NMggwYM9ykTOEi6RzDHGzbtBy4BpjIIeVlsa7uTu3gLgmyPBV/k7Hgm+LZNXEeExrIwW", + "K8d9MJN/C+jenc/vG9a9Pas/TkD3Bm7/gmK67ytXXvlKECGgW1P1BMqcaJNrO76KbZmB9JZeuk9q/N8i", + "Na58akgNRlaF1xbHCpkyts+u0F6zehEEn3fKp1rlOaSskFZk4YF9UEpUd8PTWszB9NmVBm7Riy9kL9dq", + "4q7IoWwPBtNaYHte4g5EmmH0xQQGGV+owoZ7wj7jhhVSQyZQiNPKdgryU2aftAH4m8hqFVmBOOpK6Qkv", + "fWsxtEl8NcmoLc/iAv9eBgxUB8N3rQR5aFBmSZRvquUDZfilX3+KXBq1GUKbcwA8KM6ksK+5yDbKgiDa", + "KEnDWfcj8PkhmfiD9vuJGW1p79/YbCObOXwNxgiyp+eyGHp24zFjIW+nyBnYqcL06pIMfUSRhZycsXRU", + "7xWliJe+AXtUWHVkLU+mW3hFcRObT3sR1NtW3BTVrA3W0tADjAgSZlr6ROFuygtjKYIhqy4p5MXBchCm", + "z94pNi40VSNaVtG3Isu8+i0zPT1rfwYOjgHtGxtvZOMS75+Ml1sR9SRKs0HXvgRCv/rrwLOBU5/EBo7A", + "A/2zW9DA8ImkyMv4El9SYVxk2QKVrNKhEliTH+t6N7LiI6reC3iwHb50qojE4MsWyCnJgeCaS4sSDhOe", + "Y8ANGffHTRscy6QYsOgNWYr3Cw4Rq3ly42bzhgobazDT4GMQhuVKSPs5xcw3EbOziPmk0uUhkiWw6rYO", + "Aazxt3T1Z5bfADJZLQu59O83OWkb+K6IhtgmN8OnKhLZ6ubLQQuVioSZ8tvg6QhvrnMflPIZGHDpAN/4", + "byP/VWh8evaLYWc37stlJHrhR27gLy96IBOVQsrO3/1tS+IswTZaWNhon7u115zxHSmnszSDjVEJQZGJ", + "NERNL8UkcPbD4eHMsN8LAdbzHHnDpWJC9saZmEwt82VSMfDdbMdufumH8tvSG/Q3DlvlsLoz8Ql5y9Pd", + "G8VTISdrL4WrBJjRqHB/9TUVzsaNUhUO2jzTwNOFg4+nPYw6ckYjxwuuu/1KxXItlGbDcHY/xRDnqL/S", + "CrvfZcNCZ8MuG4acJPfvMpVoSPlOQw0+sdcBYFirYvCKDSPEiFlwOddUb53lKi8ypBJM4OGWJdzAtgUQ", + "HolZWlH0TT9t5B5PoU9/AV2PpEeO0aEaLJtwVmfAMGI5rRBDXCaRQsI11FEZwnjY87uQJoVporXfvDNL", + "gn358vTiYnD8/t270+Ors/fvBhenrz9cnp7sXkfciYtIHXF8uQq3Q6XFREiOvqclMdL6aOVWrUmJ+ML+", + "pP0L/+nVIoeaJwBXWEm5rWeR+Gzbn6W6lRQKapiQWNaPnfgUxy57DTaZdtl//nTRZVS0pssu7SIDMwV3", + "rT2b8Ql02VtIBe+y18qNuYI7e+UutV1W4+5uVTKty95yKca4w3MNY1rjvZ2CJjE5U3qLws2N0ug1quhW", + "BLk21seDMHRS2VbLBPRhdYKWRLWnF7/1XX8TvBsFr0fa00vcFbw8sqwN2ccbK4OUacpoJzRLknloRGXP", + "tJa5tsu+61lvq8XEPVhCdlvfreT35Ni2VcydhW/6WBZGyBQb62D2KJo/hWme6d4yz3jplnNtnBzKNTht", + "TQIJiwtEwSXMQAMVl1vHOegI9KrC+P2aIqNeOCzMEGcZerFpaSvhn3O4YaGIsJscGyOQyvvb6VWXnb+/", + "vGopHK+MHQTxE8fZSKULVC1uloPzD1flJa3rDsfnXGR8lEGLKqOjxen1PanHDPOcRzBWvr5OGIVowIOh", + "gV4DNoJRF/BIWrvLCil+L6DRzaB64PmmoR+uoT0Zd5sirBI4KwJhO+VNXVV20N6+DYuGBMS8uia+dpuu", + "uS3LD5H8HVL8cwEN6+KLI1JlyNil98HPYwzUoPDNGtjCGiB4fQpzYBkzj2wPOOqMIsljokHGlTjFSmAo", + "jODOsrdnb0+pXM4nNQn8zuo2wTa6zhs4KuiOddbMTMzaZHR56DBhCSpSnA4yB1M7y7psuaHft7viF6+J", + "HqkbV5imxd8QnatWaeL9z11Wtm7cv6/CLIvmB0ZcqxnP+QRO1OyYkr7fKJ5u4UI9ef+2MSDUuXPk4ybs", + "p+WMOBdqyy3r2uV88uCidq2H+qbtWrUdRvymajbw+f/ofXxSr+N6LD221zHNByXcIoKP4j1moZYWo/dr", + "SnoWkoW3a259IaIVFhg7eHSxwrgVc0RxYJcQd0oBG3vOFESsYRGz/T77YIANraHiQrfN1/NIoPxyz4zG", + "yTYy+xsM6t42h5hCwFtyiJ95sHg7GH2xmGJQvdZZ0HPAakBhpqkY41WwupvPhSk4NgcciUzYRZ+d8mTa", + "GEBxMXQVftbzq7pD608nVL49+20nQ5pZA08sPzw1OxrZXJi1mBWeORu0tXf85nLfk3aZq3UOGgEgE2BX", + "YgbYw/Do/OzTKrHl433TX9vRngPYJ6a8J3Hf+gimVUCeLGVaNQgapNWLlbCrPV8e+xDVTEMcsxw0Vjnd", + "j+Zl1aE6SMFykZndE9ECO9UAx7i1WowKC2YD5+GRVnlvytOBhsSZK0LmhV1P0g0g+WIhCaT0sIiVyHCS", + "4NXDMJSu717lFJXw8uH4zWWc5NFciOSu1dc1idLhPiWMx9WeM7oQEiH+9M3lflz1r9Ckv9DtWNw0FDrB", + "v1elyhsgKmupRmsFiFif2SjyKn6PUevmzMDlZIGlA/u9VDl6WxhBSb5RXbzheuIu097MGxcZO+fCXXPe", + "HJ9/qfrCn+ubntigJ5L8qdVDHROPrBayJL+nGPY0XZE0UfRDxbCvKRKVPiKtpg/8/+b4vKonJ8bBz9ha", + "X3kQFzbu5lW2EV+ad6uEY6nSdpF58v4tcx9EpGZtnbYGUTIF3bLtC/xx242/8gqbekSS18/X9ygTL67E", + "TMhJ7yjL1G2PXsniCdbiD2iv/sc18JYNUXkVZn4veFMfVHNvemGuz4hRcO4ITGk2Fymo8FNLseKnVXr1", + "rTkZRth7Ar2HC8WMs3srvc2aTvHNt/zq5r7syMvC8M/hwiv3/k2dbVBnij/5RbuBiy/cOYc2ZkXOX4tr", + "7l2Z8rUdx9YL/PuGgMv8i/LiXWiIvN9nx1xrAVj6vqxzPaYOakKi1BphpWjLfLX3LsOeRaEqfd0Tt9yP", + "4dNKhyVofZMR62VEhawnlhQxvOyW6XI/rV61/cYvdm3W8Q5u2fqGHaxs613e3jf07Mi5dmZx+3nO8YPV", + "I1Fz8BH9vdal4pWP/6cdRPp1tHSD37kZx6O13Pi0nTQqGrDq0dpeUOBRzfKqqGhrVlj/3hIaeWKAUMtD", + "XNn1Y8nBzqZ8DtT0DPVc+VRvmrTTeHIpu7gLw2rT00sMdgHAED12JlPInTVM9cDraT2vGGdGyEkGzH1B", + "KckUfpAqoH6cI9SVYuumm9+eaT6HPvhETzVXfPQ+B7nm0VHCbWngWD5yl0MvTxxYFQ4m28aXGQnpV1eK", + "/oC0j3RN48w+ReqZEC3KG2V2hKkSuHwdTreF0HnKqEahvU3JWt5eaqZp1QynkiuQ/JxdGUvh6rNjJU0x", + "A+3uoZShtmSnYeuW0K5jivVMLJb4EtbZahw9+YJnO6V7PZZV1sTyN6NsPRNaPhoQXX9S5ruHTYa7jFtO", + "VytttrxF5ngY8wk86yIzKAkUCC4XuxoZVSeeWNswCbfZolyKj57E8rDCZhH3DyUeZF72uG9KqxQFSnwz", + "UTMmTFVznbXPsUwia22YNSRyCXouEjjW3EzXCOgZl3wCKdYrFQkwuBMWaxDCXY6VJbKFsxmc1YJU5evJ", + "U3SboyZule5V2SVlg3eWKhSNvhtWXWz+3//9fyj+tFoF1zXU8B70zId14hW4NxFz6BW5LyVLrd1Sta0U", + "fJyCDBFofhOErYLQE9MgIXA9oSBsw8vuNVVxr82KqkvHKIupstM7YTFilFrXi4kjV2cjYPXuO2cKlJmt", + "hUxBZ9jvLnimiOd0WUgsmXIpIUPzBPmCkk+IIUks2kWXXGBiDMkicRb6lBvyddPOw8suE5Kslz28epTJ", + "Ofv0BHR2ghvVkCsslxjhIpw5Vq51i6X7bIhMW+RDNgMuyasUDp4KBxey2wTGNGtnEaG1xtkUeGani7LZ", + "HFZQ6rOh/+8wIWe5hrlQhckW5ZjGCk3hNZzwOQziGwqYKOtUMSeFQh2msjQWYtlSdVarHS5fMVlVi2sj", + "FKoa565wlXshoJVKrRo1Azut1X4y5dWq5CUCZ6fb8XDodDv+RFGhlkedEmcnZQFf2mMAQZ8djar8qhhs", + "3GKsyFdL6UXBJJxBzDIl3dCyshWnetrnZyctMdYegJJHX2K0mmg+azbP8scI8PRdHrHkpyhmzpyfFdaC", + "dv+iboQ9emLr8VwMtylgWN9T13PFOlGEiua9mv0ssmxNZbI3QhZ3jLbE3r9/27sRWYY1B1HvYdWUEglC", + "lt0Wf3nbZ5f1ru3DgxTmBzczMxmGO5EjMy4rdsCpS1Hk1/RKYwYzpRclQsmdEOJivH/eP5uh58rPiSWZ", + "uS3FnSlyBygTlyVPqJFXwP1NIbcrZATWQKnZwJHEUyrkOFp218dun0vquHmI9trmiZLGai5iHPjrtMkL", + "kIiUfAWBFftsKJWEoC4mmRrxbJVbXrHhDGZJTS0lE62KPHyJ2EfqmAr7ig2TvDBgh+wAxym9GOQqE8mC", + "nAvvPrw9OqA/9FIt5iCRdyvxrKTfsmEqw1iDKZfsh/6hfx9LRVr2DvFtaXSRUKenoVIzPNrLIcuEhKaC", + "cYfFZJNZ4nQL7ZP+UO2ypVPrbDDWAIObUaTviwYIbWQ9SIRkP4sfQ9+cerCE21yXpaAxIbMMWBm62V++", + "G3pWE7KGuu8Mewuz3pkcK5YWs7zPjowpZuAw8QLXoUIi4g/os5PgqAlJSxqSjIsZVj1PnAES+lWYGc8y", + "/w6JMe+cZe7ahVgbWGV5NrgZDbFmu7GORh36CeJ0WIdytxQafmzKdUodzLAYp8emFyOBCOu445SAjjsr", + "D2h88bx6p9Yau9e3FhFc7pcHo+IdwtOwi6O3REUPQMfTQGGT5eOVYTB84nPQjy2GyLGazeKzMYwxIZt6", + "Sd3uzfgde/aDs/K16dZ0ReOzloxCY6IovQCD9wJmwJKyie/Ko3nPFLhvLpXsaWO6bCwyoH+hbTudwcz9", + "536fXTkr1ZcoyKcLI5JK+tXNQ0fmBTalbyGitiZR+cByc2NidJqzysgYYb1ZPGXPgO3hKf1SMzWrtTMl", + "ijUEezclvV0sWUsNUh1euS3QDWPI/MvI6Sy3i3VE6R1g7ttjjpcBbtkPGP4jnFmk2EgV+KRDWguJHYlV", + "WKCymrsaNm6fyOH87ozm+KGEKteaL8hoEZMJ6MEmBvDf1a6i27Ci73EmUyfJhsfnH16yd86Sd//jGOLl", + "0Cfw1nRLBO9hj1szWEloU2WA8SxTlIBbvknVan/4fVvFhJyrGzKYK9u6z96Prb/e4BsaN2xY38mQ7dWm", + "8UxUS44FvY9BFAmXLBXjMeh6m0oclNA2/c8OpnORWDHrs7fb8H8Dbm0lG+uwI3lXiohtTTIkqN2ssaPy", + "UdBjhOLdNnEVaoEV22x7vD9Ebm7ihLU6YHuh29Siq1Jpi+Z1hESP0c24rLmu1/nS61nt5MB2VzbviiXK", + "LhPKG89F3c6IJzfOkJXpwP8lXIRvlb4B7f4w5RrS6r+xOE7UQgy7DuX0j+kqIcAcKzkWk/v46fxtpFaj", + "37c0xZRG7HTvrgvh1bHtksBzsXPM2/I5Fv4Uq7UNfq55HHoZX4BmPLFiLuyCcIE+jMJYNQPN0OI3L5mQ", + "pECOzs9YwrPMhDfMFQdG6C/mtDfcWZBGKNmbQUpeeAeRZMqZUdkcai0OHInkWt0tcGC20oCpci96DxbH", + "JpZjzcvrRDXS23+Ug9RTMtzQhizgyAm1j128jyl6Y3kicB/TCkwVNi8s28vUpMtuuZZdqgS4j7t2AqSY", + "TC2DuwRyH1uDdaiqXrZPt8cPlI1ULhVAvIeNzU2X3cAiVbfS3USwW+Y+bi4Uinm6jdXL7h6UtQxmYHnK", + "Le9T1NPkKdF3jsHKy7S3V490oVJcSxnHb47PHZA+rpGXLXvYTe7QoJCfh062oBPrUsgT/WouhXRGYxpz", + "RgC1G8FXgCDY8N/UpLnPziRrFtxSM2F9wI8w/mKcwpgXmSVxoQtge+Vkfu19muno6vinDXPtKc2Mj/7B", + "Jo3OKCa4suGfH4f7GGPBpOqp/BWJsbCWBsuFNOhDxxcBpyvJiX+l/E6Y0iwVhnpGVkPngtPuumyhCjYr", + "qL5iilu4yzORCMuG7mxDN8MQ0TRsGE2ld20rcrgPGVSN25IIQXiF02cNLbWsm/rsPd1vyy8Q3jYg6iUh", + "0KpypLB9dln/APfmvqA0RvrifTDVg967AYd8KzRki/p0PMvC2gIMTc3xmWIOtR9w/pUVkww4Pc3bOCxi", + "PmC/o20f51tthShij73DP7AonDp5vyNiX1OvFDJpR9h4MGXhKaE8IODDtfcgGe93o7cwKue7yvkzMMaL", + "zlUbNf7MVq42oKlnPDfUWQFfWw7czZ30rPcXHpT6/yDXyv18kAqTZ3zBnOJ4VUZQ+gmxEqFjVB9Y6FDB", + "rfCFGurt05o7QTO1PlPU4ov3nnqf++iBCpbL3ab6jTcvlQ8C/Kn+jbb1P/jObggAB+pupwSC+w9//vCh", + "KGaDccYnhvDjQLTZAx7OHFAYM8qPneZ+qwoDvlTijhE4o8LaWCo4TsnoV7rUktWAHskanDIYW3dtEJMp", + "emxFmmbBhqeHgluu0yie0OhoKRl15W8P+A3zhlG1qjNSOt0OvkniJ9EFpipLBzewMLHjpRTn435253Pf", + "1lvq0Kw1t8lq0M+SC0QWswHZUbQc6sPOy2fLnP4OszrwaiRm4Bkrh2CQ+3VX74SRHur/yRKldIpPduWr", + "JEIs9DiPzhQp1vZf95lpiVzvOm7qFiLNR4rr1KeI7kij8bpXx169J2FyKnrl45s3RzW5SaObpfZJ+qiy", + "xu/hqPCNULWnXdKVkBQWPT+579fPSdT32dUU2JDq8ZMNRK3ivYi/ltUsOeUEktd0tTMtjXZAQDsIb0c0", + "Nueaz8CCNv1reXrHE5stmJLl7zSyUQ0O7/BoCI3QYTEXacsbK7LyzMmMTTp2VWB97HZSzSfbDT/RfLI8", + "eqbmsN3ot2oOy6PxZcSJiU2Dz92HP8OiNpZuSZsGXuJX9WFgB0mhjdpokVyCPcYP66MzIAW3dqD7yJNw", + "7TV2NRYg+GlWKKyhh2v4bcCbZg41zytQlqBp4LZx8nCQmOSuJt1wTKcnruDOluBZ5vJ4Eddu51gDt3CC", + "dXyVXtxPec5UCmssjTTMztyHbE8l+AqGp+wyjBb59x9+2O+zk9rl6d9/+AGNOG4taDfd//OPw96///bn", + "990XH/8lng5kp5GwypFRmZM21Sbch2hB49GXFjno/+tm569bKQbME8jAwjm30/vBccMRwsZTXObxN34B", + "Ceq+yf12H3P0nq2ELeuwSO0k7CjLp1wWM9Aicbew6SIPna9r+Oe9P456fz/s/bX327/9y3aZ7Cdkfm55", + "x1wqfwNozLUq3GDa03dVIn9LzQJsJTjQ3MLmKf3XTGPjQsl++oPt+dbkssgyJsb4KJSChQSfwfaji96K", + "NEZQy6vhZ2v3HwXtsgZ6GoPbic0WY7s0ssnqjkYxgbt81O3Qw2VT5cR9slLPaQT2FkCGjThD28cecm09", + "9Tr5z3imypQvi0m6MyHFzG30MIaTtc39fLA/BhBU/f6X9xZi8smnQBBye5mVwYNmppSd/k8syk7+CHSM", + "FFbNuBWJs7jdGUbcQIqxr7ggypcM5MSfg9/ROZ4dHh4e1s71Q/RgD7lluCPsdMmIS8r3GitLsEwYNCv/", + "cddli9/qJn3OhTYl7kJ93dupyGgTE3ytfutMPW87Mm5ZBtxY9py6f+IDRrnT5S3XQ0HKh+LnCLzqP5ZP", + "s/ZHwmWDhh1eIz5tNi1mXPYycQPsR/hDYFU+PYeKmhHDt3xBB2FCGgscqzhnQgL3TvFcZeRBYr/iy6pb", + "DZ0EZpCDHhiYIKURO0A+QCYbzPwTxUSqZnWPWqxd4/PGkX7YkS/LcgO4rxUMntEuVrlhI3+unLN5iz1s", + "v8aWW0Laon1h6TcPL/9Ig2KifYPsLW2PPWvs9dnm58s25V664bZ1iC1NvM7tckp3ufOML25RCm+rDOLt", + "LWq3w2pKDO+PRBamLf4Sqnd98B98zumflB9QzU3XTPzjlBvGsQOx+/27nE/guy77zucEfke3y++82/Q7", + "NudaOHXrr46zPIOX7LrDb7mw+Lrbnyir9r6bWpublwcHQN/0EzX7bv8V02ALLVntc8xm2tt/dd2JBR1Q", + "GRpKR04adPiXFTp8S9LanxGvML49bIhXDeY1E4b95bAh4b9vyPfNtIbA35IeDG54R3II/ViWqKA63erL", + "TqDypUhe7D7mSdjZTRV8fOO3eB13v+nVeyLFIxImqwgI3NweJd7tkxhJQUf2cxnCd6gtWhm5UT9YxJOb", + "qlj5xXIy/9i65WzUcnvdGxjUoQ1po0t3/JmnEa7vF4gRyGuRwZkcq1V5JMwgFXr9rlB/4aNXeZ1r6dOj", + "WsuaOVU+Q4PEBzGFajNlMHfKLfR81cPVSNuo3HHHotvtSFifk9dl151U397pnvu/64672Fx3evq2p3vu", + "/6478YiZeFzOj9xAI+1iLMIT3ioktr4VB5t1lUjEHzAYLSxE6OTSB9zgz31fQS1sQ4DZItYmxE1xtOtr", + "i3UDHdRw6IHeRk4UVNWS5vG6fKPB3K4JtLWJ24b8+HhMKZJb0+F9cVkudV+k7kYlcbeYz4JY5FD3gR1f", + "nB5dnXa6nV8vzvB/T07fnOI/Lk7fHb093SKjgZIZWg0W7G2x/AbZgt8T4f4rZOsU0lftLQsdlG+jPpIh", + "VFf3cpuCg6hcXhVwy8uQfZ4xy++UVLPFS0znobRZ39qrmt1YDXzmAySHKbd8iA9sSs/QslCyxDXaEG4r", + "I8jULdsjDzdtiVzf/l1/2A6HYZdpmHCdZs5yUWO3MMuLUSYwE0vYPjvmWQa6V/3RAwCf999fXrGDcvcH", + "tQgjKqHgkzZC5QZhCLKvmAFgw6W9lPdR7HRmpjyHPvuFZyItiygnuJkQjWsYn3B396CpA4BDqHPiSzR8", + "Z0I7j/AiijZSWmGcFP6M57mg9t08FwO31oaH7aNcOPAQSXU7PkRrgCFag6D8185wTEMu3QiyVsrJ0nzg", + "e+RvmiPNj+nD+tiqRf/m4Sflt+UMFH018NbQ+gnoW7SQlsdnarLd6DdqEsbWAqroAXDDDGfV9/gYEpsH", + "nyO2neVnWMTmIA98WYdl6+nouaJRW6jbycQcBnMBt1si+Y2Ywy8CbpcwXU2zNb7DTKtIDx33q6k2HtP3", + "zT+pjVieTUhhQ3PkrSY7k8LWu4RXU2nwq+w030UYtWHSnedbnavq6r/dVJfl92GmRk/2rRrFVf38u209", + "qe/Z/Ls2Yei1unMf28YcvkPb7v3vOt3Wzjf37DEUZlzqg7F1s4cmN6+2Ndi9a0Q5TZLvUEO8HKV4ukux", + "1jCuVnBw52KOq3PsAMeWqmvdlZI7u1YzqoW3h2IVOxcCcXMs5dfumrrsU8/czWDxDq13MlA/djtKwvYh", + "t8v68WN3l2E1pbzlwBgP7zq0zrm7jY0Iod0mqKThluNidL3D0Lhw2WGCiiN3GLRE8bsstyx1dhkbZM7u", + "69VZ/F6Iuc8MccNw98GlPbj70Ijtt+UkLRbCbqNX7bLdxq+YOvccfg9+bjEGtxzduJltKzKX7lHbD1s2", + "pbccGbXpdxx7z6Xb7p1bDo+qu/tW3aIK22+EsehkiziktOYLd/1fdW8JSd5WTL6hpN3+tsm5pQs58i5c", + "qttIfbVMTZbzJWvNZNdGjC93xpiULwoW7mxrJ4OWiutXYub7AZU7on5JlBO4rS+65ZmuvnTMu4YBFuc+", + "mvWitO2X3fHbhtmGILb7h9e2zbB1WO1KNONukSiPGJGB4X0PjMVIhbFcJtB4oPvhqSMw3J53isB4eFiC", + "96JXMQjun1zaJSjGHeubyLMK8QgUxqy6F5luO9NO5Hr/GMEUjB1sinUEY7F7tZLlC8+mUMFux+hk08RU", + "dGjrOZffBcMC3dopYhB6f1OXSzs8HP+Nivey9z+XnaBX5bq62Ui1Z1TMG0x4+exvfvVUN9GznHObTH0Y", + "4v0w3haHeNIef1gKiucvDnePRjxpjULss7MxpSpC2mUFVSADNhWTKRhbVTikIVVbcyQfr2T9O9JfDrvf", + "H3af/9B9dvhbfIsIWu9Q24SvsY9S0jAuKD9OA9YFQBFcZVcrXQWgHmjAYwpDCeEQlzQ+26vKeVoNcq1W", + "pzJ8IRPOF+qrzh/eIDGjz1BKIeMpzynmWcJtqJJUhWpQxp+D5RR4Oi6yLuUlhr9kLeTZGv550hr2WZLN", + "988PtwsCXc4FuJ/m3RCgGbRuUFuUhr8wFJW53OypRqIO3Ydd+pZrYBZLxWyOAVujSMug9tkmjXoDC6o2", + "xYwDjtfo2yvY+PpvfGijm90sZiNFlQlwId/a2S0RSpePgPHat8wUeVUZ6S5VVqnsWu4ZAPafz57hWRYz", + "lsIYywgrafb7zAc6mbJi13XnAsNfrjtddt1BnwT989jqjP51lPk/vf7hutO/pvBGioAThuIzE9wgz4xy", + "u0zUbORVlvE5ATTfv9kQOYH/hav92xUf4bQ7AHRJWiN0o/Ka6pGc3kHyaLFs3B1vhvGSC+nkiMSSqZFK", + "GHrSDIv8R6S+As3E9aQoe7ptT1XcDLRSzaDG+DGKZg1SrMDmhrJci7nIYAItYoebQeGTjNdPGVofua/d", + "VLLIUHsEGb+aKUlnj0QqIKBDUruZQpaVIHe6oIh3kEluY5UAlMZCqdVldY/XIyv2/Yz+rZoWoZaAywfY", + "bHOBnLeT15+xeHaPsz8/LiPsVM6FVhIvHmWcIlbA9K0b4tV2KspfiTXcLbywHYHtUYSEzo1s+KAQQl5n", + "uhJh5TlWmXDtffC0PH/bZTBeyQjuhB3EY1bPQy2nUMq6pTAvRhQORn95EQ8o+suLHkg3PGX0KRsV43FL", + "jyCKKNx2MlXY9sk+tmPvZ1Gl++2Gvksq5I3UK8v+ITXqbaKM6n43hFrn6vTibWf9vPWwJv/5z2dv3nS6", + "nbN3V51u56cP55ujmfzaa4j4Ak3R+2oTqrbHzq/+qzfiyU2zbOJyTHRm4r23ykr+icqKGTWyWhfv2+1o", + "dbtpLvfJjkHqOGuXNroGYpc5v5V1gG1V7Caiule7IfrqdTCwdrFZCx75rxlnuYEiVb3y9HvnV/+1vyxY", + "q1odVX2hOZBGalGXcaSFRhXLiKMLTf0QGDa1nNqwA0pXVnKf3X+Zj9E+jE283kOen9UcxnzkBBJnxs22", + "jh+itefeX5bIaquBHqr7xYZfYrWvXtmtLtIrpbaf0o9bFCKNC2JsLTngNu4npvrTKxXh/bAdXMWtrGa5", + "LXbtdH1cKylUGNKy7VIpLwZ5EmvMZqyYYdzm8fkHVqA/PQedgLR8AtE+yGvUaNUJQjSrF0658b1UtrFR", + "qIRvS+RzteNQEDXUY6Xdl0HRLRo86m45r3BqG5G2VZcB2n5cF7UjNhXyfkrnhFvuJNmtFuQAXSI9SjoQ", + "Mi8igdQpt3wrwyKtr7K5CUA5728bz/wge9Ftxyd4Gjfd6gndFxZkG5FUGWH4AfOf9zvbulT8UTTwKqp9", + "F9vp8rSse6vBd3+vNz3x2SJKrxR6eyg2y4e1iljcKaImKMTf6d40t7QSfu5YIZrqu5VoKAUpTS4Mu8aB", + "1502lnX7j2gBcoT7sG9Va0WQTAt506yghMk7ZUrQlkxMcduI/4f5IUYqXVDrPpoyVJMjAEjP3cuh7Ou7", + "DVdWtu8g0Y2mDtTr8NVKWWEFyqqcYjfUO60Xf+xiVdDuNh0q2irD0Qk9J/Qf3I5iQ4rE+q6s25bjoBIM", + "oOMpUmMhMZZ/G2uhqrMQRrXZChvdLmQGrf7ZlAUjar83sn23tm2q3fpB99zsEpzR5qrvMwbzKlTnAibb", + "lDra7nnmJ3qWKcteTLyvYE2RiBaH/a/oqN9loi0f72mu74xvdjx2QlJLeNBz/g5zRl9MAxS6AbCbUHaf", + "hwddInpDvaImYUQldbOq0a6PuZnlg7v17x8/KS3+UBJr5uBajM9UIW2fURSHu1/i3w3DTNkukzDhjb87", + "PMQVHO1gQ4mMX9yOky3WT9WtjCxf5PHFHxKwUNZV2t73vYkruPWVJKviT82ldmeKnafcOopgpSLWjlJL", + "pCnIDTnAFO1QPSX5QRufwv13Ldt+LTI4Bz0TWOja3G//2OAn7p+i3j+UXqnZ3xqX/F3zeCOlqv7y4sX+", + "bpWp1K2MPYe4veJP+AAS9vuhZb/b5HxS+mFewZZePemBDV+e0/tWjVqTg1svsbZjEXleGKhn5FM55xwS", + "x/tp6WLf0UdffzDG2moxF3299kEjtupwI1PWF48CxJkwr82v3CaPWgisrNKGt2YsmBivXuAYV8xhs3uz", + "5HY/HyvHZostQl5aA3gQAg8sJ4btwOMBKheVbRs+cige545j56C1SMGEuvweAvt1nD8/3OQrjXoOw9t/", + "xOdXM2CpgP8jFTXDTQeCPpOXRMDt73PVPurvUyFOcT101gJkxu8w2V78AWfy7Y/tO8Bg39CT4+2PW2Jk", + "ucbUs5a4JXe6oyIVajNxH4cy2+5zqtOFHRznIgXVZxdEyKZs8Up8MOVzcPdjGuUD3rhM2XmRGTjyf01u", + "wFYVlFPqWYNZ5MyANWyk7JSNFuVRfSklX727QS/C0I56SrYyXYTBVP5Q/lI6ATfPZkiezXw7i2yBzVcx", + "GEAVlk00T2BcZGWvaZ9DP8PoMfSoCYnhD1oXuYWUjoo0En8N2aWIICHMbegJKwhWye5yDpnKdw1JvMJC", + "bTSUla8GFhvc1aqqsKVE/UjrguAvW1sHtFkuAWus/t7qcu7NlFRWSZGUMUqMfO3VTnmilTFlx+N6azFi", + "mj77YHyDvjfc2B6u3Ds78UF4hY91v7w8De4y7yUUhgqqURzPSivNHV4V3RmDQ/G3tThsyw1YqhNBFaJu", + "hYZeBnPIvC8Jaxtgvai8VkPCY46BTPE81AkmRKDJpdP32ZEeCau5DuUevHlJTXt97YiqUoITYClN1mev", + "V7rprCto0Y1VosAdg+6hz4rIhqUqwVgiKBs7Dr0T7F99iYeDpb+c4Ly1OLEuW61jsakL7ZfiMqwQ8h+X", + "79+VHsMYtDNhPJTWV+egYkXkg1+GfrNQdQyuhBbfsueJeuhegg0045Vf6RtvbalrneT2bejKtrrbd9XF", + "FrqNprqNfrqN8r/+/qlDH17anY/r3LH17tO6bEvcX4bnvXs8pLb18Ij0ysoz0eJT/ZVnWS/JVHJDIKuc", + "DzVgNvulOPz6KSk5xYaahNWOhAlVYby1s32xrKSsu7tTuxHfZOTeysvrp4wbu6JXqwa0GgzYoN+aYKHL", + "crzF8w4XRX98OkeUdpbKdu/sPXxYcdsbWBir1Q2YaEHKaMhHvGjmvZKBQpRitY+QDFVLCnKS6A67iWZ8", + "0b+WJysNlkIbyVlIAztIQ2nifWqq4+RWiKK/lj7s2YkAtxa1FpZMhdtdbb0GpNge/u1/Hjq4+Fyl/f61", + "rBVJxc4LDmqLnLTErdIp9ktN6WHQx9GWJxfSat5zX9GC5lo6K0Byqj2F6o1+znlhHJ6cYUJ7852YTSi5", + "GkVdtC1Tt6WVhCNFhCvWwidlMFUYq01dHFpqh6mBY5gE1tMiNmOacqeuneW+yBUT8p/USBgTRl6xmTCW", + "3wCZPagn0aJAmI14cmNynkBFBOywz97LbOFFmIlBgO0ZkYG02aIBp2tZfYa0sU+gKi+kh/1nUaoPsSjb", + "ttH4VQsLZeOP+zH6emw1ojRCrbuw4H37f3zEZnz0CIlFSstOiuwMWx+yo/OzTrczB21oO4f9Z/1DdHfm", + "ILGlY+f7/mH/e1/pDQ9yEJJoDqgJELm6koiv6y3oCWBCDH5JJAB3wmAkg5JguqzInfJhS5NG0nDmwl22", + "ctBzYZROu8RkWIW1kFZkCLny6xOYXymVGXbdQXNPCjm57mCyLjXbNkyN0GZK2QjGSodyoOj98fliSEwO", + "h+S4SdHbaZNpWOW1b4LkC/T8qNKF75dfNoapcpMP/mnIt0oaM/IwHKC5ZF2EIxEMrWIzBKsvT/mP606v", + "dyOUuaFcjV7Pt4PrTfLiuvPb/v3TK2hDcbKqvnP8SRlWmKqH6zw/PIy45XH/hO8U70nl0Tyyl4uUfux2", + "XtBMMcujXPHgRx54ksokf+x2fthmHNaKkDzzo7Cs6mzG3cWm84HostxixguZTD0S3Ob9nnFYRb1lC61N", + "XFEY0L3QhqZaBrB2txYGGLUjY5XnrYzzGPHy576jqu613MgubHduuZa7sssxaCy3HqDAZlzyCV0nfV/n", + "pe6nSMXsNHQbu/Rt/brXEvuq9rAeN6TljHSOcv5AhujCPT45Pwgp2Uruo/4ZOUsa0muJ/ooAy42cfV51", + "Qrsvc8dVQ8yi2gb5ffZzSIDzP0k+A3Mt93yaldemx0rdCDAejtcd6tSK9Y79Q9K0nIH+2r+WlwAsVLum", + "VnDVTvoTpSYZlIR9QA88ZZJo+DuB1NfKduf/kRuRHBV2+n4O+idr89PQtpNgEN0wOorcx+ZDPtE8BVOO", + "8kr1Lb/zJTuEkuYc9Lmjk87L7593O+cqL3JzlGXqFtLXSn/QmcGnzNVK3p3fPj6WXAu08tWKtmWyc2dp", + "l3BFnime9qoGgT0u01741ok9ZSKGzgccRjVUNZs5CVJOwf4QOeM6mYq543C4s9idz05hxgqZgmYHUzWD", + "AxIhVYNGc3BdHB5+nzhWwH9B91q6+6B2Mm5WX4HktpD3MDRKyXktP6GhQfAqBaM5kumFh/E6mTQrMity", + "bGyp9KwXfGVtNketzWNrlmr1jTM+CP0IE8yL4LZRcmKb7vSvVeZwio/lVrE84wn4iucBXbthfemB4Kj3", + "d97747D31/6g99ufz7rPf/gh/qb/h8gH2L1yZYt/rwgy9BDxIZeFzCmBp2Kfctd72F4uZNjOuBRjMBZV", + "9H7dCzES0nHiJqu+3J4vQR27maw14GrYvZ8V9ywWhltSA5ECpN2ItCOuKZkDm6Ty9HPLvRURVGKzRuR7", + "3DiBZPbrQrA8opeG/i59MAo2XlzqnYbkYcnUUl+bpaaKhh7ZfMfF0LG+z478r6j5KfjImTPkLbOCZ9nC", + "N06ZqqzsMn2XZIVxxOvMny4ziknFFIYJYMQ/K4WNYQmX5KPIgM8Bm2KEWA5jVW6CE2EstLG+5UHo11i2", + "NxdlsQ3yVoY+jNSL9lqGqtyFwadGbJQ79VyVAqUtuXth5QfEjBSqIuNWu4EFNcb04LqW4f0y5ws3i39W", + "YFoVMu1ZLXLmTEeZUOA0YFa9TMVcpAXP/DQxyfsjGoLNxpn3NwPX+kxXV6p6/93PGMEpW3o+fE7eKxmB", + "moRGGaBO00tsttSTMzBbE3FVN84nwlek3ec90UQN0kIz08DWnxVDl2JWZJQlSVxXb1ccdySu4IjcVQdO", + "1Lej6QJ4elxzbcWg9VjoanbqRWwt3b3Khrt+SdRTK3zzYOi6Q5NnuUyvWfHytYETfYPt8Gw6J5+I9OMe", + "0PuSP3o9fUoV9cEPWPhiBNav5JANzvQt8FX2wI2jqYz1fSIMrXbX3Ro5j7J+rd5XjM8oDHkuQh+I8rb8", + "xWD8J5H6yiPqtl7UsInmZnfnuNWHBZXQasGA9yBQqQ1lt3ykcpYbD6UE3bLa0qsQhh7I5daUEzEP3f/I", + "MM2AG0Dbqt5UaUPfxJjFU3YBfSLSXO1zfU+54Sb6QtQlbqUqF0lo4oiHJYqZgCWCGZTt51uFxN/ANkp7", + "PqV6jNcQjfMuRh3QSctDPAYU/wa2EdjgLQ8SFmGlbYyPZtv0OHDLEqNPROarDdkfZB16KLiTfV5Sfxsq", + "ZzawE7RiGehfSRqzDcYarerXyNEQ3Fuug8/4KDNr7/1llgH5yat0l1qNtWsZq5xGIWJY3SvXMAVJ9+bV", + "Em1dZgCupdtMvMwa47Zyo0+E7Y81QArmxqq8r/Tk4M79v1wrqw7unj2jf+QZF/KAJkth3J+SPPfhXFMl", + "lTb1wA8fyxjO627UPoY+8aDAbAnjXWiEBZVGXzx83b8nYoflsoL35QZEKFLLl2QtkI6v+5KQLrcg/Hqf", + "mjZRdcVvoMpcfCqLcSUB86PH0VqNg2GpBzklDFcrbfZuriiWagMU6/pZEVpmGrAKQSEIbQM6VZa1CzFK", + "LWVzn36ZLZz1dqAcb4eUUPc3W7PxapK0aS02/HyN4pXeDGzkdvpezpJlaoKZn1YkN4btSWV93jG5OGsU", + "xEYw5XPhSJov2JzrxStmC/TS+db1gYFDzBQmSVRHoefGkGqKianed+mfurv1aNUQ8oMvPQ2X5l45B5rC", + "1QL7FPeBXiQKFgqR3UEUDkNsGDkwej0NOXDL3rFej4KuDhm9IJBBTm8Iw5iEvAwZnk/EfrWc4/tKR09e", + "X4gPiTZT2QqEHm6dZbyDNReCfluEow+4fCK8LMdzPsjJQUGEX4zWcmcjp8Y6LPgY4XaZVhXQDc+NzP0/", + "CkNeLIcno9Qqn4iM5c5AsyrPMbUiAbZHAQnda+nfZKvXmK4THJiN5p/jujWbz9dANuIPISf7/tZcLiTK", + "ClsM7nhis8W1xOUaL1MaeCqk0+Xu9uzu4xhFHdYYUt3oQmdDXM+LHc5GYGwPxmOl7bWsmnCV1aLDrOGV", + "ws2Mhpq72PAJMEpP+NHJRoeE0LlTz3iGoaZWXcthMCeHvusAlwuENFuogqUKQ6AluB0fWZYBd0arDI5l", + "is9wX+O75AiYryPUv5YXIXCmiStqeK8LWZb5xWerl7X4mzpuPAa69LzeReNYLmOsH0WJktmCsO9VH8iU", + "AmPLFByKWb+WVnNpgnn7kokx4/i0o6vwH7dvfGxyG+Q6c2qxYjqGKYOA3XhDXtuMC+noAdemQOAEPK26", + "P0kle8/v7vx7V65VzidOIfev5bmGMZrWDjxOjRnIOeavDqvogn8dUirQgYfREN/zfHQrsU0G4XWxZ7WY", + "TMDZSdeScECcJCTi06ejVuH7MWUVoHxc8u8jBgpQWNCgHt62FN9x9br3P3zuTTN2ic14zv7v//4/DGO8", + "Dcy4tCLBysHnR1fHP7HV6Ll4oV//1aAlULK2A3rjZsM/rymI8brzsh4n+dvH4ZYbwtHR3Xi0brONmRMa", + "aJnE70mrzQWGbA8LqBxQ+ZQDsEk/pJ9Ske0QUL1KQBRSbrrhfRaTeMsEkWVpLCpR3AhbanBqk0mjdcDW", + "xJGc1sN8DHohw+4Tp7GSAuuMVFP0MTKEjlFlBqyNO9rvbw5CeXCIyNPHb2DMuBsy8LJzFZqW6/4fxsai", + "UzDtCwyCd9iIncFgU5+U6IWzFwWmz7w4C/FXvgAFVgn3XZ2qwEE/2P0/c1DrFo8WvIHMjd/D53YKtWND", + "H+Z3QKvgw/5wn9JNhw5u+aBiiSFpBRSRhG4fzxAOa6e8jK8xTt/hB7ea5zmsdK/fiC5f3Mop9wgbX7wp", + "X3+8egev3CspvFZ9l76gLstATsg/n3DiNcueH774H1RcsFuxnkNggsG+FEaBMsIjgHYxyqClGHQTlmuM", + "tirBKkAQXw+qsZSRrUVOj5VLNFlSxZ7TkWWdIJ9JhAXh4Y44cmNq9hf1RNWwhLy8fFWZmyUVuJkzWH67", + "6j/EsH9x+NfN49wGM5GsXAce57F82XoI14dWOAEaXO5/UZaXMd0py6ccQVy/eRyhPUPX9rQ0aPAq77Nz", + "m5ZonhVmBfahftdBTfuWUfaRcG6vVZ/KwRnpCvSJKdqvHpItV5H1wb+yhrtSA8ifjWIfHLvcchxHGmNz", + "kGjgFgZl8wckkyIWMYQfliV5nipsqLnKTqTybF0FITrnF+ReoJMyjjlfFfgDXlJwYnMLvJzgh0+NF1ql", + "3sXt3u/SJUroiOnDOOvF5nHvlH2tCpk+4oM27pzxdrwFO3gNyl6TuftlYwvrw/03QBTio8SRupXOYnbc", + "NfhDYEGgCdhY3S1baGkYZ38/O2flXaB2hwhXg7JETFXLLZBGfzWGxK9/IvTfRY4R+ZrPwII22POhrcth", + "yTlog1pV2vrONAiHwtudG/d7ASgO6E4Xqto1aaBbd2JsqpL3207K2cP1QY9eDurhjGUlJCSsOoC/Rrr0", + "yKqLEHcbIEILF9o4vRqbbkGw4e67Z7muXYBn4XEY7VA31/5aur6Wawib/d3YlKnxGLRhRkykGIuEY+r5", + "mBu6/tGC3n69linU/+T+zTXdAP8QuXe48GQqYI49YsEuz4JsFI/MqnGVg9HXwlbdP1c7npXHxQiGPvtJ", + "TKag6b/KxsnMzHiW1d0Ro8Iyy2+AZUpOQPevZY8wYexL9r8ctmkK9qzLfOK/QyykbO9/fX942Pvh8JC9", + "/fHA7LuBvrBBc+D3XTbiGZeJM6XcyAPEANv7X89+qI0lxDWH/ns34DMM+eGw9z8ag1a2+ayLfy1HPD/s", + "vShHtGCkRi0DnKZTR0dVyT38q6q75EHV6dZ+oy3jP0ysDv+uUtFz74PE4tWSX+v/I6JxyZ1Xikd0uITa", + "DV4sNkVD2UF9W5mAksCDdaWZ+5eiYXezCasu8qsEhVZerUX9V0g2fwPbaLIfeiatYK8km0wYi3a6aaWb", + "qtf//ZTJ10kp1akjpFJd3zKqTfIV0gpm6yLmKZFwlTawO3zb9S30M3/C0NjHuLphKGrl7vgK8YQnwA7W", + "+Mq1jpk18LS8dEd5+QJ46q/c27EyLhZMQjf/l8LNKrFge1WnngfZEij6o3lcXxmxYNZY47muJA4DJOgH", + "tUrxrdy9WrD/6ZKQWjoD3Lu6Rq0Qvk8Z+goReQl2ldHrRf4PsImAmYq8xDC9gLYHYWGdE1N7KPW540pX", + "8SWkEHyovoaZ8jKActn6LVUngnnwaNEjpUXS8kSfgrGDDc0R3De+uXgpwXzVNG/QbtMWodu572u+f8mv", + "trpzOQaCwqNVYkAslUUYvnZRFynOMPb2Wp0dgmtzbZEZjo4XikHDNtFUT0ZYU/k2V9JXlumrjTnIu/lo", + "rLEr6af1/hG1SjlVjITajg8eKbJlHT/ck7D/LvKKrGsI/G9D5Lxe8GiJRFfo3TtXNhD8rq7RNr64lpsZ", + "Y7OLtOERvZZLLtH2ckfex/lozNUaRXU1hWXXS6lCtogb+mxMG4/yaSvW+m77QB/flMvvDYsZYXlfR069", + "Hn7Tq8bt93eroRzw8CTi4sjD8L+5yFgm1xaxcbtckGjpJlBra/RUd4BI56TtcXvP4ql47Giv7w9S/F5A", + "rN1PxZW3Hhxbxast12u3yZQ9do2/z0RsdJi6k9oXapKTmiWG0Dr4M4D8oy9jDlSkZJneVF6R25KTAh0P", + "3tPg/Q4lHtf5Hja7Gl7ECusToijY+StH1CU28Alx5TFv3zKSDihHrtWVRK2qX5tT+uwT4mrZLWThztJu", + "o/6gTe8Bl3i19a1zIjmnVQsbNa7dhX0OIbYs5Sme+s/Of/YuL097vnxQ7yraiuItpIL7autj7BGDrTd8", + "SuLeshDbb7zchVe6FVEXeZT7+DWSKfUKWoayL3lCYrekWHeZXx9khEV5tnF4ntSML77i/PyE797vq4YE", + "oSllaz/KRu+Uv7x40bZNbOLYsq21XSyJ+bbR+A90x97Tm1GWhPra1Si6pZzmDPGQVahWpibmoAJs/IlO", + "TQyxToscXiII311oHeUGQeNJvKpvG21lH19mrLJM3cYjDxqNvGtd75bRjAkeZdqeGIfmfMIwv7U1jNmu", + "VXZZp3b2+GrVB4Oc2tR0PptGe6MmW6oyR1hftPaKaQa3acqhvLw8JQbJM7641ZT2RkUjtyivWjb/Oi9H", + "s8QJW3wLHWsw01qLWkTNnWV8woU0dBMPWQi6kFjCWSrJMpXwbKqMffnX58+fU3YqzjrlBjvIGRTV3+V8", + "At912Xd+3u8ooec7P+V3ZaeYUKXBd1X0sRg4Y7U5LJVrCy2rRm6BvGKOEw+C6tzHpB2e4ma3stZnynqI", + "7MMBNJ6sUgL3SyyHWh0Byw5c4s6JIiLE6RmEZBJyR/tF3zfYcgs9WX2fcoXPRAeNHbRRQFXNWPtvvogy", + "uImazZyUMAuZTLWSqjCh6m1AsMn5rdyI4Uv86klRjEt8Xhz7LbQhGX/+zMVPVnHL1yD3T/8PvJvfiGYF", + "oSiifxZYimbzvbyaea1JWFryRSHSh1wW7oVQd5ovslLp+5+/yvgCJ0rExN00rWLBbG2nOCoMsJHmLuiz", + "/zZUR+f5RnePF6CE9SU4O7/6r96IWilsJj5juS3aXZFB5NNXn5r2nliP0aFiKsz/8lVGKXsEMBOO1476", + "VGxh0+BX/22kDh7nM9tPtIU2++nHBbbuIPfbV+txqzQfIzpbS4eqsJsccRXwVGHXeuQ+kzx6gGepPJsb", + "tqWPKUBXFTYvqEd+JsaQLJIMvj2gPN0DSo2qVWGXHGYaEiwXOjmoHmHj0pUyhy/C90+aqF2usrm27HK6", + "px/4+VK0P1NtizKxO9cwF3hnZIRcSNlcpKBq7wg1rPvkslYpFrLP6ohf+3pWPlr51XW9yT5VIfNN/BvV", + "XItQq9u/CpTD2x6yUOjFn7F474+j3t8Pe3/t/fZv/3Iv0YgAO5jlLx6cTlBRpI95bAi48tfeayGxSX3v", + "KNboWczAWD7LnZCj5vzo2a2mpsF99reCay4tULzcCNjF6+Pvv//+r/31LyCNrVxSPMq9duJjWe67EbeV", + "54fP1zE2FpcTWcYEFoucaDCmy3LsZ8GsXpDvk2o8NsF9AVYvekdj98NqKdxiMqFcUWyrgR0ghWRVw/zQ", + "fVEviAmqQ5SxbM8isWwfv+KEUyrFa5AXqYH6FhIlE6Q9WvMHLzxjm4f2pyjzAdYplLAaZXquBNmv8Gto", + "XKnLXT5agh3Psvq0TbCtdECNhN49tfJtLrJW9z5bx6JeCHyFFaIQAmUV90qu9dl7Kjlbl3U5aHZ2gi0Q", + "sbb5RBiLXRqxZLWTIP1VLKt8HZJV/vQ4rq1xf/PKh8J93oLhVuVN9UPgNgnPwKo/QKsD389+bZsQuiu4", + "iX55S0UL3QxY+EMxN0vXIZfrNMPry5j9dHV1zqzm47FImJJM2D475lkWaoUcnZ9RiWxh3JS3Tlvd8htg", + "wrIRJLwwwD5IcaP52NKvofN44hs73YBvUrIIRQxCzskvb6OlPuiYl+7kV+rvoFVnm7BG/L5nVc+dknlY", + "pY+CnLMUZrmypDb8zAhXCFCtgai/ijiQ6/F2AcYqDcaXzaSpy6OUnQiqNbpO/qpbNCEQms3NkNWAFo1I", + "MyCE0tjSzPnlLZPKlxLBytnG2zZTyFLGHdqir+zy4bgB+USooYk3YcZCBjNn+2wstFNvyFSOapba67Pw", + "8YvDF0yMa99R1e6qSGq09czfwF6V+3lC71e5yKXlNup2v4of8L6222p3q/b5y8qVS+KMa98Eg/JdCSGt", + "iECtlnALE6rEC3cOWMIRhsH6EfU6Kmyk0gVWk6Wg7vRVuMnVp9BgOY0TuqQEQx36zU6oZ76vPxpOY8xJ", + "qpaxJU+8ZNjdnyUZcG1CsabaKWPdixz0mkT0BB16KfCiXKZeaPPT+XDvTcWfK2M6VrJzHSMUsb45YDdQ", + "fqDD54fPmnR4y4kQa36UiiZf+fAqN+7QjRPWDXgsUn1FYtf9XymjvfrZTUSeF/bzUfcXT827Zgs9zYYM", + "fN5wost1Cqah9GvpH3Fj7Ez+E7tjcEmedyZCJmi1AD0EdNmE+xZ6mMKYYPnv5W3U2fOQ0k/xa2PERELK", + "QM4hUzlUpplf1jCeBufi88MXkd/HIqNL2p5UYflQUNqnduG335nAwshywTbe77qvXhweOhtpzjOR8rJX", + "fUtrjfNilAlTaRt6LnmiN0NaC5f4TG+G1Tk9kqJxdYiOnHbrxGqJ0YTr0BOgwndo698n7o1YyzQhTxLI", + "kbwKW2F6Pa29ImkftvKASuzNFn804RYsscxsK8+Ky/51kNSqdw6s+cJWzUwM22enPJmyseYzCjvGYhdK", + "z9hQpC/ZnwZ+/3h9LVNu+Uv2Z0BBz+Hb/f36Wg6dZiPY+8r/ZUO2BIzpzZRUVkmR4HNeDtqgoyvRypgl", + "cecTAV8xzt5wY3uIsd7ZCd24sTeR17huoIQkBDQjl+F1WIMpZuGSTcfusxOtctoUhTARwic8N8F8HYp0", + "SB1BsP+P9xiAmENKvwlDNSPslEv2jPEp8DQEWGdurwZA4qfd8LJ4C9oJCoFZwmVX9lExHoPus+NM4Fe+", + "k6jVPLmJzOYsgxQsJBb322evMda8Oj51gHKXzibI0MFWLVtZ2R5VDhmYxGAAsJx2oIerKbBb4WA15Tkm", + "NGDjQJCgRcKGTRk1pO6mIbjdnxy8ye+56mdsEkItGNme+3yBzYocpVBLPc5SlRQzkG7U0C5yGFK7rVLQ", + "Dqm7iKMXpWdleY2q9Y23Nf4Vt3WCH5O46TIDGSR+PzR5tBcfEkvzeBtr2F04cgt9O9AwM01e8H21lGYG", + "ZMoOKSM+iprQwG5bfuoyo5pMMedZQdH/M3AsojUkWDWBluJuDYHtucKDGT19VC9mDRr6fFkpWymIN1tI", + "t68uYWX5BIwbdonPn71LRySeLN3o/zcAAP//6cbNyHK4AQA=", } // 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 edb649c4..f853ff5e 100644 --- a/server/lib/recorder/ffmeg_test.go +++ b/server/lib/recorder/ffmeg_test.go @@ -18,11 +18,13 @@ func defaultParams(tempDir string) FFmpegRecordingParams { fr := 5 disp := 0 size := 1 + audioSource := "KernelOutput.monitor" return FFmpegRecordingParams{ FrameRate: &fr, DisplayNum: &disp, MaxSizeInMB: &size, OutputDir: &tempDir, + AudioSource: &audioSource, } } @@ -66,6 +68,7 @@ func TestFFmpegRecorder_Params(t *testing.T) { assert.Equal(t, *params.DisplayNum, *got.DisplayNum) assert.Equal(t, *params.MaxSizeInMB, *got.MaxSizeInMB) assert.Equal(t, *params.OutputDir, *got.OutputDir) + assert.Equal(t, *params.AudioSource, *got.AudioSource) } func TestFFmpegArgs_PadsOddDimensions(t *testing.T) { @@ -83,6 +86,93 @@ func TestFFmpegArgs_PadsOddDimensions(t *testing.T) { assert.Equal(t, "pad=ceil(iw/2)*2:ceil(ih/2)*2", vf) } +func TestFFmpegRecordingParams_ValidateAudioConfig(t *testing.T) { + base := func() FFmpegRecordingParams { + fr, disp, size := 5, 0, 1 + dir := t.TempDir() + return FFmpegRecordingParams{FrameRate: &fr, DisplayNum: &disp, MaxSizeInMB: &size, OutputDir: &dir} + } + yes := true + src := "KernelOutput.monitor" + server := "unix:/tmp/pulse/native" + + t.Run("audio off is always valid", func(t *testing.T) { + require.NoError(t, base().Validate()) + }) + t.Run("audio on with source and server is valid", func(t *testing.T) { + p := base() + p.RecordAudio = &yes + p.AudioSource = &src + p.PulseServer = &server + require.NoError(t, p.Validate()) + }) + t.Run("audio on without source is rejected", func(t *testing.T) { + p := base() + p.RecordAudio = &yes + p.PulseServer = &server + require.Error(t, p.Validate()) + }) + t.Run("audio on without server is rejected", func(t *testing.T) { + p := base() + p.RecordAudio = &yes + p.AudioSource = &src + require.Error(t, p.Validate()) + }) +} + +func TestFFmpegArgs_IncludesPulseAudioWhenEnabled(t *testing.T) { + tempDir := t.TempDir() + params := defaultParams(tempDir) + recordAudio := true + pulseServer := "unix:/tmp/pulse/native" + params.RecordAudio = &recordAudio + params.PulseServer = &pulseServer + + args, err := ffmpegArgs(params, filepath.Join(tempDir, "out.mp4")) + require.NoError(t, err) + + assert.Contains(t, args, "-f") + assert.Contains(t, args, "pulse") + assert.Contains(t, args, "KernelOutput.monitor") + assert.Contains(t, args, "-map") + assert.Contains(t, args, "1:a:0") + assert.Contains(t, args, "-preset") + assert.Contains(t, args, "veryfast") + assert.Contains(t, args, "-tune") + assert.Contains(t, args, "zerolatency") + assert.Contains(t, args, "-c:a") + assert.Contains(t, args, "aac") + assert.NotContains(t, args, "aresample=async=1") + assert.NotContains(t, args, "aresample=async=1:first_pts=0") +} + +func TestFFmpegArgs_VideoOnlyKeepsLegacyFlags(t *testing.T) { + tempDir := t.TempDir() + + // Video-only must stay identical to the pre-audio behavior: wall-clock + // timestamps on, and none of the audio-path-only encoder/buffer flags. + videoArgs, err := ffmpegArgs(defaultParams(tempDir), filepath.Join(tempDir, "v.mp4")) + require.NoError(t, err) + assert.Contains(t, videoArgs, "-use_wallclock_as_timestamps") + assert.NotContains(t, videoArgs, "-thread_queue_size") + assert.NotContains(t, videoArgs, "-preset") + assert.NotContains(t, videoArgs, "-tune") + + // Recording audio drops wall-clock stamping (to keep the two inputs synced) and + // adds the real-time encoder + buffer headroom flags. + p := defaultParams(tempDir) + recordAudio := true + pulseServer := "unix:/tmp/pulse/native" + p.RecordAudio = &recordAudio + p.PulseServer = &pulseServer + audioArgs, err := ffmpegArgs(p, filepath.Join(tempDir, "a.mp4")) + require.NoError(t, err) + assert.NotContains(t, audioArgs, "-use_wallclock_as_timestamps") + assert.Contains(t, audioArgs, "-thread_queue_size") + assert.Contains(t, audioArgs, "-preset") + assert.Contains(t, audioArgs, "-tune") +} + func TestFFmpegRecorder_ForceStop(t *testing.T) { tempDir := t.TempDir() rec := &FFmpegRecorder{ diff --git a/server/lib/recorder/ffmpeg.go b/server/lib/recorder/ffmpeg.go index a44c0286..5308d30d 100644 --- a/server/lib/recorder/ffmpeg.go +++ b/server/lib/recorder/ffmpeg.go @@ -36,6 +36,11 @@ const ( // currently being finalized (remuxed to add duration metadata). var ErrRecordingFinalizing = errors.New("recording is being finalized") +// ErrInvalidParams indicates the requested recording parameters are invalid for +// this server (e.g. audio requested without a configured source/socket). Callers +// can use errors.Is to surface a client-facing error instead of a 500. +var ErrInvalidParams = errors.New("invalid recording parameters") + // FFmpegRecorder encapsulates an FFmpeg recording session with platform-specific screen capture. // It manages the lifecycle of a single FFmpeg process and provides thread-safe operations. type FFmpegRecorder struct { @@ -69,6 +74,9 @@ type FFmpegRecordingParams struct { // MaxDurationInSeconds optionally limits the total recording time. If nil there is no duration limit. MaxDurationInSeconds *int OutputDir *string + RecordAudio *bool + AudioSource *string + PulseServer *string } func (p FFmpegRecordingParams) Validate() error { @@ -87,10 +95,39 @@ func (p FFmpegRecordingParams) Validate() error { if p.MaxDurationInSeconds != nil && *p.MaxDurationInSeconds <= 0 { return fmt.Errorf("max duration must be greater than 0 seconds") } + // Audio capture is opt-in per recording. When enabled, the server must have + // both the pulse source to read and the daemon socket to reach it (the image + // configures both); otherwise ffmpeg would fail at runtime. + if p.recordAudio() { + if strings.TrimSpace(p.audioSource()) == "" { + return fmt.Errorf("audio source is required when recording audio") + } + if strings.TrimSpace(p.pulseServer()) == "" { + return fmt.Errorf("pulse server is required when recording audio") + } + } return nil } +func (p FFmpegRecordingParams) recordAudio() bool { + return p.RecordAudio != nil && *p.RecordAudio +} + +func (p FFmpegRecordingParams) audioSource() string { + if p.AudioSource == nil { + return "" + } + return *p.AudioSource +} + +func (p FFmpegRecordingParams) pulseServer() string { + if p.PulseServer == nil { + return "" + } + return *p.PulseServer +} + type FFmpegRecorderFactory func(id string, overrides FFmpegRecordingParams) (Recorder, error) // NewFFmpegRecorderFactory returns a factory that creates new recorders. The provided @@ -99,6 +136,9 @@ type FFmpegRecorderFactory func(id string, overrides FFmpegRecordingParams) (Rec func NewFFmpegRecorderFactory(pathToFFmpeg string, config FFmpegRecordingParams, ctrl scaletozero.Controller) FFmpegRecorderFactory { return func(id string, overrides FFmpegRecordingParams) (Recorder, error) { mergedParams := mergeFFmpegRecordingParams(config, overrides) + if err := mergedParams.Validate(); err != nil { + return nil, fmt.Errorf("%w: %w", ErrInvalidParams, err) + } return &FFmpegRecorder{ id: id, binaryPath: pathToFFmpeg, @@ -116,6 +156,9 @@ func mergeFFmpegRecordingParams(config FFmpegRecordingParams, overrides FFmpegRe MaxSizeInMB: config.MaxSizeInMB, MaxDurationInSeconds: config.MaxDurationInSeconds, OutputDir: config.OutputDir, + RecordAudio: config.RecordAudio, + AudioSource: config.AudioSource, + PulseServer: config.PulseServer, } if overrides.FrameRate != nil { merged.FrameRate = overrides.FrameRate @@ -132,6 +175,15 @@ func mergeFFmpegRecordingParams(config FFmpegRecordingParams, overrides FFmpegRe if overrides.OutputDir != nil { merged.OutputDir = overrides.OutputDir } + if overrides.RecordAudio != nil { + merged.RecordAudio = overrides.RecordAudio + } + if overrides.AudioSource != nil { + merged.AudioSource = overrides.AudioSource + } + if overrides.PulseServer != nil { + merged.PulseServer = overrides.PulseServer + } return merged } @@ -170,6 +222,18 @@ func (p FFmpegRecordingParams) clone() FFmpegRecordingParams { v := *p.OutputDir c.OutputDir = &v } + if p.RecordAudio != nil { + v := *p.RecordAudio + c.RecordAudio = &v + } + if p.AudioSource != nil { + v := *p.AudioSource + c.AudioSource = &v + } + if p.PulseServer != nil { + v := *p.PulseServer + c.PulseServer = &v + } return c } @@ -472,44 +536,79 @@ func (fr *FFmpegRecorder) Delete(ctx context.Context) error { // ffmpegArgs generates platform-specific ffmpeg command line arguments. Allegedly order matters. func ffmpegArgs(params FFmpegRecordingParams, outputPath string) ([]string, error) { var args []string + recordAudio := params.recordAudio() // Input options first switch runtime.GOOS { case "darwin": + // AVFoundation captures video and audio through a single combined input + // spec ("