diff --git a/src/matches/clips/clips.service.ts b/src/matches/clips/clips.service.ts index aa3672f1..4e985a12 100644 --- a/src/matches/clips/clips.service.ts +++ b/src/matches/clips/clips.service.ts @@ -217,6 +217,10 @@ export class ClipsService { ); try { + const outroEnv = await this.gameStreamer.resolveOutroBranding( + dims, + spec.output.fps, + ); await this.gameStreamer.dispatchClipRenderToPod(session.id, { job_id: jobId, token: sessionToken, @@ -229,6 +233,7 @@ export class ClipsService { })), output_dims: dims, output_fps: spec.output.fps, + outro_env: outroEnv, }); } catch (error) { this.logger.error( diff --git a/src/matches/game-streamer/game-streamer.nade-previews.spec.ts b/src/matches/game-streamer/game-streamer.nade-previews.spec.ts index 7642fe92..8143373f 100644 --- a/src/matches/game-streamer/game-streamer.nade-previews.spec.ts +++ b/src/matches/game-streamer/game-streamer.nade-previews.spec.ts @@ -87,6 +87,7 @@ describe("GameStreamerService — nade previews", () => { {} as any, steamAccounts as any, { resolveDefault: jest.fn().mockResolvedValue(null) } as any, + {} as any, ); }); diff --git a/src/matches/game-streamer/game-streamer.service.spec.ts b/src/matches/game-streamer/game-streamer.service.spec.ts index ed9f4766..71ded6d2 100644 --- a/src/matches/game-streamer/game-streamer.service.spec.ts +++ b/src/matches/game-streamer/game-streamer.service.spec.ts @@ -6,6 +6,11 @@ jest.mock("@kubernetes/client-node", () => ({ })); import { GameStreamerService } from "./game-streamer.service"; +import { + DEFAULT_OUTRO_ACCENT, + computeOutroVersion, + outroCacheKey, +} from "./outro-branding"; describe("GameStreamerService", () => { let service: GameStreamerService; @@ -16,6 +21,12 @@ describe("GameStreamerService", () => { resolveEnabled: jest.Mock; bundleUrl: jest.Mock; }; + let s3: { + stat: jest.Mock; + has: jest.Mock; + getPresignedUrl: jest.Mock; + getPresignedUrlOrigin: jest.Mock; + }; const config = { get: (key: string) => { @@ -23,7 +34,10 @@ describe("GameStreamerService", () => { return { namespace: "test" }; } if (key === "app") { - return { relayDomain: "https://tv.example.test" }; + return { + relayDomain: "https://tv.example.test", + demosDomain: "https://demos.example.test", + }; } return {} as any; }, @@ -37,6 +51,12 @@ describe("GameStreamerService", () => { resolveEnabled: jest.fn(), bundleUrl: jest.fn().mockResolvedValue(null), }; + s3 = { + stat: jest.fn(), + has: jest.fn(), + getPresignedUrl: jest.fn(), + getPresignedUrlOrigin: jest.fn(), + }; service = new GameStreamerService( logger as any, @@ -48,6 +68,7 @@ describe("GameStreamerService", () => { {} as any, {} as any, broadcastHuds as any, + s3 as any, ); }); @@ -333,6 +354,63 @@ describe("GameStreamerService", () => { }); }); + describe("resolveS3PublicOrigin", () => { + // A remote store signs the outro URLs against its own host, so the + // demos domain would fail the pod's outro URL allowlist. + it("is the origin the outro urls are presigned against", async () => { + s3.getPresignedUrlOrigin.mockResolvedValue( + "https://5stack.s3.example.test", + ); + + await expect((service as any).resolveS3PublicOrigin()).resolves.toBe( + "https://5stack.s3.example.test", + ); + }); + + it("falls back to the demos domain so the pod still starts", async () => { + s3.getPresignedUrlOrigin.mockRejectedValue(new Error("bad endpoint")); + + await expect((service as any).resolveS3PublicOrigin()).resolves.toBe( + "https://demos.example.test", + ); + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining("bad endpoint"), + ); + }); + }); + + describe("resolveOutroBranding", () => { + // game-streamer drops the whole outro env for an accent that is not an + // HSL triple, so the stock amber is what gets rendered, and the version + // has to hash that accent rather than the setting. + it("renders and hashes the stock accent when the setting is not an HSL triple", async () => { + const settings: Record = { + "public.logo_url": "branding/logo.png", + "public.brand_name": "Adria", + "public.color_dark_tactical_amber": "0 0% 0%) url(http://10.0.0.1/x", + }; + hasura.query.mockImplementation(async (query: any) => ({ + settings_by_pk: { value: settings[query.settings_by_pk.__args.name] }, + })); + s3.stat.mockResolvedValue({ etag: "logo-etag" }); + s3.has.mockResolvedValue(false); + s3.getPresignedUrl.mockImplementation( + async (key: string) => `https://s3.example.test/${key}`, + ); + + const env = await service.resolveOutroBranding("1920x1080", 60); + + expect(env.CLIP_BRAND_ACCENT).toBe(DEFAULT_OUTRO_ACCENT); + const version = computeOutroVersion({ + brandName: "Adria", + accent: DEFAULT_OUTRO_ACCENT, + etag: "logo-etag", + }); + const key = outroCacheKey({ version, dims: "1920x1080", fps: 60 }); + expect(env.CLIP_OUTRO_PUT_URL).toBe(`https://s3.example.test/${key}`); + }); + }); + describe("reportStatus", () => { const streamSetOf = () => hasura.mutation.mock.calls[0][0].update_match_streams.__args._set; diff --git a/src/matches/game-streamer/game-streamer.service.ts b/src/matches/game-streamer/game-streamer.service.ts index 8b3d842b..86a4198b 100644 --- a/src/matches/game-streamer/game-streamer.service.ts +++ b/src/matches/game-streamer/game-streamer.service.ts @@ -24,6 +24,14 @@ import { BroadcastHud, BroadcastHudsService, } from "src/broadcast-huds/broadcast-huds.service"; +import { S3Service } from "../../s3/s3.service"; +import { + outroAccentFromSetting, + computeOutroVersion, + outroCacheKey, + buildOutroEnv, + sharedClipOutput, +} from "./outro-branding"; import { LoggingService } from "../../k8s/logging/logging.service"; import { SteamAccountService, @@ -177,6 +185,7 @@ export class GameStreamerService { private readonly loggingService: LoggingService, private readonly steamAccounts: SteamAccountService, private readonly broadcastHuds: BroadcastHudsService, + private readonly s3: S3Service, ) { this.gameServerConfig = this.config.get("gameServers"); this.appConfig = this.config.get("app"); @@ -370,6 +379,84 @@ export class GameStreamerService { return value === "false" || value === "0" ? "0" : "1"; } + // Branded outro env for the render pod. Active only when a custom logo is + // set. Returns {} (stock outro), a presigned cache URL (hit), or a render + // instruction + presigned PUT + branding props (miss). Best-effort: any + // failure returns {} so the pod falls back to the baked stock outro. + public async resolveOutroBranding( + dims: string, + fps: number, + ): Promise> { + try { + const logoPath = await this.readSetting("public.logo_url"); + if (!logoPath) { + return {}; + } + // An empty brandName (no public.brand_name) keeps the stock 5STACK.gg + // wordmark and tagline next to the custom logo, the same fallback the + // web uses (brandName || "5Stack"). The empty name is still part of the + // version hash, so keep the CLIP_BRAND_NAME key with an empty value. + const brandName = (await this.readSetting("public.brand_name")) ?? ""; + // The web themes from the dark palette only (the light-mode + // `public.color_*` rows are deleted at boot), so this is its accent. + // Anything but an HSL triple renders the stock amber, which is then + // what the version hash covers. + const accent = outroAccentFromSetting( + await this.readSetting("public.color_dark_tactical_amber"), + ); + + let etag = logoPath; + try { + etag = (await this.s3.stat(logoPath))?.etag ?? logoPath; + } catch { + /* keep logoPath as the version seed */ + } + + const version = computeOutroVersion({ brandName, accent, etag }); + const key = outroCacheKey({ version, dims, fps }); + + if (await this.s3.has(key)) { + return buildOutroEnv({ + hit: true, + cacheUrl: await this.s3.getPresignedUrl(key, undefined, 3600, "get"), + }); + } + return buildOutroEnv({ + hit: false, + putUrl: await this.s3.getPresignedUrl(key, undefined, 3600, "put"), + logoUrl: await this.s3.getPresignedUrl( + logoPath, + undefined, + 3600, + "get", + ), + brandName, + accent, + }); + } catch (error) { + this.logger.warn( + `resolveOutroBranding failed: ${(error as Error)?.message ?? error}`, + ); + return {}; + } + } + + // The origin render-clip.mjs accepts the outro URLs from. They are signed + // through getPresignedUrl, which uses the demos domain only for the + // in-cluster store; a remote store signs against its own host. + private async resolveS3PublicOrigin(): Promise { + try { + return await this.s3.getPresignedUrlOrigin(); + } catch (error) { + this.logger.warn( + `failed to resolve the S3 presign origin, using the demos domain: ${ + (error as Error)?.message ?? error + }`, + ); + return this.appConfig.demosDomain; + } + } + public async resolveClipFps(): Promise<30 | 60> { const value = (await this.readSetting(SystemSettingName.ClipFps)) ?? @@ -849,6 +936,11 @@ export class GameStreamerService { name: "CLIP_BAKE_BRANDING", value: await this.resolveClipBakeBranding(), }, + // Trusted origin for render-clip.mjs's outro-env URL allowlist: the + // origin resolveOutroBranding's presigned URLs really carry (a remote + // store signs against its own host, not the demos domain). Independent + // of the demo source, so faceit/external demos still brand. + { name: "S3_PUBLIC_ORIGIN", value: await this.resolveS3PublicOrigin() }, ]; if (options.roundTicks != null) { env.push({ @@ -1140,6 +1232,7 @@ export class GameStreamerService { }>; output_dims: string; output_fps: number; + outro_env?: Record; }, ) { const url = this.getDemoSpecUrl(sessionId, "render-clip", "demo"); @@ -2388,6 +2481,26 @@ export class GameStreamerService { name: "CLIP_BAKE_BRANDING", value: await this.resolveClipBakeBranding(), }); + { + // The pod gets one outro env, but each job renders at its own spec + // output (a re-queued job keeps the output it was created with). Key + // the outro on the output all the jobs share so it matches every clip + // it is appended to; when they differ, keep the stock outro. + const output = sharedClipOutput(jobs.map((j) => j.spec)); + if (output) { + const outroEnv = await this.resolveOutroBranding( + output.dims, + output.fps, + ); + for (const [name, value] of Object.entries(outroEnv)) { + env.push({ name, value }); + } + } else { + this.logger.warn( + `[batch-highlights ${matchMapId}] jobs mix clip outputs, using the stock outro`, + ); + } + } env.push(...(await this.buildNodeCs2OptionsEnv(nodeId))); this.logger.log( diff --git a/src/matches/game-streamer/outro-branding.spec.ts b/src/matches/game-streamer/outro-branding.spec.ts new file mode 100644 index 00000000..399269a0 --- /dev/null +++ b/src/matches/game-streamer/outro-branding.spec.ts @@ -0,0 +1,135 @@ +import { + DEFAULT_OUTRO_ACCENT, + computeOutroVersion, + outroCacheKey, + buildOutroEnv, + clipOutputFromSpec, + sharedClipOutput, + outroAccentFromSetting, +} from "./outro-branding"; + +describe("outro-branding", () => { + it("default accent is the stock amber triple", () => { + expect(DEFAULT_OUTRO_ACCENT).toBe("33 94% 58%"); + }); + + // The web saves whole numbers from its color picker and keeps the + // decimals of its stock palette. + it.each([ + ["33 94% 58%", "33 94% 58%"], + ["0 0% 100%", "0 0% 100%"], + ["360 100% 100%", "360 100% 100%"], + ["224.3 76.3% 48%", "224.3 76.3% 48%"], + ["217.2 91.2% 59.8%", "217.2 91.2% 59.8%"], + [" 224.3 76.3% 48%\n", "224.3 76.3% 48%"], + ])("accent keeps the saved triple %p as %p", (value, accent) => { + expect(outroAccentFromSetting(value)).toBe(accent); + }); + + it.each([ + undefined, + "", + " ", + "orange", + "#f59e0b", + "hsl(33 94% 58%)", + "33, 94%, 58%", + "33 94 58", + "33 94% 58%", + "1000 94% 58%", + "33 94% 58%; background: url(http://attacker.test/x)", + "33 94% 58%)} body { display: none", + "33 94% 58%\n", + ])("accent falls back to the stock amber for %p", (value) => { + expect(outroAccentFromSetting(value)).toBe(DEFAULT_OUTRO_ACCENT); + }); + + it("version is deterministic and 12 chars", () => { + const a = computeOutroVersion({ + brandName: "ACME", + accent: "1 2% 3%", + etag: "e1", + }); + const b = computeOutroVersion({ + brandName: "ACME", + accent: "1 2% 3%", + etag: "e1", + }); + expect(a).toBe(b); + expect(a).toHaveLength(12); + }); + + it("version changes when the logo etag changes", () => { + const a = computeOutroVersion({ + brandName: "ACME", + accent: "1 2% 3%", + etag: "e1", + }); + const b = computeOutroVersion({ + brandName: "ACME", + accent: "1 2% 3%", + etag: "e2", + }); + expect(a).not.toBe(b); + }); + + it("cache key embeds version + dims + fps", () => { + expect( + outroCacheKey({ version: "abc123abc123", dims: "1920x1080", fps: 60 }), + ).toBe("branding/outro_abc123abc123_1920x1080_60.mp4"); + }); + + it("hit env is just the cache URL", () => { + expect(buildOutroEnv({ hit: true, cacheUrl: "http://s3/x.mp4" })).toEqual({ + CLIP_OUTRO_URL: "http://s3/x.mp4", + }); + }); + + it("miss env carries render instruction + branding props", () => { + expect( + buildOutroEnv({ + hit: false, + putUrl: "http://put", + logoUrl: "http://logo", + brandName: "ACME", + accent: "33 94% 58%", + }), + ).toEqual({ + CLIP_OUTRO_RENDER: "1", + CLIP_OUTRO_PUT_URL: "http://put", + CLIP_BRAND_LOGO_URL: "http://logo", + CLIP_BRAND_NAME: "ACME", + CLIP_BRAND_ACCENT: "33 94% 58%", + }); + }); + + it("clip output mirrors the pod's job-fields", () => { + expect( + clipOutputFromSpec({ output: { resolution: "720p", fps: 30 } }), + ).toEqual({ dims: "1280x720", fps: 30 }); + expect( + clipOutputFromSpec({ output: { resolution: "1080p", fps: 60 } }), + ).toEqual({ dims: "1920x1080", fps: 60 }); + expect(clipOutputFromSpec({})).toEqual({ dims: "1920x1080", fps: 60 }); + expect(clipOutputFromSpec(null)).toEqual({ dims: "1920x1080", fps: 60 }); + }); + + it("shared clip output is the output every spec has", () => { + const hd30 = { output: { resolution: "720p", fps: 30 } }; + expect(sharedClipOutput([hd30, hd30])).toEqual({ + dims: "1280x720", + fps: 30, + }); + }); + + it("shared clip output is null when the specs differ or there are none", () => { + const hd30 = { output: { resolution: "720p", fps: 30 } }; + expect( + sharedClipOutput([hd30, { output: { resolution: "720p", fps: 60 } }]), + ).toBeNull(); + expect( + sharedClipOutput([hd30, { output: { resolution: "1080p", fps: 30 } }]), + ).toBeNull(); + expect(sharedClipOutput([])).toBeNull(); + }); +}); diff --git a/src/matches/game-streamer/outro-branding.ts b/src/matches/game-streamer/outro-branding.ts new file mode 100644 index 00000000..a2571952 --- /dev/null +++ b/src/matches/game-streamer/outro-branding.ts @@ -0,0 +1,98 @@ +import { createHash } from "node:crypto"; + +export const DEFAULT_OUTRO_ACCENT = "33 94% 58%"; + +// An HSL triple as the web saves a theme color: whole numbers from its +// color picker ("33 94% 58%") or the decimals of its stock palette +// ("224.3 76.3% 48%"). game-streamer checks CLIP_BRAND_ACCENT against the +// same pattern and drops the branded outro for anything else. +const OUTRO_ACCENT_PATTERN = + /^\d{1,3}(\.\d+)? \d{1,3}(\.\d+)?% \d{1,3}(\.\d+)?%$/; + +// The accent the outro renders with: the setting when it is such a triple +// (trimmed), the stock amber otherwise, so the outro version covers the +// accent that is really rendered. +export function outroAccentFromSetting(value: string | undefined): string { + const accent = value?.trim(); + return accent && OUTRO_ACCENT_PATTERN.test(accent) + ? accent + : DEFAULT_OUTRO_ACCENT; +} + +export function computeOutroVersion(parts: { + brandName: string; + accent: string; + etag: string; +}): string { + return createHash("sha1") + .update(`${parts.brandName}|${parts.accent}|${parts.etag}`) + .digest("hex") + .slice(0, 12); +} + +export function outroCacheKey(args: { + version: string; + dims: string; + fps: number; +}): string { + return `branding/outro_${args.version}_${args.dims}_${args.fps}.mp4`; +} + +export interface ClipOutput { + dims: string; + fps: number; +} + +// The dims and fps the render pod gives a clip. Mirrors game-streamer's +// clip-helpers.mjs job-fields, which sets each batch job's CLIP_OUTPUT_DIMS +// and CLIP_OUTPUT_FPS from its spec. +export function clipOutputFromSpec(spec: unknown): ClipOutput { + const output = ( + spec as { output?: { resolution?: unknown; fps?: unknown } } | null + )?.output; + const fps = Number.parseInt(String(output?.fps), 10); + return { + dims: output?.resolution === "720p" ? "1280x720" : "1920x1080", + fps: Number.isFinite(fps) ? fps : 60, + }; +} + +// The output every spec shares, or null when they differ (or there are none). +export function sharedClipOutput(specs: unknown[]): ClipOutput | null { + const [first, ...rest] = specs.map((spec) => clipOutputFromSpec(spec)); + if (!first) { + return null; + } + return rest.every((o) => o.dims === first.dims && o.fps === first.fps) + ? first + : null; +} + +export interface OutroEnvHit { + hit: true; + cacheUrl: string; +} +export interface OutroEnvMiss { + hit: false; + putUrl: string; + logoUrl: string; + brandName: string; + accent: string; +} +export type OutroEnvState = OutroEnvHit | OutroEnvMiss; + +// The api's tsconfig has strict mode off, so a boolean discriminant does not +// narrow the union in the else branch; cast to the concrete variant instead. +export function buildOutroEnv(state: OutroEnvState): Record { + if (state.hit) { + return { CLIP_OUTRO_URL: (state as OutroEnvHit).cacheUrl }; + } + const miss = state as OutroEnvMiss; + return { + CLIP_OUTRO_RENDER: "1", + CLIP_OUTRO_PUT_URL: miss.putUrl, + CLIP_BRAND_LOGO_URL: miss.logoUrl, + CLIP_BRAND_NAME: miss.brandName, + CLIP_BRAND_ACCENT: miss.accent, + }; +} diff --git a/src/matches/matches.controller.spec.ts b/src/matches/matches.controller.spec.ts index 33ee1279..f540bfde 100644 --- a/src/matches/matches.controller.spec.ts +++ b/src/matches/matches.controller.spec.ts @@ -309,6 +309,7 @@ describe("MatchesController", () => { {} as any, {} as any, {} as any, + {} as any, ); clips = { diff --git a/src/s3/s3.service.spec.ts b/src/s3/s3.service.spec.ts index a05024de..7cec56f7 100644 --- a/src/s3/s3.service.spec.ts +++ b/src/s3/s3.service.spec.ts @@ -198,3 +198,28 @@ describe("S3Service.removePrefix", () => { ).rejects.toThrow(/AccessDenied/); }); }); + +// getPresignedUrl is stubbed for the same reason as above: its files-sdk path +// cannot run here. +describe("S3Service presigned url origin", () => { + it("is the origin of a url getPresignedUrl signs for the bucket", async () => { + const service = build("s3.us-east-005.backblazeb2.com", "443", true, { + forcePathStyle: false, + }); + const sign = jest + .spyOn(service, "getPresignedUrl") + .mockResolvedValue( + "https://5stack.s3.us-east-005.backblazeb2.com/probe?X-Amz-Signature=x", + ); + + await expect(service.getPresignedUrlOrigin()).resolves.toBe( + "https://5stack.s3.us-east-005.backblazeb2.com", + ); + + // The default bucket, signed for outside the cluster like the URLs that + // get handed to pods. + const [, bucket, , , useLocal] = sign.mock.calls[0]; + expect(bucket).toBe("5stack"); + expect(useLocal).toBeFalsy(); + }); +}); diff --git a/src/s3/s3.service.ts b/src/s3/s3.service.ts index 4e109055..3696fc97 100644 --- a/src/s3/s3.service.ts +++ b/src/s3/s3.service.ts @@ -531,6 +531,21 @@ export class S3Service implements OnModuleDestroy { return await client.url(key, { expiresIn: expires }); } + // The origin of the URLs getPresignedUrl signs for a bucket (without + // useLocal): the public demos domain for the in-cluster store, otherwise the + // store's own host (with the bucket in it under virtual-host style). It is + // read off a real signed URL rather than rebuilt from the config, so it + // follows whatever addressing the SDK picks; GET and PUT URLs come from the + // same client and share it. Signing is local, so the probe key is never + // requested. + public async getPresignedUrlOrigin( + bucket: string = this.bucket, + ): Promise { + const url = await this.getPresignedUrl("origin-probe", bucket, 60, "get"); + + return new URL(url).origin; + } + public async createMultipartUpload( key: string, bucket: string = this.bucket,